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

Prism

Prism is a visualization library for materialized tabular data. It compiles declarative JSON specs into charts — server-side SVG/PNG via Go, and live in-browser via web components — using Vega-Lite-inspired vocabulary with snake_case naming and structured, expression-free transforms. Data enters as inline rows (data.values / datasets.*.values) or via a caller-supplied DataResolver; Prism never reads .pulse files.

Install

go install github.com/frankbardon/prism/cmd/prism@latest

60-second tour

prism init                          # writes .prism/ with schemas + examples
prism plot .prism/examples/bar_basic.json > bar.svg
prism plot --theme=dark bar.json > bar-dark.svg
prism serve --addr :8080            # Twirp + /prism/scene endpoint
prism mcp                            # MCP server over stdio

Try it now

  • Interactive Playground — edit a spec and see it render live, entirely in your browser via WASM. ~25 curated examples covering marks, composition, transforms, and themes.

Where to go next

Getting started

Install

go install github.com/frankbardon/prism/cmd/prism@latest
prism version    # prism v0.2.0

Bootstrap a project

mkdir my-project && cd my-project
prism init

This writes:

.prism/
├── schemas/         # JSON Schema files (offline validation + autocomplete)
├── examples/        # 8 curated starter specs
├── editor/          # VSCode / JetBrains / Neovim / Vim config templates
└── README.md

First chart

cp .prism/examples/bar_basic.json my-chart.prism.json
prism plot my-chart.prism.json > chart.svg
open chart.svg

Providing data

Prism never reads data files itself — a spec carries its rows inline, or the host CLI supplies them at run time. The two ways to bind data:

  • Inline — put the rows directly in the spec under data.values (optionally typed with data.fields). This needs no flag and is how every starter example works:

    {
      "data": {"values": [{"brand": "alpha", "score": 0.42}]},
      "mark": "bar",
      "encoding": {
        "x": {"field": "brand", "type": "nominal"},
        "y": {"field": "score", "type": "quantitative"}
      }
    }
    
  • --data rows.json — when a spec’s data block names an external source (data.source / data.ref), pass a JSON rows file and the CLI feeds those rows to the resolver:

    prism plot chart.json --data rows.json > chart.svg
    

    where rows.json is a flat array of row objects:

    [{"brand": "alpha", "score": 0.42}, {"brand": "beta", "score": 0.71}]
    

    The --data flag is accepted by plot, plan, execute, and scene.

Editor setup

Each entry in .prism/editor/ has a header comment with install instructions. The fastest path:

  • VSCode — copy .prism/editor/vscode-settings.json into .vscode/settings.json. *.prism.json files get autocomplete + inline validation from the embedded schema.
  • JetBrains — copy .prism/editor/jetbrains.xml to .idea/jsonSchemas.xml.
  • Neovim — paste the .prism/editor/neovim.lua snippet into your init.lua (requires nvim-lspconfig).
  • Vim — paste the .prism/editor/vim.alelint block into your .vimrc (requires dense-analysis/ale and prism in PATH).

Validating a spec

prism validate my-chart.prism.json

Returns valid on stdout (exit 0) or one or more PRISM_* errors with fixup suggestions. Add --json for machine-readable envelopes.

Rendering formats

prism plot my-chart.prism.json --format svg > chart.svg
prism plot dashboard.json --format svg > dashboard.svg
prism plot my-chart.prism.json --format html > chart.html

svg (default) and html are both built in — html wraps the same SVG output in a standalone HTML document, so it picks up the same theme. See Themes: Rendering backends.

Themes

prism plot bar.json --theme=dark > bar-dark.svg
prism plot bar.json --theme=print > bar-print.svg

Bundled themes: light (default), dark, print. Custom themes via theme.json — see Themes concepts.

Geographic charts

geoshape / geopoint marks need map tier geometry, which the host binary loads at runtime rather than embedding. Point the loader at a directory of tier files with --geodata-dir (or the PRISM_GEODATA environment variable):

prism plot world.json --geodata-dir ./geodata > world.svg
PRISM_GEODATA=./geodata prism plot world.json > world.svg

A repo checkout already has the tiers in its geodata/ directory; for a standalone install, download world-110m.geo.json (and world-50m / admin1-50m if your specs use them) from https://frankbardon.github.io/prism/static/prism/geodata/ into a folder and pass that folder. Without a directory, rendering a geo mark fails with PRISM_GEODATA_DIR_UNSET. The flag is accepted by plot, scene, serve, mcp, and static-bundle. See Geographic Marks for the full workflow.

Embed in a static page (no server)

Prism ships as a WebAssembly module that renders client-side. Build the bundle, copy it into your site:

make build-wasm-tinygo
./bin/prism static-bundle --wasm ./public/prism

Then drop a <prism-chart> element into any HTML page:

<script src="/prism/wasm_exec.js"></script>
<script type="module" src="/prism/prism-element.mjs"></script>
<prism-chart spec="/specs/my-chart.prism.json"></prism-chart>

See Browser / WASM concepts and the static-site cookbook for mdBook / Astro / Hugo integration recipes.

What’s next

Migrating from Vega-Lite

Prism borrows Vega-Lite’s vocabulary (mark, encoding, transform, layer, facet) and channel model. The divergences are intentional — read this guide to port specs in minutes.

At a glance

Vega-LitePrismWhy divergence
data.urlinline data.values / datasets.*.values (or a runtime ref)Prism reads already-materialized rows; it never fetches a URL or reads a .pulse file.
transform[].aggregatesame shapeidentical
op: "mean"samefriendly aliases match Vega-Lite verbatim
mark, encodingsame vocabularysame
type: "quantitative"samenominal/ordinal/quantitative/temporal
scale.schemesamesame color schemes
selectionsame shapepoint + interval supported v1
params / signalsdroppedno reactive runtime
layer, concat, facet, repeatsamefull composition v1
condition encodingssame shapeselection + test predicate conditions supported
strokeWidth (camelCase)stroke_widthsnake_case throughout
xOffset / yOffsetx_offset / y_offsetsnake_case; same grouped-bar primitive — see Offset (dodge) channels
Vega expression languagestructured filter / calculate built-insno expression language, no JS eval

snake_case (D019)

All field names in spec + scene IR are snake_case. Single-word Vega-Lite vocabulary (mark, encoding, transform, layer, facet) stays as-is.

Vega-LitePrism
strokeWidthstroke_width
cornerRadiuscorner_radius
fontSizefont_size
tickCounttick_count
labelOverlaplabel_overlap
xOffsetx_offset
yOffsety_offset

Offset (dodge) channels

Vega-Lite’s xOffset / yOffset are x_offset / y_offset here — the same grouped-bar primitive, renamed by the snake_case rule above. The camelCase spelling is rejected at decode (PRISM_SPEC_009, unknown field), never silently dropped, so a ported spec tells you immediately rather than rendering an ungrouped chart.

{
  "$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}
  ]},
  "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"}
  }
}

Two behavioural divergences to know before porting a dodged chart:

Grouped and stacked bars are not supported. An explicit stack written beside a bound offset is rejected with PRISM_SPEC_065 rather than half-applied — the two spend the same geometry on the same grouping. Dodging alone needs no stack key at all, because the implicit bar/area stack yields to a bound offset. For a stack inside each dodged group, draw the dodged chart and split the stacking field out with facet.

Horizontal groups are mirrored. Prism’s y band scale runs bottom-to-top, and sub-bands run in the same direction the parent band assigns its own categories, so with y_offset the first offset category takes the lower sub-band of each slot. Vega-Lite’s y band runs top-to-bottom, so the identical spec placed side by side looks flipped. Pin the order explicitly with "x_offset": {"scale": {"domain": [...]}} (or y_offset) when the comparison matters.

Sub-band ordering otherwise matches Vega-Lite: scale.domain first, then a sort naming categories, then a sort direction, then the distinct values ascending. See Encoding › Offset channels.

Structured transforms (D005)

Prism has no expression language. Vega-Lite’s inline expression strings for filter predicates and calculate computed columns are replaced by structured built-ins — JSON object trees. A raw string where a predicate or expression is expected is rejected at decode time.

Vega-LitePrism
"filter": "datum.score > 50""filter": {"op": "gt", "field": "score", "value": 50}
"filter": "datum.region === 'NA'""filter": {"op": "eq", "field": "region", "value": "NA"}
"filter": "datum.a > 0 && datum.b != null""filter": {"and": [{"op": "gt", "field": "a", "value": 0}, {"op": "not_null", "field": "b"}]}
"calculate": "datum.x * 2", "as": "y""calculate": {"op": "mul", "operands": [{"field": "x"}, {"literal": 2}]}, "as": "y"
"calculate": "datum.x == null ? 0 : datum.x", "as": "y""calculate": {"fn": "coalesce", "args": [{"field": "x"}, {"literal": 0}]}, "as": "y"

No datum. prefix, no operators, no JS function calls. See Spec › Filter transform and Spec › Calculate transform for the full grammar (operators, functions, case, and null / division semantics).

Aggregate aliases (D003)

Vega-Lite parity:

count sum mean median min max stdev variance q1 q3 ci0 ci1

Prism adds: distinct mode.

Cohort-analytics extensions (Prism-only): wmean ratio lift share.

Dropped features (v1)

  • params / signals — no reactive runtime.
  • Inline Vega expressions everywhere — use the structured filter / calculate built-ins, or pre-compute richer logic before the data reaches Prism.
  • Vega-Lite tooltip template strings — Prism tooltips are pre-formatted TooltipLine lists.

Added features

  • datasets block + per-layer data overrides — first-class multi-source.
  • Hash join transform ({join: {left, right, on, kind}, as}) — in-Prism, no Pulse change.
  • Cohort-analytics aggregates (wmean, lift, share, ratio).
  • sankey, funnel, sparkline marks — first-class, not third-party plugins.
  • Server-side + browser-side dataset registries.
  • MCP tool surface for agent integration.

Worked porting example

Vega-Lite:

{
  "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
  "data": {"url": "data/cars.json"},
  "transform": [{"filter": "datum.Horsepower > 100"}],
  "mark": {"type": "bar", "cornerRadius": 4},
  "encoding": {
    "x": {"field": "Origin", "type": "nominal"},
    "y": {"aggregate": "mean", "field": "Horsepower", "type": "quantitative"},
    "color": {"field": "Origin"}
  }
}

Prism:

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"values": [
    {"Origin": "USA",    "Horsepower": 130},
    {"Origin": "Europe", "Horsepower": 105},
    {"Origin": "Japan",  "Horsepower": 95}
  ]},
  "transform": [{"filter": {"op": "gt", "field": "Horsepower", "value": 100}}],
  "mark": {"type": "bar", "corner_radius": 4},
  "encoding": {
    "x": {"field": "Origin", "type": "nominal"},
    "y": {"aggregate": "mean", "field": "Horsepower", "type": "quantitative"},
    "color": {"field": "Origin", "type": "nominal"}
  }
}

Diffs:

  • $schema: URN form.
  • data.url → inline data.values (the caller materializes the rows; Prism reads no URL or .pulse file).
  • filter: expression string → structured {op, field, value} predicate.
  • cornerRadius → corner_radius.
  • color channel: explicit type (Vega-Lite infers; Prism is strict).

Editor setup

prism init writes .prism/editor/ with configs for VSCode, JetBrains, Neovim, Vim — autocomplete + inline validation on *.prism.json files from the embedded JSON Schema bundle.

Spec

A Prism Spec is a JSON document describing one chart. It is the contract between authors (humans / agents) and the Prism pipeline.

Six-stage pipeline

Spec (JSON) → Parse → Validate → Plan → Compile → Encode → Render → Bytes
                                          │
                                          ├─→ Pulse engine (data ops)
                                          └─→ Renderer backend (SVG / Canvas)

Minimum viable spec

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"values": [{"brand_id": "a", "score": 0.62}, {"brand_id": "b", "score": 0.55}]},
  "mark": "bar",
  "encoding": {
    "x": {"field": "brand_id", "type": "nominal"},
    "y": {"field": "score",    "type": "quantitative", "aggregate": "mean"}
  }
}

Five top-level keys are typically present:

KeyPurpose
$schemaURN identifier (urn:prism:schema:v1:spec) for editor autocomplete + version pinning.
dataWhere the rows come from — an inline values array, a runtime ref (resolved by a DataResolver), a named alias, or a geodata feature_collection. The external .pulse source variant was removed (PRISM_SPEC_039).
transformOptional array of row-level operations (filter, calculate, aggregate, sort, …).
markWhat to draw — bar, line, point, pie, sankey, …
encodingHow to bind data fields to visual channels (x/y/color/size/…).

Full top-level field list

$schema       data            datasets        transform
mark          encoding        layer           concat
hconcat       vconcat         facet           repeat
spec          selection       resolve         theme
width         height          padding         background
title         subtitle        description     projection
animation

Exactly one of mark | layer | concat | hconcat | vconcat | facet | repeat must be present. The validator enforces this with PRISM_SPEC_* codes.

Animation

The optional animation block requests a client-side tween whenever the spec swaps. Static SVG output is unaffected — the renderer ignores the block entirely. Only the browser web component (<prism-chart>) and the WASM runtime honour it.

{
  "$schema": "urn:prism:schema:v1:spec",
  "data":    {"name": "sales", "values": [...]},
  "mark":    "bar",
  "encoding": {
    "x": {"field": "region", "type": "nominal", "key": true},
    "y": {"aggregate": "mean", "field": "score", "type": "quantitative"}
  },
  "animation": {"duration_ms": 600, "easing": "cubic_in_out"}
}

Fields:

FieldDefaultNotes
duration_ms400Total tween length, capped at 5000.
easingcubic_in_outOne of linear, cubic_*, quad_*, sine_*, expo_* (× in/out/in_out).
stagger_ms0Per-mark delay applied in document order.
enterfadefade or none. Marks that appear at scene-swap time.
exitfadefade or none. Marks that disappear at scene-swap time.

For the tween to match marks across scene swaps (object constancy), declare a join key on one encoding channel via "key": true. Without a key, validation fires PRISM_SPEC_023.

Animation respects the user’s prefers-reduced-motion setting: the animator snaps directly to the final state when the preference is reduce.

When two scenes are structurally incompatible (different layer count, different mark families, etc.) the animator falls back to an instant replace and emits PRISM_WARN_ANIM_FALLBACK on the prism:warn CustomEvent stream.

Spec rules that govern animation:

  • PRISM_SPEC_022 — unknown easing name.
  • PRISM_SPEC_023 — block declared but no channel has key: true.
  • PRISM_SPEC_024 — more than one channel carries key: true.

Filter transform

filter keeps the rows for which a structured predicate evaluates true. The predicate is a JSON object tree, never an expression string — a raw string value is rejected at decode time. Each predicate node is exactly one of a leaf test or a boolean combinator.

Leaf comparisons — eq, ne, lt, lte, gt, gte — compare a field against a literal (value) or against another column (to_field, a field-vs-field compare):

"transform": [
  {"filter": {"op": "gt", "field": "Horsepower", "value": 100}},
  {"filter": {"op": "eq", "field": "Origin", "value": "USA"}},
  {"filter": {"op": "lt", "field": "sale_price", "to_field": "list_price"}}
]

Set membership — one_of / not_one_of — tests a field against a non-empty candidate set:

{"filter": {"op": "one_of", "field": "Origin", "values": ["USA", "Europe"]}}

Inclusive range — between — keeps rows where lo <= field <= hi:

{"filter": {"op": "between", "field": "year", "lo": 2010, "hi": 2019}}

Null checks — is_null / not_null — take only a field:

{"filter": {"op": "not_null", "field": "quota_mean"}}

Boolean combinators — and / or / not — nest predicates to any depth. A combinator node carries only its branch, never leaf operands:

{"filter": {"and": [
  {"op": "gt", "field": "Horsepower", "value": 100},
  {"or": [
    {"op": "eq", "field": "Origin", "value": "USA"},
    {"not": {"op": "is_null", "field": "Cylinders"}}
  ]}
]}}

Operator reference:

OperatorOperandsMeaning
eq ne lt lte gt gtefield + exactly one of value / to_fieldEquality / ordered comparison against a literal or another column.
one_of not_one_offield + values (non-empty)Set membership.
betweenfield + lo + hiInclusive range (lo <= x <= hi).
is_null not_nullfield onlyNull-state test.
and ornon-empty list of predicatesBoolean conjunction / disjunction.
notone predicateBoolean negation.

The grammar is intentionally minimal — no substring, regex, or date arithmetic. Anything richer is precomputed by the caller before the data reaches Prism.

Calculate transform

calculate appends one derived column, named by as, from a structured expression tree (again, never an expression string). A node is exactly one of:

  • a field reference — {"field": "Horsepower"}
  • a literal — {"literal": 5} (number, string, or bool; a null literal is rejected)
  • an arithmetic op — {"op": "add"|"sub"|"mul"|"div"|"mod", "operands": [...]}
  • a pure function — {"fn": "abs"|"round"|"floor"|"ceil"|"neg"|"coalesce"|"min"|"max", "args": [...]}
  • a string concat — {"concat": [...]}
  • a conditional — {"case": [{"when": <predicate>, "then": <expr>}], "else": <expr>}

add and mul take two or more operands; sub, div, mod take exactly two. abs/round/floor/ceil/neg take one argument; coalesce/min/max take two or more. case requires at least one when → then branch and a mandatory else fallback (if is accepted as a decode-time alias for case).

Arithmetic — Horsepower / Weight:

{"calculate": {"op": "div", "operands": [{"field": "Horsepower"}, {"field": "Weight"}]}, "as": "power_ratio"}

Default a null with coalesce:

{"calculate": {"fn": "coalesce", "args": [{"field": "quota"}, {"literal": 0}]}, "as": "quota_padded"}

Build a label with concat:

{"calculate": {"concat": [{"field": "Origin"}, {"literal": " — "}, {"field": "Name"}]}, "as": "label"}

Bucket with case; each when arm reuses the filter predicate grammar verbatim:

{"calculate": {
  "case": [
    {"when": {"op": "gte", "field": "score", "value": 0.9}, "then": {"literal": "A"}},
    {"when": {"op": "gte", "field": "score", "value": 0.8}, "then": {"literal": "B"}}
  ],
  "else": {"literal": "C"}
}, "as": "grade"}

The output column type is inferred: a numeric expression yields a float column, a string expression a categorical column.

The grammar is intentionally minimal — no log/sqrt/pow/trig, no substring, no date arithmetic. Precompute anything richer upstream.

Null and division semantics

Both filter and calculate use two-valued logic — there is no SQL-style three-valued “unknown”.

  • Filter leaves. A null operand makes a leaf comparison, one_of / not_one_of, or between evaluate false (the row is excluded unless an enclosing or / not rescues it). Test for null explicitly with is_null / not_null; and / or / not then operate on plain booleans.
  • Calculate null propagation. Arithmetic (add/sub/mul/div/mod) and the single-argument numeric functions (abs/round/floor/ceil/neg) propagate nulls: any null operand yields a null result. min / max skip null arguments and return null only when every argument is null. coalesce returns its first non-null argument. concat treats a null operand as the empty string and always yields a (possibly empty) string. case returns the then of the first branch whose when holds, else the else.
  • Division by zero. A runtime zero divisor (div / mod) yields null silently — no error, no warning. A literal-zero divisor (e.g. {"op": "div", "operands": [{"field": "x"}, {"literal": 0}]}) is a spec mistake and is rejected at validate time as PRISM_SPEC_038.

Validation codes:

  • PRISM_SPEC_037 — filter predicate not well-formed (unknown field, type-mismatched comparison, between with lo > hi, empty values set).
  • PRISM_SPEC_038 — calculate expression not well-formed (unknown operand field, literal-zero divisor, as missing or shadowing a source column).

Crosstab transform

The crosstab transform builds a contingency table in Prism’s in-memory engine: it composes the cell aggregation across the row × column grouper grid, recomputes the margin axes, applies the configured normalisation, and returns long-form rows ready for a heatmap encoder.

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"name": "sales"},
  "transform": [{
    "crosstab": {
      "rows":    [{"field": "region"}],
      "columns": [{"field": "quarter"}],
      "cell":    {"aggregate": "mean", "field": "revenue", "as": "mean_revenue"},
      "margins": {"rows": true, "columns": true},
      "normalize": "none"
    }
  }],
  "mark": "heatmap",
  "encoding": {
    "x":     {"field": "quarter", "type": "nominal"},
    "y":     {"field": "region",  "type": "nominal"},
    "color": {"field": "mean_revenue", "type": "quantitative"}
  }
}

Body:

FieldRequiredNotes
rowsyesRow-axis groupers. One or more {field: "..."} (category, default) or {field: "...", type: "date", period: "..."} (date bucketing).
columnsyesColumn-axis groupers. Same shape.
cellyes{aggregate, field, as} — aggregate alias (sum, mean, count, …).
margins{rows, columns, grand} — emit total rows with _margin sentinel.
normalizenone (default), row, column, total.
shapelong (default) returns one row per cell; matrix is reserved.
overlaysPost-result overlay layers; each adds one F64 column aligned to the base cell. See below.

Crosstab overlays

overlays attaches post-result overlay layers to the cell grid. Each overlay adds one F64 column — index-aligned to the base cell — so it can drive a color or opacity channel. v1 supports the cell-scoped kinds that align one-to-one with heatmap cells:

kindColumn valueNotes
share_of_rowcell / row-margincells along a row sum to 1.0
share_of_colcell / column-margincells down a column sum to 1.0
index_vs_margincell / margin × 100requires axis (row or column); 100 = on-margin
zscore_vs_margin(cell − margin) / sdrequires axis; a significance proxy (|z| > 1.96 ≈ p < .05) — bind to opacity for significance shading
"crosstab": {
  "rows":    [{"field": "region"}],
  "columns": [{"field": "quarter"}],
  "cell":    {"aggregate": "sum", "field": "revenue", "as": "revenue"},
  "overlays": [{"kind": "share_of_row", "as": "row_share"}]
}

When any overlay is present the node emits body cells only (overlays decorate body cells), so user margins flags are ignored for the visual output. Group/series-scoped kinds (index_vs_total, share_of_total) land in a follow-up.

Constraints:

  • Crosstab accepts any upstream table — a source-bound dataset, an inline data.values cohort, or the output of an earlier transform. It runs entirely in Prism’s in-memory engine, so you can filter (or otherwise reshape) the rows first and then cross-tabulate the result (see the derived-input example below). It is no longer restricted to the first position on the chain.
  • Grouper type is category (default) or date. A date grouper buckets a temporal field by period — one of year, quarter, month (default), week, day, day_of_week — emitting string bucket-key labels ("2024", "2024-Q1", "2024-03", …). Range / rounded / quantile groupers land in a follow-up.
  • Margin rows carry a _margin column the encoder leaves on the table — filter them out at the chart level by upstream filter-after composition or by avoiding the margins flag for the visual rendering use case.

Cells are validated through PRISM_SPEC_032 (shape rule) and PRISM_SPEC_034 (normalize enum) — both structural checks only. Run prism errors lookup <code> for details + fixups.

Derived-input example

Because crosstab consumes whatever table its upstream stage produces, you can chain it after any other transform. Here a filter narrows the rows to one region before the cross-tabulation runs:

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"values": [
    {"region": "West", "quarter": "Q1", "channel": "online",  "revenue": 120},
    {"region": "West", "quarter": "Q2", "channel": "retail",  "revenue": 80},
    {"region": "East", "quarter": "Q1", "channel": "online",  "revenue": 60}
  ]},
  "transform": [
    {"filter": {"op": "eq", "field": "region", "value": "West"}},
    {"crosstab": {
      "rows":    [{"field": "quarter"}],
      "columns": [{"field": "channel"}],
      "cell":    {"aggregate": "sum", "field": "revenue", "as": "revenue"}
    }}
  ],
  "mark": "heatmap",
  "encoding": {
    "x":     {"field": "channel", "type": "nominal"},
    "y":     {"field": "quarter", "type": "nominal"},
    "color": {"field": "revenue", "type": "quantitative"}
  }
}

Regression transform

The regression transform fits an ordinary-least-squares regression over the materialised source table (pure-Go, in-memory) and emits the two endpoints of the fitted trend line — (min(x), ŷ) and (max(x), ŷ). Because every OLS fitted point is collinear, two endpoints draw the full line; layer a line mark over (predictor, fitted) on top of a point scatter of (predictor, target) for the classic regression overlay.

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"name": "sales"},
  "layer": [
    {"mark": "point", "encoding": {
      "x": {"field": "spend", "type": "quantitative"},
      "y": {"field": "revenue", "type": "quantitative"}
    }},
    {
      "transform": [{"regression": {"target": "revenue", "predictors": ["spend"], "as": "fit"}}],
      "mark": "line",
      "encoding": {
        "x": {"field": "spend", "type": "quantitative"},
        "y": {"field": "fit", "type": "quantitative"}
      }
    }
  ]
}

Body:

FieldRequiredNotes
targetyesDependent variable (y).
predictorsyesIndependent variable (x). Exactly one in v1 — the only shape that maps to a 2-D line.
asFitted-value output column name (default fitted).

Constraints:

  • Regression accepts any upstream table — like crosstab, the OLS fit runs in-memory over whatever rows its input stage yields, so you can filter or otherwise derive the cohort first and then fit the trend. PRISM_SPEC_035 is a structural check only (target present + at least one predictor); there is no first-position requirement.
  • v1 fits unpenalized OLS with a single predictor. Multiple predictors, GLM/Bayesian families, and the per-row residual / leverage attributes land in a follow-up.

TimeUnit transform

The timeunit transform truncates a temporal field to a calendar period and appends the truncated date as a new column — the Vega-Lite timeUnit analogue. The output is a date (the period start), so the derived column stays temporal for axis / scale resolution and sorts chronologically. It runs client-side (pure epoch arithmetic) and, like every Prism transform, composes anywhere in a chain.

{
  "transform": [{"timeunit": "month", "field": "order_date", "as": "order_month"}],
  "mark": "line",
  "encoding": {
    "x": {"field": "order_month", "type": "temporal"},
    "y": {"aggregate": "sum", "field": "revenue", "type": "quantitative"}
  }
}
FieldRequiredNotes
timeunityesPeriod: year, quarter, month, week (ISO / Monday start), day. Truncates to the period start.
fieldyesTemporal field to truncate.
asyesOutput date column name.

day_of_week and other component-extraction units (which return an ordinal, not a date) land in a follow-up.

Unpivot transform

The unpivot transform reshapes wide → long: one input row carrying N measure columns becomes N output rows, each repeating the untouched remaining columns, naming the measure in a key column and carrying its value in a value column. It is the Vega-Lite fold analogue, and the usual way to get several wide metric columns onto one categorical axis (colour or dodge by the key column).

{
  "data": {"values": [
    {"region": "east", "q1": 1, "q2": 2},
    {"region": "west", "q1": 3, "q2": 4}
  ]},
  "transform": [{"unpivot": ["q1", "q2"], "as": ["quarter", "amount"]}],
  "mark": "bar",
  "encoding": {
    "x": {"field": "region", "type": "nominal"},
    "y": {"field": "amount", "type": "quantitative"},
    "color": {"field": "quarter", "type": "nominal"}
  }
}
FieldRequiredNotes
unpivotyesColumns to stack. Each must be numeric — the value column is f64.
asno[key_column, value_column] output names. Defaults to ["key", "value"].
datanoOptional input alias, like every other transform.

Output schema is the input schema minus the unpivoted fields, plus the key column (categorical, carrying the source column name) and the value column (numeric), in that order.

  • Row count is input_rows × len(unpivot). The reshape multiplies rows, so the product is checked against PRISM_TABLE_MAX_ROWS before anything is materialised; an overflow is PRISM_RESOLVE_007.
  • Row order is row-major: every measure of input row 0 in unpivot declaration order, then every measure of input row 1, and so on. A source row’s outputs stay adjacent.
  • Nulls survive. A null measure cell produces an output row whose value is null — the row is not dropped and the value is never coerced to zero. What happens to it next is the encode-time null policy (see Multi-source).
  • Types. A categorical, boolean or date column cannot fill a numeric value column; naming one in unpivot is refused with PRISM_COMPILE_002 rather than yielding zeros or NaN. A key / value name that collides with a carried column is refused the same way — rename through as.

Worked example: wide → long → grouped bar

The usual reason to reshape is that the stored table is wide — one row per series, one column per metric, because in the source schema a metric is a column and not a dimension. A grouped bar wants the opposite: the metric on the category axis, the series dodged inside each slot. unpivot is the step between the two, and the x_offset channel is what dodges the result.

Start from the wide table:

seriesawarenessconsiderationfamiliaritytrust
Northwind68476172
Category average54515758
{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"values": [
    {"series": "Northwind",        "awareness": 68, "consideration": 47, "familiarity": 61, "trust": 72},
    {"series": "Category average", "awareness": 54, "consideration": 51, "familiarity": 57, "trust": 58}
  ]},
  "transform": [
    {"unpivot": ["awareness", "consideration", "familiarity", "trust"],
     "as": ["metric", "score"]}
  ],
  "mark": "bar",
  "encoding": {
    "x": {"field": "metric", "type": "nominal"},
    "y": {"field": "score", "type": "quantitative"},
    "x_offset": {
      "field": "series",
      "type": "nominal",
      "scale": {"domain": ["Northwind", "Category average"]}
    },
    "color": {
      "field": "series",
      "type": "nominal",
      "scale": {"domain": ["Northwind", "Category average"]}
    }
  }
}

The transform turns 2 rows × 4 measures into 8 long rows — series carried through untouched, metric naming the source column, score carrying its value:

seriesmetricscore
Northwindawareness68
Northwindconsideration47
Northwindfamiliarity61
Northwindtrust72
Category averageawareness54
………

Four things are worth reading off that spec:

  • series survives because it is not named in unpivot. Every column the transform does not stack is repeated on each output row, which is what leaves a dimension for the offset channel to dodge on.
  • The x axis order is the unpivot declaration order. Output is row-major, so the first input row emits its measures in the order they are listed, and the band scale assigns slots first-seen from there. Reordering the unpivot list reorders the axis.
  • x_offset and color bind the same field, and both pin the same scale.domain. Pinning is what keeps the legend order and the sub-band order in agreement, and what keeps a template’s pair order from moving when the underlying category set changes between renders — see Encoding › Ordering the sub-bands.
  • The bars dodge, they do not stack. Every bar keeps its own baseline; the offset scale subdivides the metric’s band slot and its padding defaults to zero, so the pair touches and together fills the slot.

The rendered result is the unpivot_grouped_bar gallery fixture.

Pivot transform — parses, does not execute

pivot is the long → wide counterpart of unpivot, and it is the one transform the spec grammar accepts that no backend can run. It decodes, it has a JSON Schema variant, it builds a plan node — and nothing implements that node’s execution.

Rather than let it fail mid-pipeline, validate refuses it up front:

PRISM_SPEC_067: Transform "pivot" at transform[0] is accepted by the spec
grammar but no backend can execute it.

This moved an existing failure earlier; it did not create one. Before the rule, a pivot spec validated cleanly and then died inside execute as PRISM_COMPILE_001, naming an internal node kind (PivotNode) the author never wrote. Every other transform variant executes — filter, calculate, aggregate, bin, window, join, union, unpivot, sample, sort, limit, crosstab, regression, timeunit, stack. That list is not a promise typed into a doc: validate/rules/transform_executable.go states it, and a repo gate drives one minimal spec per variant through the real planner and the real in-memory backend, failing just as loudly when a transform named there stops working as when one omitted there starts.

Use crosstab for the same long → wide shape. It spreads the distinct values of one field across columns and does execute:

{
  "transform": [
    {"crosstab": {
      "rows":    [{"field": "region"}],
      "columns": [{"field": "quarter"}],
      "cell":    {"aggregate": "sum", "field": "revenue"}
    }}
  ]
}

The general answer, when a reshape is outside what the built-in transforms express, is the same one Prism gives everywhere: it consumes already-materialized rows, so do the reshape upstream and hand it the finished table — inline as data.values, through the datasets block, or at runtime via a DataResolver and a data: {"ref": …} binding.

Stack transform

The stack transform accumulates one quantitative field into per-row [start, end] bounds inside each stack, so a bar or area mark can draw each segment as a span instead of anchoring every segment on the axis baseline. It is the Vega-Lite stack transform, and it is what the implicit stacking described in Encoding → Stacking compiles down to.

{
  "transform": [
    {"stack": "users", "groupby": ["stage"], "offset": "zero", "as": ["users_lo", "users_hi"]}
  ],
  "mark": "bar",
  "encoding": {
    "x":  {"field": "stage", "type": "nominal"},
    "y":  {"field": "users_lo", "type": "quantitative"},
    "y2": {"field": "users_hi", "type": "quantitative"},
    "color": {"field": "plan", "type": "nominal"}
  }
}
FieldRequiredNotes
stackyesQuantitative field to accumulate.
groupbynoFields delimiting one stack — typically the dimension position channel. Omitted stacks the whole table as one group.
offsetnozero (default) accumulates from the baseline; normalize rescales each stack onto 0…1; center slides each stack so its own midpoint lands on zero.
asnoOutput column pair [start, end]. Defaults to ["<field>_start", "<field>_end"]. These are column names, not a dataset alias — stack publishes no alias, exactly like bin and calculate.
datanoOptional input alias.

Semantics:

  • Positive and negative values accumulate on independent cursors from zero, so a stack holding both grows up and down from the baseline rather than cancelling.
  • normalize rescales each stack over its own extent ((v − lo) / (hi − lo)), which reduces to v / total for all-positive data. A stack whose values are all zero normalises to zero rather than dividing by zero.
  • center keeps each stack’s thickness and slides the whole pair of bounds so the stack’s own midpoint is zero — the streamgraph silhouette. Output bounds are therefore signed, and every stack is symmetric about the same line no matter how much total it carries. The inside-out segment ordering a centred encoding stack applies (see Encoding › Centred stacks) is not available here — this transform has no grouping channel to derive series from, so it keeps upstream row order like the other offsets.
  • A null or non-numeric cell contributes nothing: it gets a zero-height [cursor, cursor] bound and the cursor does not move.
  • Segment order inside a stack is upstream row order. This transform carries no sort key of its own; order the rows with a preceding {"sort": …} transform, or bind the order channel, which sorts the rows before the stack accumulates.
  • The output columns must not collide with existing ones; PRISM_PLAN_STACK_OUTPUT_COLLISION names the offender. A stack field the upstream table does not carry raises PRISM_PLAN_STACK_FIELD_MISSING.

Like every other transform, stack accepts any upstream table — a leaf, inline values, or the output of an earlier transform — so it composes anywhere in the chain.

Sort direction

The sort transform and the window transform’s sort key both take per-field entries of the form {"field": …, "order": …}. order is "ascending" (the default) or "descending"; "asc" and "desc" are accepted aliases. The same vocabulary applies to the order channel’s sort key.

{"transform": [{"sort": [{"field": "revenue", "order": "descending"}]}]}

A window transform may carry its own sort array — it orders rows within each partition without reordering the table:

{"transform": [
  {"window": [{"op": "rank", "as": "place"}],
   "partitionby": ["region"],
   "sort": [{"field": "revenue", "order": "descending"}]}
]}

Strict by default

  • Unknown fields error (typos like xfield vs x.field caught at parse). Strictness reaches every level: an unrecognised key inside a channel object, or inside that channel’s axis / legend sub-block, fails the decode rather than being dropped. The JSON Schema shape stage agrees — additionalProperties: false on every $def — so prism validate and a library caller using spec.Decode alone reject the same document. (That was not always true: a hand-written UnmarshalJSON receives raw bytes and Go does not propagate the outer decoder’s DisallowUnknownFields into it, so each decoder in spec/ re-arms strictness itself. internal/gates/spec_strict_decode_test.go drives an unknown key through every one of them on each build.)
  • Semantic violations error (agg op on incompatible field type, etc.).
  • A key that decodes and then reaches no consumer is reported as a warning (below) — and, for anything newly added, fails the build: internal/gates/spec_field_consumer_test.go requires every exported, JSON-tagged spec/ field to have a typed consumer outside spec/.
  • 24+ PRISM_SPEC_* rules cover field-existence, channel-for-mark, selection refs, structured filter / calculate predicates, scale type compatibility, animation easing / key constraints, and more. Run prism errors lookup <code> for details on any.

Validate a spec

prism validate my-chart.prism.json
prism validate --json my-chart.prism.json

Warnings — and the fields that do nothing

A spec can be perfectly valid and still ask for something Prism does not do. Those keys used to be discarded in silence; they now report themselves as warnings. A warning never stops the chart: the document still renders, and the warning rides alongside it.

Where they surface:

  • prism plot / prism scene print WARN <CODE>: <message> to stderr, so the rendered bytes on stdout stay clean.
  • prism scene also carries them in the document itself, under the top-level warnings array of the Scene IR (code, message, details) — the same array the browser runtime and the Twirp / MCP facades read.
  • prism errors lookup <CODE> explains any of them, with fixups.

The inert-field family reports a key that decoded, passed validation, and then reached no consumer. Each one names the exact path (layer[1].mark.tooltip, encoding.y.scale.padding_inner) plus the mark or channel it was written on:

CodeFires when
PRISM_WARN_MARK_DEF_INERTa mark_def property is set on a mark that never reads it — pad_angle on a bar, dx on a rect — or on a property no mark reads at all (shape, tooltip, layout)
PRISM_WARN_CHANNEL_INERTa channel binding reaches no encoder: fill, stroke, size, shape (no mark reads them), opacity on anything but heatmap, or a format on a table column that also binds a sub-mark (that column draws geometry, not text)
PRISM_WARN_SCALE_FIELD_INERTa scale property does not apply to the family the channel resolves to — padding_inner on a linear scale, base on anything but log, zero on a log / time / discrete scale
PRISM_WARN_LEGEND_NOT_BUILTa quantitative or temporal color channel builds a gradient legend, but the mark never reads the colour ramp (only heatmap consumes a sequential palette today), so the key describes a scale the marks do not use
PRISM_WARN_FACET_CHILD_SKIPPEDa facet child’s encoding asks for a channel-level aggregate, a stack, or an order — the child encoding is stripped before the plan is built, so none of the three is injected

Silence is intentional for three groups, and none of them warns: a key that is honoured; a key that is rejected outright at validate (orient: "radial" → PRISM_SPEC_046, scale.range on a position channel → PRISM_SPEC_045, bar + stack: "center" → PRISM_SPEC_053 — a rejection is already visible); and a key that is inert by design — the json:"-" internal bindings, and axis.format / legend.format, which a validation rule reads. Universal style properties (fill, stroke, stroke_width, stroke_dash, opacity, …) are never reported either: every mark carries them into its Scene IR style, so no mark type can render them inert. A channel carrying a condition is likewise never reported: the condition pass evaluates it whatever the channel.

The report is produced once per spec, from the top of the composition tree, so a layer or concat child is never warned about twice.

Spec patches (RFC 6902)

Iterative edits to a rendered chart don’t need a full spec re-send. A caller can transmit an RFC 6902 JSON Patch and the library applies it atomically, re-decodes, and re-compiles:

[
  { "op": "replace", "path": "/mark", "value": "area" },
  { "op": "add",     "path": "/encoding/color",
                     "value": { "field": "category", "type": "nominal" } },
  { "op": "test",    "path": "/data/name", "value": "current_window" },
  { "op": "remove",  "path": "/title" }
]

Same protocol in Go and in WASM:

next, err := prism.ApplyPatch(s, patch)
// or, statefully:
scn, _ := prism.NewScene(ctx, s, prism.CompileOptions{})
err := scn.Apply(patch)
const newSpecJSON = prism.applyPatch(specJSON, JSON.stringify(patch));
const patchJSON   = prism.diffSpecs(beforeJSON, afterJSON);

Atomic application. Either every operation in the patch succeeds and the new spec replaces the old, or no state changes. A failing op surfaces as PRISM_SPEC_PATCH_001 with the offending op index in the envelope’s Details.OpIndex.

Test operations. Include a test op to fail-fast on optimistic-concurrency violations — the patch aborts if the current spec value at path differs from the expected value.

Diff helper. prism.DiffSpecs(before, after) (Go) and prism.diffSpecs(beforeJSON, afterJSON) (WASM) produce a patch that transforms one spec into the other. Useful for callers that think in full specs and only want to transmit the delta.

Further reading

Marks

A mark is the visual primitive that data rows become — bars, lines, arcs, etc. Specify via top-level mark (shorthand string) or mark: {type: "...", ...properties}.

Catalog

Basic marks (Vega-Lite parity)

MarkWhen to use
barCompare categories. The default. Stacks by segment — see Encoding › Stacking — or draws side-by-side groups when an offset channel is bound, see Grouped bars.
lineContinuous trends; ordered x-axis.
areaFilled trends. Supports negative values, an explicit y2 lower edge, stacking — see Encoding › Stacking — and orient.
pointScatter, dot plots.
circle, squareConvenience aliases for point with shape preset.
tickStrip plots, ranking dot plots. Honours orient.
rectHeatmap cells, custom rectangular layouts.
ruleReference lines, benchmarks, ranges.
textInline labels, annotations. Content comes from the text channel — see Text.
arcPrimitive for pie / donut / sankey links.

Composite marks

MarkInternally expands to
histogrambar + auto-bin transform.
heatmaprect + 2D bin + sequential color scale. Binds an optional field-driven opacity channel for per-cell shading — pair it with a crosstab zscore_vs_margin overlay column to fade insignificant cells (significance shading). Opacity maps the field linearly over [min, max] to [0.15, 1.0].
boxplotrect (IQR) + rule (median, whiskers, caps) + point (outliers). Honours orient. The whiskers take the box colour; the median is drawn on the box, so it takes the theme’s boxplot_median key and carries its own prism-mark-boxplot-median class.
violinarea symmetric around centerline (Epanechnikov KDE). Honours orient.
piearc with theta computed from share.
donutarc with inner_radius_ratio > 0.

Specialty marks

MarkWhen to use
sankeyFlow diagrams (source/target/value table).
funnelConversion funnels — stacked trapezoids.
sparklineInline micro-line charts, no axes.
sparkbarInline micro-column charts, no axes — bar-family sibling of sparkline.
winlossEqual-length micro-bars by the sign of the measured value (>0 one way, <0 the other, ==0 flat). Magnitude is ignored — only direction encodes. Honours orient.
sparkareaInline filled micro-area charts, no axes — area-family sibling of sparkline; fill reaches the y=0 baseline.
bulletCompact KPI gauge — a measure bar over qualitative bands, with an optional comparative bar and target tick. Keeps its measure axis.
progressMulti-row metric bars — one value bar per row on a full-scale track. The multi-row sibling of bullet; row labels come from the category axis.
imageSprites / data-URL images at position.
pathRaw SVG path data — escape hatch.
geoshapeCountry / admin-1 polygons (choropleth). See Geographic Marks.
geopointLon/lat → point overlay. See Geographic Marks.
tableInteractive, paginated data table. Columns replace x/y — see Table below.
customEscape hatch for a caller-registered renderer function. No position channels — see Custom below.

Spark adornments

The sparkline, sparkbar, and sparkarea marks accept three opt-in mark-def fields that emphasize specific values on the bare spark. All three default off — a spark with none set renders byte-identically to one without the fields. They are independent and compose freely; set any combination on the same mark.

Mark-def fieldTypeEffect
point_lastbooleanDraws an emphasis dot on the final (most recent) value.
point_extentbooleanDraws highlight dots on the minimum and maximum values.
reference_band{from, to}Shades a faint normal-range band between the two value-axis bounds, spanning the spark’s full extent across the category axis, behind the series.

Dots inherit the spark’s line color; the band is a faint fill of the same color. from / to are data-space values on the spark’s value axis and may be given in either order. The winloss mark is not in scope for adornments — its bars encode direction, not a continuous series.

{
  "mark": {
    "type": "sparkline",
    "point_last": true,
    "point_extent": true,
    "reference_band": {"from": 15, "to": 22}
  },
  "encoding": {
    "x": {"field": "t", "type": "quantitative"},
    "y": {"field": "v", "type": "quantitative"}
  }
}

Text

The text mark draws one label per row at the position its x / y channels resolve to. Label content comes from the text encoding channel:

encoding.textLabel content
{"field": "label", "type": "nominal"}That column’s value for the row.
{"field": "score", "type": "quantitative", "format": ".1f"}The column’s value through the d3-format subset — 80.04 renders as 80.0.
{"value": "n/a"}The literal, repeated on every row (formatted too, when format is set).
omittedFallback: the y field’s value verbatim (or the x field’s when y is unbound).

text.aggregate is 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 alongside the other channels. So {"text": {"aggregate": "mean", "field": "score", "type": "quantitative"}} labels each group with its mean.

At least one position channel must be bound. A label-only mark (say x bound, y omitted) is valid — the unbound axis centres the label in the plot region rather than erroring.

{
  "mark": {"type": "text", "font_size": 12, "baseline": "bottom"},
  "encoding": {
    "x": {"field": "brand_id", "type": "nominal"},
    "y": {"field": "score", "type": "quantitative"},
    "text": {"field": "score", "type": "quantitative", "format": ".0%"}
  }
}

Mark-def fields align (left / right), baseline (top / bottom), angle, and font_size position and orient the label.

Arbitrary free-floating annotations (“no data” callouts not tied to a row) are not reachable from a spec today — every text mark is row-driven.

Tree / dendrogram / network

Hierarchical and relational marks share a small layout package (encode/marks/layout) and decompose to existing primitives (path, point, rect, text) so the SVG renderer handles them without new geometry types.

MarkWhen to use
treeRooted hierarchy (org charts, decision trees). Reingold-Tilford tidy layout.
dendrogramClustering tree — tree variant with link_shape: step + node_shape: none defaults.
networkUndirected / directed node-link diagram. Force-directed layout (deterministic seed).

Channel bindings:

  • source — parent / from-node id field (required for tree/dendrogram/network).
  • target — child / to-node id field (required).
  • value — optional edge weight (network) / node size (tree).
  • text — optional per-node label. See Node labels.
  • color, fill, stroke, opacity, size — standard mark props.

Mark-def options:

  • orient — vertical (default) or horizontal; picks the direction the layout grows. See Orientation. radial is rejected (PRISM_SPEC_046).
  • link_shape — step (default), curve, straight.
  • node_shape — circle (default), rect, none.
  • node_size — base radius / side length (default 6).
  • layout (network) — force (default), random.
  • iterations, link_distance, charge, seed (network).

Validate rules: PRISM_SPEC_028 (missing source/target), PRISM_SPEC_029 (multi-root tree). Encode-time: PRISM_ENCODE_TREE_CYCLE, PRISM_ENCODE_NETWORK_NONFINITE, PRISM_WARN_NETWORK_CYCLE.

Node labels

Bind the text channel to label the nodes. Content resolves through the same path the text mark uses — field reads a column, value supplies a literal, format runs the result through the d3-format subset — so a label formats exactly as the equivalent text mark would. See Encoding › Text channel.

Labelling is opt-in. With no text channel these marks emit no label geometry at all, which is what keeps an unlabelled tree or network byte-identical to one drawn before labels existed.

These marks are node-oriented while the channel is row-oriented, so a row’s label binds to the node named by that row’s target value (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, a pure source in a network — has no row of its own and falls back to its id.

Where the label lands follows the classic tidy-tree convention: a label sits on the far side of its node from that node’s subtree, so it never collides with the links or the children below it.

MarkorientInternal nodeLeaf
tree, dendrogramvertical (default)Above the node, centredBelow the node, centred
tree, dendrogramhorizontalLeft of the node, right-alignedRight of the node, left-aligned
networkn/aBelow the node, centredBelow the node, centred

A force layout has no growth direction and no leaf / internal distinction, so every network label simply hangs under its node.

The plot rect is inset by the estimated label band when labels are on, so the outermost labels (a vertical tree’s root and leaf row, a horizontal tree’s root and leaf column) stay inside the chart rather than running off the canvas. That inset is why turning labels on also moves the nodes.

Limitation: Prism runs no text-measurement pass. Label widths are estimated at 6px per character — the same standing approximation the axis label_limit and label_overlap heuristics use — so the inset is approximate for very wide glyphs, and sibling labels within one depth row are not collision-tested against each other. Hiding a label (the axis heuristic’s answer to an overlap) would lose a node’s identity, so graph labels are never dropped; give a crowded tree more room, shorter labels, or orient: horizontal.

Bullet

The bullet mark is a compact KPI gauge (after Stephen Few’s bullet graph). It draws, back-to-front:

  1. qualitative band rects — graded background ranges (dark → light),
  2. the measure bar — the encoded data value (thick),
  3. an optional comparative bar — a secondary value, thinner overlay,
  4. an optional target tick — the value to beat.

Unlike the spark family, bullet keeps its measure axis, and the measure-axis domain is widened to span the bands / target / comparative so none of them clip past the data range.

Channel bindings:

  • Horizontal (default): x is the quantitative measure, y is the nominal metric label.
  • Vertical (orientation: "vertical"): y is the quantitative measure, x is the nominal metric label.

The headline measure reads from row 0 of the measure field (a bullet is a single KPI readout).

Mark-def options:

  • bands — ordered list of cumulative qualitative range bounds measured from zero, strictly ascending (e.g. [150, 225, 300]). Validated by PRISM_SPEC_036.
  • target — the reference value to beat. A literal number, or a string naming a data field resolved from row 0.
  • comparative — a secondary measure (e.g. prior period). Like target, a literal number or a data-field name.
  • orientation — horizontal (default) or vertical. Note the field name: bullet keeps its own orientation rather than the shared orient, because it is not a category/measure swap — a bullet is a single KPI readout whose bands, comparative bar and target tick all rotate together, and its default is horizontal where orient’s is vertical. Folding it into orient would silently flip every existing bullet. See Orientation.
{
  "mark": {
    "type": "bullet",
    "bands": [150, 225, 300],
    "comparative": 240,
    "target": 260
  },
  "encoding": {
    "x": {"field": "actual", "type": "quantitative"},
    "y": {"field": "metric", "type": "nominal"}
  }
}

Validate rule: PRISM_SPEC_036 (bands strictly ascending).

Progress

The progress mark draws one metric row per data row: a value bar sitting on a full-scale track, where the visible remainder of the track reads as “distance still to go”. It is the layout behind a metric-row panel — four labelled rows, each a bar against a 0–100 ceiling.

It is the multi-row sibling of bullet. bullet collapses its measure to row 0 — it is a single KPI readout — so a four-metric panel needs a facet wrapper, and facet labels its rows "<field> = <value>" with no format control. progress reads every row, and the row labels are simply the category axis’s tick labels.

Two things make it more than a bar with a background rect:

  • The measure domain is mark-owned. total names the value the track runs to, and the measure scale is extended to reach it before the scale is built. The track therefore ends at the plot edge instead of running past it — the clipping bullet still suffers when a band bound sits above the data range.
  • The track is a separate scene mark. Each row emits a progress-track-N rect and a progress-N value rect, in that order, rather than one rect with a painted backdrop. That is what makes the track independently themeable and independently selectable in CSS. Both carry the row’s data-prism-datum-row back-reference, so a hover on the filled part of a row behaves like a hover on its remainder.

Channel bindings:

  • Horizontal (the default reading): x is the quantitative value, y is the nominal metric label.
  • Vertical: x is the nominal label, y is the quantitative value.

Orientation comes from the shared mark.orient vocabulary — progress does not carry a per-mark orientation field the way bullet does. You rarely write it: a nominal y against a quantitative x already infers horizontal.

Mark-def options:

  • total — the measure ceiling the track runs to. A literal number applies to every row; a string names a data field read per row, so each metric can carry its own maximum (a per-rep quota, say). Omit it and the track spans the data-derived domain instead. A literal must be positive (PRISM_SPEC_061).
  • thickness — the fraction of the category band a row occupies, centred in it. Defaults to 0.5; must be greater than 0 and at most 1.
  • corner_radius — the standard mark-def field, applied to both the track and the value bar so they round together.

A value above its row’s total overflows the track rather than being clipped — over-attainment stays visible.

{
  "mark": {"type": "progress", "total": 100, "corner_radius": 3, "thickness": 0.45},
  "encoding": {
    "x": {"field": "score", "type": "quantitative"},
    "y": {"field": "metric", "type": "nominal"}
  }
}

The two halves are themed independently, through two keys rather than one (see Themes: multi-element marks):

KeyStyles
marks.progressThe value bar. mark.fill and a color channel still shadow it, in that order.
marks.progress_trackThe unfilled track. There is no mark_def equivalent — the track is styled through the theme.

Both take the full MarkStyle shape, so a track can carry a stroke, an opacity or a pattern fill, not just a colour. Every bundled theme sets both, so a progress chart tracks light / dark / print with no per-chart configuration; a custom theme that sets neither falls back to its own grid colour for the track, which keeps it reading as chrome rather than as a second series.

In the rendered SVG the halves carry distinct classes — prism-mark-progress and prism-mark-progress-track — so a stylesheet can scope to either. Marks are otherwise classed by geometry, and both of these are rects.

Right-hand value and delta labels (“92.4”, “+14.3 vs category”) are not part of the mark — layer a text mark over it.

Validate rule: PRISM_SPEC_061 (both position channels bound; thickness in (0, 1]; a literal total positive).

Image and path

image and path are single-geometry escape hatches: each spec emits exactly one mark from a mark-def field rather than one mark per data row. They take no positional data series of their own — encoding may be left empty ({}).

image places a raster sprite at a position. Key fields:

  • url (string, required) — the image source, read from mark_def.url. Offline-first: only data: URLs (e.g. base64-encoded PNG) and relative paths are accepted; remote http(s) fetch is rejected at validate time by PRISM_SPEC_016. The string passes through verbatim to the rendered <image href>.
  • size (number) — side length in pixels. Images are square; defaults to 64.
  • Position — when both x and y channels are bound, the image anchors at the scaled value of row 0; with no position channels it lands at the plot region’s top-left quarter (a sensible single-decoration default).
{
  "mark": {"type": "image", "url": "data:image/png;base64,iVBOR...", "size": 64},
  "encoding": {}
}

path draws a raw SVG path — the escape hatch for primitives Prism does not model natively. Key field:

  • path (string, required) — the SVG d string, read from mark_def.path and passed through untouched to the rendered <path d=...> (the renderer handles attribute escaping). An empty d is rejected by PRISM_SPEC_017.

Standard style props (fill, stroke, stroke_width, opacity) apply. For a data-driven polyline, prefer line with x/y encodings.

{
  "mark": {"type": "path", "path": "M 100 100 L 200 100 L 150 200 Z", "fill": "#3b82f6"},
  "encoding": {}
}

Validate rules: PRISM_SPEC_016 (image URL allowed), PRISM_SPEC_017 (non-empty path d).

Table

table is an interactive, paginated data table (E1). It has no position channels — encoding.columns[] is the entire visual contract, and each entry is a standard channel binding (field, type, aggregate, title, format, …) plus an optional mark naming a sub-mark that renders that column’s cells (e.g. sparkline for an inline trend column) instead of formatted text.

Mark-def options:

  • page_size — rows rendered per page. Defaults to 25 when unset (spec.TablePageSizeDefault).

Column fields (encoding.columns[], one object per column):

  • field, type, aggregate, scale, title, format, bin, sort, value, condition — same shape and meaning as any other channel encoding.
  • mark — optional sub-mark rendering this column’s cells (e.g. "sparkline"). Omit to render the column as formatted text.

format is a d3-format specifier from the supported subset and is applied to the column’s cell text at encode time. The raw value is kept alongside the formatted text, so a client-side header sort still compares numbers, not the formatted strings. A format on a column that also binds a mark has no text to shape and is reported as PRISM_WARN_CHANNEL_INERT.

{
  "mark": {"type": "table", "page_size": 50},
  "encoding": {
    "columns": [
      {"field": "name", "type": "nominal", "title": "Account"},
      {"field": "revenue", "type": "quantitative", "aggregate": "sum", "format": ",.0f"},
      {"field": "trend", "type": "quantitative", "mark": "sparkline"}
    ]
  }
}

Validate rule: PRISM_SPEC_040 (encoding.columns[] required and non-empty). See Renderer compatibility below for the svg vs html backend split, and the gallery table/ entries for full worked examples (including a paginated plain-column table and a sparkline sub-mark column).

Custom

custom (E2) is the escape hatch for a visualization none of the built-in marks express: a consuming application registers its own render function under a name (prism.RegisterCustomMark(name, renderer)), and a spec references that name instead of describing geometry. Like table, it has no position channels — the mark-def renderer field is the entire visual contract, and encoding may be left empty ({}).

Mark-def field:

  • renderer (string, required) — the name a CustomRenderer was registered under. Always a plain string key, never executable code — the spec JSON never carries the implementation itself (this preserves Prism’s no-expression-language invariant). Resolved against the active registry at render time, not decode time: an unregistered name is a render-time error (PRISM_RENDER_CUSTOM_MARK_NOT_FOUND), not a validate-time one.

A registered renderer implements at least one of two Go interfaces (prism.SVGCustomRenderer / prism.HTMLCustomRenderer — thin re-exports of github.com/frankbardon/prism/custommark, the package that actually owns the registry), or is registered as a synchronous JS callback in the browser via prism.registerCustomMark(name, fn). Both paths, the full SVG/HTML dual-method fallback matrix, and — most importantly — the security contract (the renderer author owns escaping row data and owns all script execution, not Prism) are covered in the Custom marks cookbook entry.

{
  "mark": {"type": "custom", "renderer": "badge"},
  "encoding": {}
}

Errors: PRISM_RENDER_CUSTOM_MARK_NOT_FOUND (unregistered renderer name at render time, naming every currently-registered name in its details). See Renderer compatibility below — unlike table, custom renders through both backends, since a renderer can implement RenderSVG, RenderHTML, or both.

Style properties

Beyond type, a mark_def object carries the visual properties every mark is drawn with. They are spec-level constants — one value for the whole mark — as distinct from an encoding channel, which varies per row. Where a mark-def property and a theme mark token name the same thing, the mark def wins; see Themes: mark style precedence.

Paint

PropertyApplies toMeaning
fillfilled marksFill color, #RRGGBB / #RRGGBBAA.
strokeallStroke color.
stroke_widthallStroke width in pixels.
stroke_dashall stroked marksDash pattern, [on, off, …] pixels. Emits stroke-dasharray.
opacityallOverall element opacity, [0, 1].
fill_opacityfilled marksFill-paint alpha, [0, 1].
stroke_opacitystroked marksStroke-paint alpha, [0, 1].
corner_radiusbar / rectCorner rounding in pixels.

stroke_dash is a whole-pattern override, not a merge: a mark def that names it replaces the theme mark block’s stroke_dash outright, and one that omits it keeps the theme’s. An empty array reads the same as an absent key, so to draw solid over a dashed theme token, drop the stroke_dash from the theme rather than writing [] in the spec. A pattern of all zeros is treated as unset — some renderers draw an all-zero dash as an invisible stroke rather than a solid one.

opacity, fill_opacity and stroke_opacity are independent and multiplicative — none overrides another. This matches Vega-Lite, whose canvas renderer computes the fill alpha as opacity × (fillOpacity ?? 1) and the stroke alpha as opacity × (strokeOpacity ?? 1). Prism emits all three as separate SVG attributes (opacity, fill-opacity, stroke-opacity), which SVG composites the same way, so:

{"mark": {"type": "bar", "opacity": 0.5, "fill_opacity": 0.5}}

paints a fill at an effective alpha of 0.25, not 0.5. Set fill_opacity alone when you want a translucent fill under a solid stroke; set opacity when you want the whole mark — fill, stroke and all — to fade together. An explicit 0 is honoured (a fully transparent paint), unlike an omitted property, which inherits.

Clipping (clip)

clip is the one mark-def property that is not a paint at all: it forces the plot-region clip on (true) or off (false).

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

Omit it and the encoder decides: the clip is armed only when a position channel pins an explicit scale.domain, which is the one way a mark can land outside the plot rect. Because the clip bounds a plot rect rather than a single mark, a layer resolves it once for the whole stack — one clip: true arms it for every layer, and a clip: false otherwise disarms it for all of them. See Encoding: rows outside the domain.

Typography

Applies to text marks and to any mark that draws a text component.

PropertyMeaning
fontFont family, e.g. "Inter, system-ui, sans-serif".
font_sizeGlyph size in pixels.
font_weight"normal" | "bold" | "lighter" | "bolder", or a number (100–900).
font_style"normal" | "italic" | "oblique".
alignHorizontal anchor — "left" | "center" | "right".
baselineVertical anchor — "top" | "middle" | "bottom" | "alphabetic".
angleRotation in degrees about the anchor point.
dx, dyPixel offset from the anchor point.

font_weight normalises to a number in the Scene IR, since SVG’s font-weight attribute is numeric. The CSS keywords map to their computed values against the default inherited weight of 400: normal → 400, bold → 700, bolder → 700, lighter → 100.

dx / dy are applied after angle, in the rotated frame — so a rotated label nudged with dy: -4 moves 4px along its own baseline normal, not straight up the page. This is Vega’s rule (translate(x,y) rotate(a) translate(dx,dy)) and is what makes dx/dy useful for lifting a label clear of the geometry it annotates:

{
  "mark": {"type": "text", "dy": -6, "font_weight": "bold", "font_style": "italic"},
  "encoding": {
    "x": {"field": "quarter", "type": "nominal"},
    "y": {"field": "revenue", "type": "quantitative"},
    "text": {"field": "revenue", "type": "quantitative"}
  }
}

Grouped bars (dodging)

A bar normally fills the whole band slot its category owns. Bind an offset channel — x_offset or y_offset — and the slot is cut into one sub-band per distinct value of the offset field, so the rows sharing a category draw side by side:

{
  "$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, two bars in each: the brand’s score beside its category average. The sub-bands touch and together fill the slot exactly, so the grouped bars read as one block per metric.

Runnable versions of both orientations live in the gallery: grouped_bar and grouped_bar_horizontal.

bar is the only mark that draws this. Every other band-seated mark fills its slot with a single shape — a tick is one line, a heatmap cell one rect, a boxplot one summary of the whole category — so there is nothing to divide, and an offset bound on one of them is rejected with PRISM_SPEC_063 rather than quietly changing the geometry.

Both orientations

Dodging follows orientation like everything else about the mark. Put the category on y and the measure on x and use y_offset:

{
  "$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": {
    "y":        {"field": "metric", "type": "nominal"},
    "x":        {"field": "score",  "type": "quantitative"},
    "y_offset": {"field": "series", "type": "nominal"},
    "color":    {"field": "series", "type": "nominal"}
  }
}

The offset must sit on the band axis — the one carrying the categories. x_offset against a quantitative x has no slot to cut and is rejected with PRISM_SPEC_064, as is binding both offset channels at once.

Sub-bands run in the same direction the parent band assigns its own categories. Prism’s y scale runs bottom-to-top, so in the horizontal form above the first offset category takes the lower sub-band of each slot.

Dodging replaces stacking

A bar that would stack implicitly — aggregated measure, discrete grouping — stops doing so the moment an offset is bound. The two spend the same geometry on the same grouping: a stack accumulates the segments along the measure axis, a dodge spreads them across the category band. The offset is what the author asked for explicitly, so the inferred stack yields to it and no stack key is needed.

Writing an explicit stack beside an offset is a contradiction and is rejected with PRISM_SPEC_065. Remove the stack, not the offset: dodging is the thing the spec asked for outright, and Prism refuses rather than drawing half of each. "stack": null and "stack": false are opt-outs, agree with the offset, and are accepted in silence.

Prism does not combine the two on one mark. A chart that stacks within each dodged group is built by composition instead: draw the dodged chart and split the stacking field out with facet.

What else to know

  • The sub-band domain is the distinct offset values across the whole table, so every bar is the same width and a category missing a series leaves that series’ sub-band empty.
  • The offset builds no legend. Bind color to the same field, as both examples above do.
  • y2 together with x_offset is a legal ranged, dodged bar; a span on the offset’s own axis is rejected with PRISM_SPEC_066.
  • Sub-band order, padding, composition behaviour and the diagnostics are all covered in Encoding › Offset channels.

Orientation (mark.orient)

A bar does not really have an “x axis” and a “y axis” — it has a category axis (the discrete band the bar sits in, which sets its thickness) and a measure axis (the continuous value, along which it grows from the data-zero baseline). Which physical axis plays which role is the mark’s orientation.

orientCategory axisMeasure axisBars grow
verticalxyup/down from a baseline at y = 0
horizontalyxright/left from a baseline at x = 0

The same split applies to every cartesian family, not just bar — a boxplot’s band and whisker caps sit on the category axis while its quantiles walk the measure axis, an area’s series runs along the category axis and fills to a baseline on the measure axis, and a tick draws a short segment along the measure axis at its category’s centre.

Inference

You usually do not write orient at all. It is inferred from whichever axis carries the discrete (band) scale, the same way Vega-Lite infers it:

x scaley scaleInferred
bandcontinuousvertical
continuousbandhorizontal
bandbandvertical (ambiguous; the default wins)
continuouscontinuouserror — neither axis can host the category

The last row holds for the marks that need a band to sit in (bar, rect, boxplot, violin, winloss). area and tick position rows along an axis that is usually continuous or temporal, so two continuous axes are perfectly legal there and fall back to the mark’s historic direction — vertical for area, horizontal for tick. An explicit orient still wins on those marks, and is the only way to draw a horizontal area.

So a nominal y against a quantitative x already draws a horizontal bar chart:

{
  "mark": "bar",
  "encoding": {
    "y": {"field": "channel", "type": "nominal"},
    "x": {"field": "delta",   "type": "quantitative"}
  }
}

An explicit orient overrides the inference. On a mark that needs a band it cannot invent one, though: "orient": "horizontal" against a continuous y fails with PRISM_ENCODE_001 naming the axis that needs the band, rather than drawing something else and hoping you notice.

Everything else about the mark is orientation-agnostic: the baseline, corner_radius, color grouping and the x2/y2 span channels all behave the same in either direction. A negative value crosses the baseline the same way too — leftward instead of downward.

Categories run in the same direction as every other Prism y scale: the first category sits at the bottom of a horizontal bar chart, not the top. Pin an explicit order with {"scale": {"domain": [...]}} when you want a different one.

Which marks read it

MarkMeaning of orientDefaultNeeds a band on the category axis
bar, rectSwaps the category and measure axes, as above.inferred, else verticalyes
progressSwaps the category and measure axes, as above.inferred, else horizontalyes
boxplot, violinSwaps the axes: the band holds the box / density fan, the measure axis the quantiles or samples.inferred, else verticalyes
winlossSwaps the axes: the streak runs across the band and the equal-length bars grow either side of the zero baseline.inferred, else verticalyes
sparkbarAs bar — sparkbar is a thin wrapper over the bar encoder.inferred, else verticalyes
area, sparkareaMoves the series axis and the fill baseline. A horizontal area runs bottom-to-top and fills to x = 0.verticalno
tickMoves the short segment to the other axis, centred in its category slot.inferred, else horizontalno
tree, dendrogram, networkThe direction the layout grows — not a category/measure swap. It also picks the side node labels sit on.verticaln/a
bulletUses its own orientation field instead (see Bullet).horizontaln/a
heatmapNot implemented — a heatmap is banded on both axes, so there is no category/measure split to swap.——
everything elseNot implemented — orient is rejected, never ignored.——

Two shapes are worth calling out:

  • y2 supplies an area’s explicit lower edge only while the area is vertical. On a horizontal area the fill measures along x, so a bound y2 would be a second position on the series axis — that combination is rejected rather than quietly dropped.
  • A horizontal boxplot or violin reads its category from y and its values from x, so swap the two channel bindings (or set orient explicitly) rather than only relabelling the axes.

radial is named by the vocabulary but implemented by no mark, so it is rejected too. For a radial reading reach for a polar mark (arc / pie / donut). Both rejections are PRISM_SPEC_046 at validate time; the rule is mark orient supported in validate/RULES.md.

Interpolation (line and area curves)

mark.interpolate selects how consecutive points are joined. It applies to the line and area families (sparkline and sparkarea inherit it, since they are thin wrappers over the same encoders). Default is linear.

interpolateShape
linearStraight segments between points. The default.
monotoneMonotone cubic spline (Fritsch–Carlson). Smooth, and provably never overshoots the data — the safe smoothing choice.
stepRight-angle steps with the riser midway between each pair of x values.
step-beforeRight-angle steps with the riser at the earlier x — the value changes before it is reached.
step-afterRight-angle steps with the riser at the later x — the value holds until the next point.
cardinalCardinal spline through every point. Smoother than monotone, but it may overshoot.

mark.tension (0–1) parameterises cardinal only; every other method ignores it. 0 is the default and the loosest curve; 1 collapses the spline back to straight segments. Values outside the range are clamped.

{
  "mark": {"type": "line", "interpolate": "monotone", "stroke_width": 2},
  "encoding": {
    "x": {"field": "day",  "type": "temporal"},
    "y": {"field": "load", "type": "quantitative"}
  }
}

Distribution geometry

PropertyMarkMeaning
maxbinshistogramUpper bound on the bin count. Omit for automatic bin selection.
violin_resolutionviolinKernel-density sample points per violin.

Arc geometry

PropertyMeaning
inner_radiusDonut hole radius in pixels (absolute; wins over the ratio).
inner_radius_ratioDonut hole as a fraction of the outer radius, [0, 1].
outer_radiusOuter radius in pixels.
pad_angleAngular gap between neighbouring sectors, in radians.

pad_angle is the gap between two sectors, not the inset applied to one: each sector gives up half the pad at each of its two ends, so two adjacent sectors end up pad_angle radians apart. A sector narrower than pad_angle collapses to nothing rather than drawing backwards.

Prism applies a single constant angular inset at every radius. d3-shape (and therefore Vega-Lite) varies the inset with radius so the linear gap stays constant from the inner to the outer edge; the two agree closely for a thin annulus and diverge for a full pie with a large pad. pad_angle: 0.02 (about 1.15°) is a good starting point:

{"mark": {"type": "donut", "pad_angle": 0.02, "inner_radius_ratio": 0.6}}

An area applies its curve to both boundaries — the upper edge and the reversed lower/baseline edge — so a band keeps parallel outlines rather than a curved top over a straight bottom. The short connector between the two edges is always a straight segment.

That is what makes area the streamgraph mark: a centred stack ("stack": "center", see Encoding › Centred stacks) hands each series a floating pair of edges, and a smooth interpolate carries both of them, so the ribbons read as one flowing stream. bar cannot take the centred offset — it is baseline-anchored geometry, and PRISM_SPEC_053 says so rather than drawing detached columns.

Geometry semantics match d3-shape’s curveLinear, curveMonotoneX, curveStep/curveStepBefore/curveStepAfter and curveCardinal.tension(t), so a Prism curve and the equivalent Vega-Lite / d3 curve trace the same path.

Out of scope. The basis and bundle families and d3’s -open / -closed variants are not implemented and are rejected by schema/v1/mark.schema.json at validation time rather than silently falling back.

Rendering. linear lines emit <polyline points="…">; every other interpolation emits <path d="…"> with the same prism-mark-line class, identity and style attributes. The split is intentional — <polyline> expresses a linear line exactly, and keeping it pins the byte shape of every committed linear golden and cross-impl fixture. Area marks were already <path> and stay so for every curve. All control points route through render/precision.go’s 3-decimal quantisation, so host Go, TinyGo-via-WASM and the browser bundle emit identical path data.

Nulls (mark.invalid)

A row carrying a null in a scale-bound channel (x / y) cannot be positioned, and mark.invalid decides what happens to it.

ValueEffect
"filter"Drop the row. Its category leaves the scale domain with it, so the axis never mentions it and a path closes over the hole. The default, and the only behaviour before v0.16.
"break"Keep the row. Its category holds its slot on the axis, no mark is drawn for it, and line / area split into separate segments either side of the gap.

Supported by line, area, point, bar, rule and text. Any other mark rejects "break" with PRISM_SPEC_062 rather than silently filtering instead — the polar, histogram, specialty and geographic families build their own geometry and never hand a raw field value to a scale, so neither mode means anything there.

A lone measurement between two gaps is drawn as a dot, because a one-point path renders nothing and "break" must not hide a row the author asked to keep.

See Nulls at encode time for the full policy, including which channels are scale-bound and what happens when every row is null.

Channel allowlists

Not every channel is valid for every mark — theta only makes sense on arc, source/target only on sankey, etc. The validator catches mismatches with PRISM_SPEC_003.

Three capabilities are gated by their own codes rather than by the general allowlist, because the mark accepts the channel’s shape and simply cannot draw what it asks for: the span channels (PRISM_SPEC_042), mark.orient (PRISM_SPEC_046) and the offset channels (PRISM_SPEC_063). All three reject rather than ignore.

Renderer compatibility

Every mark listed above renders through both Go backends (render/svg and render/html — see Themes: Rendering backends); render/html reuses render/svg’s own emitters internally, so there is nothing mark-specific to opt into.

The one exception is the table mark: it renders as DOM/CSS markup — sortable/paginated rows, row selection — with no SVG geometry equivalent. Requesting a top-level table mark via the svg backend fails with PRISM_RENDER_MARK_UNSUPPORTED naming the mark and backend, rather than silently emitting an empty <svg>; render it via the html backend instead. This restriction applies only to a table mark used directly — embedding a geometry-bearing mark (e.g. a sparkline column) inside a table’s cells is unaffected and renders normally via either backend (the html backend re-invokes render/svg’s own emitters for that one cell’s inline <svg>).

The html backend’s <table> markup is inert until wired up with static/vendor/prism/prism-table.mjs (E1-S5): installTableHandlers(root) attaches header-click sort (by each column’s underlying field value — read from a data-prism-sort-value attribute stamped on every <td>, not the cell’s rendered display, so a sparkline column sorts by its numeric series rather than by its <svg> markup), client-side pagination (slices the already-rendered rows using page_size; no extra network/WASM round trip), and row-click selection (dispatches the same structured prism:select event other marks emit, keyed off the data-prism-datum-row attribute every <tr> carries). A host page that serves/mounts server- or CLI-produced html-backend output (prism plot --format html) imports prism-table.mjs directly and calls installTableHandlers(root) itself, independent of the <prism-chart>/WASM pipeline.

<prism-table> (E4-S2, registered in prism-element.mjs alongside <prism-chart>) is the live-in-browser counterpart: it renders a spec/src through prism.renderHTML (the WASM HTML backend bridge — see Browser: Render backends) and calls installTableHandlers on the mounted result automatically, so a table mark is now live-renderable in the browser exactly like any other mark, just through the HTML backend instead of the SVG one.

Worked examples

Every mark above has a fixture in the gallery, with one exception: custom has no gallery fixture, since rendering one requires a registered CustomRenderer implementation (Go code), not just a JSON spec — see the Custom marks cookbook for worked, runnable examples instead. Start the gallery tour with:

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

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

Selections

Selections drive interactive scene filtering. Two kinds (point and interval), two reactive modes (client and server), one wire protocol (CustomEvent('prism:select')).

Declaring a selection

{
  "selection": {
    "brush": {"type": "interval", "encodings": ["x"]},
    "click": {"type": "point", "encodings": ["color"]}
  },
  "mark": "bar",
  "encoding": {
    "x": {"field": "brand_id", "type": "nominal"},
    "y": {"field": "score", "type": "quantitative"},
    "color": {
      "condition": {"selection": "click", "field": "category"},
      "value": "#d1d5db"
    }
  }
}

Point vs interval

KindTriggerState
pointClick on a mark{points: [{layerID, rowID}]}
intervalDrag-brush on plot region{range: {channel, min, max}}

Reactive modes

ModeLoop
clientBrush/click → DOM class toggle on marks. Zero network.
serverBrush/click → POST /prism/scene with synthesized filter → re-render.
bothApply client immediately, server in background.

Cross-chart filtering

<prism-coordinator>
  <prism-chart spec="overview.prism.json"></prism-chart>
  <prism-chart spec="detail.prism.json"></prism-chart>
</prism-coordinator>

Both charts declaring the same selection ID synchronize via the coordinator. A brush on the overview filters the detail.

URL state

Selection state round-trips through window.location.hash so shareable links restore the brush:

https://your-app.example/dashboard#prism-sel:<base64>

Falls back to localStorage when the encoded state exceeds 1024 characters.

Hit-test attributes

Every SVG mark carries:

  • data-prism-layer="<layer-id>"
  • data-prism-datum-row="<row-id>"

The JS port reads these to resolve clicks back to source rows.

Structured event shape

Every prism:select CustomEvent carries the same structured payload across browser, Go, and Twirp contexts. The shape mirrors the Go selection.Event struct (package github.com/frankbardon/prism/selection):

{
  "scene_id":     "scene-0",
  "selection_id": "brush",
  "kind":         "point",          // "point" | "interval" | "lasso"
  "timestamp":    1716826200000,    // ms since epoch
  "marks": [
    { "mark_index": 0, "instance_key": "layer-0:42" }
  ],
  "data_rows": [
    { "dataset_name": "cohort", "row_index": 42 }
  ],
  "data_extent": { "x": { "min": 10, "max": 50 } },   // interval/lasso
  "pixel_extent": { "x": { "min": 120, "max": 480 } },// interval/lasso, optional
  "spec_path": "/selection/brush"
}

mark_index is the index of the layer in the spec’s layer array (or 0 for a single-mark spec). instance_key is <layer_id>:<row_id> and is stable across re-renders for the same source row. data_extent is the canonical (renderer-size-independent) representation of an interval brush; pixel_extent is best-effort UI-overlay info.

The browser handler:

chart.addEventListener("prism:select", (ev) => {
  for (const mark of ev.detail.marks) {
    // mark.mark_index, mark.instance_key
  }
});

The Go side builds the same shape from raw input via selection.Build(...). Legacy id and state keys are retained on the event payload for back-compat with handlers written before the structured-event upgrade.

Driving conditional encodings

A selection name can drive a per-channel condition clause so marks switch fills, strokes, or opacities live as the selection state changes. See the brush_highlight gallery fixture and the highlight-on-brush cookbook recipe.

Worked examples

Themes

Themes drive colors, fonts, spacing, and per-mark defaults across all renderers. A single Go struct (theme.Theme) is the source of truth; resolved tokens emit as CSS variables that the SVG output and the live browser component both consume.

Bundled themes

NameWhen to use
light (default)Standard web pages, light backgrounds. Tableau10 categorical + Viridis sequential.
darkDark dashboards, terminal embeds. Observable10 categorical + Magma sequential.
printReports, print-ready output. Grayscale only, no transparency on lines, hatch-friendly.
high_contrastProjector / presentation, low-vision readers. Pure black/white, bold weights, no grid lines.
colorblindColorblind-safe defaults. Okabe-Ito categorical + Cividis sequential (deuteranopia-tuned).

Pick at plot time

prism plot bar.json --theme=dark > bar-dark.svg
prism plot bar.json --theme=colorblind > bar-cb.svg

Theme structure

theme.Theme is composed of nested blocks. Every field is optional — absent fields inherit from the registered base.

{
  "name": "my_theme",
  "base": "light",

  "mark":   { "fill": "#4c78a8", "opacity": 1 },
  "marks": {
    "bar":  { "fill": "#4c78a8", "corner_radius": 2 },
    "line": { "stroke": "#4c78a8", "stroke_width": 1.5, "fill": "transparent" },
    "area": { "fill": "#4c78a8", "opacity": 0.7 },
    "point":{ "fill": "#4c78a8", "size": 64 }
  },

  "axis": {
    "domain_color":  "#6b7280",
    "tick_color":    "#6b7280",
    "tick_size":     5,
    "grid_color":    "#e5e7eb",
    "label_color":   "#111827",
    "label_font_size": 11,
    "label_line_height": 1.2,
    "label_letter_spacing": 0.1,
    "title_color":   "#111827",
    "title_font_size": 12,
    "title_padding": 8,
    "title_line_height": 1.2,
    "title_letter_spacing": 0.1
  },

  "legend": {
    "label_color":      "#111827",
    "label_line_height": 1.2,
    "title_font_weight":"600",
    "title_line_height": 1.2,
    "symbol_size":      64,
    "padding":          8
  },

  "title": {
    "color":      "#111827",
    "font_size":  16,
    "font_weight":"600",
    "anchor":     "start",
    "line_height":    1.25,
    "letter_spacing": 0.2
  },

  "view": {
    "background":   "transparent",
    "padding":      0
  },

  "range": {
    "category":  { "scheme": "tableau10" },
    "ordinal":   { "scheme": "blues" },
    "ramp":      { "scheme": "viridis" },
    "heatmap":   { "scheme": "viridis" },
    "diverging": { "scheme": "rdbu" }
  },

  "schemes": {
    "brand_primary": ["#001eff", "#33ffaa", "#ff3366"]
  },

  "style": {
    "rule_emphasis": { "stroke": "#000000", "stroke_width": 2 }
  },

  "states": {
    "selected":   { "opacity": 1 },
    "deselected": { "opacity": 0.3 }
  },

  "filters": {
    "soft_shadow": "<feDropShadow dx=\"0\" dy=\"2\" stdDeviation=\"2\" flood-opacity=\"0.3\"/>"
  },

  "raw_css": ".prism-mark-bar:hover { filter: brightness(1.1); }",

  "gradients": {
    "brand_fade": {
      "type": "linear",
      "angle": 90,
      "stops": [
        { "offset": 0,   "color": "#4c78a8" },
        { "offset": 1,   "color": "#f58518" }
      ]
    },
    "spot_glow": {
      "type": "radial",
      "cx": 0.5, "cy": 0.5, "radius": 0.75,
      "stops": [
        { "offset": 0, "color": "#ffffff" },
        { "offset": 1, "color": "#4c78a8" }
      ]
    }
  },

  "patterns": {
    "hatch": { "type": "cross-hatch", "color": "#6b7280", "spacing": 6, "size": 1 },
    "custom_dots": { "content": "<circle cx=\"2\" cy=\"2\" r=\"1\" fill=\"#4c78a8\"/>" }
  },

  "category_styles": {
    "Origin": {
      "USA":    { "fill": "#4c78a8" },
      "Europe": { "fill": "#f58518" },
      "Japan":  { "fill": "#e45756" }
    }
  }
}

Block reference

BlockDrives
markDefault style applied to every mark unless marks.<type> overrides.
marks.<type>Per-mark-type defaults. Key matches the spec’s mark.type (bar, line, area, point, rule, text, tick, rect, arc, geoshape, geopoint, …).
marks.<type>_<element>Defaults for one element of a mark family that draws several per row — progress_track and boxplot_median today. See Multi-element marks.
axisAxis domain, ticks, grid, labels, titles — applied to both cartesian axes.
axis_x / axis_ySame token set as axis, layered over it per property for one axis only (see Per-axis blocks). Because the x axis draws the vertical grid lines and the y axis the horizontal ones, these are also how grid lines are themed per orientation.
legendLegend fills, symbols, labels, padding.
titleChart title typography.
viewChart-rect background, stroke, padding.
rangeDefault color scheme per scale role (category, ordinal, ramp, heatmap, diverging, symbol, cyclic).
schemesPer-theme custom named-scheme registry. Entries shadow the global catalogue.
styleNamed-style registry — marks reference an entry via their style attr.
statesState overlays (selected, deselected, hover, focus). Materialise as .prism-<state> CSS classes.
filtersNamed registry of raw SVG <filter> inner-content bodies. mark/marks.<type>/style.<name>/axis/legend/title/view each carry a filter field naming an entry here.
raw_cssRaw CSS string appended verbatim to the emitted <style> block.
gradientsNamed registry of linear/radial gradient definitions, referenced via url(#name) fills (see Gradients and patterns).
patternsNamed registry of pattern fills — built-in catalogue or raw-SVG content — referenced via url(#name) fills (see Gradients and patterns).
dark_variantName of a registered counterpart theme for automatic light/dark rendering (see Dark variant pairing).
category_stylesField name → field value → MarkStyle map for theme-level data-driven styling (see Category styles).

Mark style precedence

A mark’s final style is resolved by folding four layers in a fixed order, each shadowing the previous per field. A layer that leaves a field unset never clears what an earlier layer wrote:

  1. Prism’s built-in fallback for the mark type (so a chart still renders under a theme that declares nothing).
  2. The theme’s mark block — the global default for every mark.
  3. The theme’s marks.<type> block — the per-mark-type override.
  4. The spec’s own mark_def — see Marks: style properties.

The spec wins. Anything written in a spec’s mark: {…} object shadows the theme token of the same name; a theme can only supply the default for a property the spec does not state. Encoding channels and conditions resolve after all four and shadow the lot, since they vary per row.

theme.MarkStyle and spec.MarkDef share field names by design where they mean the same thing. The overlap is:

PropertyIn theme mark / marks.<type>In spec mark_def
fill, stroke, stroke_width, stroke_dash✅✅
opacity, fill_opacity✅✅
corner_radius, size, shape✅✅
font_size, font_weight, font_style✅✅
align, baseline✅✅
line_height, letter_spacing, filter✅— theme-only
stroke_opacity, font (family)— spec-only✅
dx, dy, angle, pad_angle, radii— spec-only✅

So a theme can set a house fill_opacity for every area mark and an individual chart can still override it:

{
  "mark": {"type": "area", "fill_opacity": 0.9},
  "encoding": {"x": {"field": "t", "type": "temporal"},
               "y": {"field": "v", "type": "quantitative"}}
}

renders at fill_opacity: 0.9 even under a theme declaring "marks": {"area": {"fill_opacity": 0.3}}, while that theme’s stroke and corner_radius (which the spec does not mention) still apply.

stroke_dash follows the same per-field rule, with one wrinkle worth stating: it is a list, and the override is whole-pattern rather than element-wise. A spec "stroke_dash": [6, 3] replaces a theme’s [2, 2] outright; an omitted key keeps the theme’s; and an empty array reads as omitted, so a mark cannot go solid by writing [] — drop the token from the theme instead. Both sides spell the field stroke_dash on the wire and StrokeDash in Go, so when reading the encoder, check whether the receiver is a theme.MarkStyle or a spec.MarkDef before concluding which one a line applies.

Multi-element marks

Most marks draw one shape per row, so one marks.<type> key styles the whole thing. Some families draw several, and an element painted with the family’s own colour can be invisible — so those elements claim their own key:

KeyStyles
marks.progressA progress mark’s value bar. Behaves like any other marks.<type> block.
marks.progress_trackThe unfilled track behind it.
marks.boxplot_medianThe median line a boxplot draws across its box.

A progress mark draws a value bar and the unfilled track behind it showing how much distance is left; a track that inherits the bar’s fill is invisible.

A boxplot draws a box, a median, two whisker stems and two caps. The whiskers sit outside the box, where the box’s own colour is the right and legible choice, so they take it. The median is drawn on the box, so it is the one element that colour cannot serve — a median stroked in the box’s fill is exactly as unreadable as a median with no stroke at all. Every bundled theme already answers this question for arc, whose wedge separators face the same problem, so the shipped boxplot_median defaults mirror each theme’s own arc stroke rather than inventing a second convention.

progress_track takes the full MarkStyle shape, so the track honours fill, stroke, stroke_width, opacity, a filter, and url(#name) gradient / pattern refs exactly as a bar does:

{
  "theme": {
    "name": "light",
    "marks": {
      "progress": {"fill": "#2563eb", "corner_radius": 4},
      "progress_track": {"fill": "#eef2ff", "stroke": "#c7d2fe", "stroke_width": 1}
    }
  }
}

Two things about these element keys differ from a normal per-type block, both on purpose:

  • They are not mark types. mark.type cannot be set to progress_track or boxplot_median; each names an element, not something you can draw on its own.
  • They do not inherit the global mark block. Every other marks.<type> key layers over mark, but mark.fill is the data fill — the very value that makes these elements vanish. A track that inherited it would come out the same colour as the bar sitting on it, and a median the same colour as the box it crosses. A theme that sets neither key falls back to a derived default instead: the grid colour for a track, and the whisker treatment for a median.

The median also carries its own prism-mark-boxplot-median class, so it can be selected apart from the whiskers, which are prism-mark-rule like any other line.

Every bundled theme states both keys. high_contrast is the one that diverges from the grid-colour default: its grid colour is pure black, which would paint a black track under a black value bar, so it uses a white track with a black outline.

The two elements also carry distinct CSS classes in the rendered SVG — prism-mark-progress and prism-mark-progress-track — so downstream stylesheets can scope to either half. Every other mark is classed by its geometry (both of these would otherwise be prism-mark-bar).

Per-axis blocks

axis states house style for both cartesian axes at once. When the two need to differ, axis_x and axis_y layer over it — carrying the same token set, and overriding per property:

{
  "axis":   { "grid_color": "#e5e7eb", "tick_color": "#6b7280" },
  "axis_x": { "grid_color": "#f3f4f6" }
}

The x axis draws a #f3f4f6 grid and keeps the #6b7280 tick colour it inherited; the y axis takes both values from axis unchanged. A block that sets one token does not reset the rest — nothing is replaced wholesale.

Grid orientation. An x axis emits the vertical grid lines and a y axis the horizontal ones, so axis_x.grid_color / axis_y.grid_color (and grid_width, grid_dash, grid_opacity) are how the two orientations are coloured apart — a common request that axis alone cannot express. A frequent pattern is horizontal rules only:

{
  "axis_x": { "grid_color": "transparent" },
  "axis_y": { "grid_color": "#e5e7eb", "grid_dash": [2, 2] }
}

x2 and y2 resolve to the same block as their base channel — a span channel never carries an axis of its own.

How it is applied

Most of these tokens ride the CSS cascade: theme/css.go emits the per-axis block as custom-property declarations scoped to that axis’s own group, and the renderer already wraps each axis (its grid lines included) in <g class="prism-axis prism-axis-x">:

:root{--prism-grid-color:#e5e7eb;}
.prism-axis-x{--prism-grid-color:#f3f4f6;}

Custom properties inherit, so the scoped declaration shadows the :root one for that axis alone. The per-property merge is the cascade — nothing is pre-folded, and the same post-hoc restyling that works on --prism-grid-color works on the scoped variable.

Tokens that move SVG coordinates (tick_size, label_padding, title_padding) or that are emitted as SVG attributes rather than CSS (label_line_height, label_letter_spacing, title_line_height, title_letter_spacing) cannot travel that way — a CSS variable cannot move a line endpoint or a text coordinate. Those ride the Scene IR instead, on scene.Theme.axis_x / axis_y, and the renderer falls back to the shared axis value property by property. filter lands on the axis’s own group and composes with the shared block’s filter on the enclosing prism-axes group.

A theme that declares neither block emits exactly the bytes it did before the blocks existed: this is purely additive.

Axis geometry precedence

Three axis tokens are geometry, not appearance: tick_size (the major tick length), label_padding (the gap between the axis line and its tick labels) and title_padding (the gap between those labels and the axis title). They move SVG coordinates, so unlike the colour and font tokens they cannot be re-styled after the fact by overriding a CSS variable — the variables --prism-axis-tick-size, --prism-axis-label-padding and --prism-axis-title-padding are emitted for reference, but the geometry itself is resolved before the SVG is written.

All three names also exist on a position channel’s axis block (see Encoding: axis components and geometry). Where they overlap, the resolution order is fixed, highest first:

  1. The spec’s axis block — "axis": {"tick_size": 12} on the channel.
  2. The theme’s axis_x / axis_y block — the per-axis override (see Per-axis blocks).
  3. The theme’s axis block — "axis": {"tick_size": 9}.
  4. Prism’s built-in metric — 5 px tick, 4 px label padding, 8 px title padding.

This is the one precedence chain every axis token follows, geometry or not; only the machinery differs (the CSS cascade for colour and font tokens, resolveAxisMetric in the SVG renderer for geometry). Each level shadows the previous per property.

The spec wins. A theme can set the house tick length for every chart, and an individual chart still overrides it per channel; the theme value applies only where the axis block says nothing. This matches mark style precedence: the theme supplies defaults, the spec states intent.

{ "axis": { "tick_size": 9, "label_padding": 10 } }

under a spec whose x channel carries "axis": {"tick_size": 12} renders a 12 px x tick (spec) with a 10 px label gap (theme, since the spec left label_padding alone), while the y axis — which states nothing — takes both theme values.

title_padding is measured from the tick labels, not from the plot edge, and it is added to a fixed per-side text allowance rather than replacing it, so the 8 px every built-in theme states reproduces the title position Prism has always drawn. Note that a padding larger than the default draws into the outer margin: the layout reserves a fixed depth per side and does not grow it to follow a token, the same text-metric deferral tick_size and label_padding are subject to.

Minor ticks are not separately tokenised; they render at 0.6× whatever major tick size resolves, so the default 5 px still yields a 3 px minor tick. The remaining axis knobs — labels, ticks, domain, label_limit and zindex — are spec-only: they decide whether a component is drawn and in what order, which is an authoring decision rather than a house style, so no theme token shadows them.

Typography tokens

line_height and letter_spacing are optional pointer-typed typography tokens (same “absent means inherit” semantics as font_size and every other sparse numeric token). The SVG renderer (and render/html, which delegates to it) applies letter_spacing as a letter-spacing presentation attribute and line_height as a style="line-height:…" declaration directly on the resolved title / axis-label / axis-title / legend-label / legend-title / text-mark <text> element — these are per-element, conditional attributes (nothing is emitted when a token is left unset), not the CSS-variable + fixed-class mechanism used for font_size/font_weight/color tokens. line_height only has a visible effect on multi-line text; Prism does not yet wrap title, axis-label, or text-mark content onto multiple lines, so today the property is present in the markup (ready for any future wrapping) but inert for every element except where content already spans multiple <tspan>s.

BlockFields carrying the tokens
title (TitleStyle)line_height, letter_spacing — applies to the chart title text.
axis (AxisStyle)label_line_height/label_letter_spacing (tick labels) and title_line_height/title_letter_spacing (axis title), mirroring the existing label_font_size/title_font_size split.
legend (LegendStyle)label_line_height/label_letter_spacing (entry labels) and title_line_height/title_letter_spacing (legend title), mirroring the existing label_font_size/title_font_size split.
mark/marks.text/style.<name> (MarkStyle)line_height, letter_spacing — applies to text-mark content.
{
  "mark": { "font_size": 12, "line_height": 1.3, "letter_spacing": 0.2 },
  "axis": {
    "label_font_size": 11, "label_line_height": 1.2, "label_letter_spacing": 0.1,
    "title_font_size": 12, "title_line_height": 1.2, "title_letter_spacing": 0.1
  }
}

No @font-face / font-loading support is implied or added by these tokens — Prism only sets the CSS typography properties on already font-resolved text elements.

Raw CSS and filter escape hatch

filters and raw_css, plus the filter field on mark / marks.<type> / style.<name> / axis / legend / title / view, are an escape hatch for visual effects Prism’s typed tokens don’t model directly (drop shadows, blurs, hover states beyond states, arbitrary selectors). A filter value must name a key present in the theme’s filters map — an unresolved reference fails loudly at theme load (PRISM_THEME_FILTER_UNKNOWN) rather than silently rendering without the effect, an intentional departure from range.*’s scheme-name fallback behavior.

Trust boundary: theme JSON — including raw_css and every filters body — is developer-authored and trusted the same as spec JSON is today. Prism does not sanitize or sandbox this content before it lands in the rendered <style>/<filter> markup. Never route untrusted or attacker-influenced theme JSON (e.g. end-user-supplied theme files in a multi-tenant service) through theme.LoadFile / theme.LoadBytes / a spec’s inline theme.raw_css / theme.filters override.

Rendering: the SVG backend emits one <filter id="prism-filter-<name>"> element per entry in filters, wrapping the raw body verbatim inside a single top-level <defs> block. Any style block whose resolved filter names an entry gets filter="url(#prism-filter-<name>)" on the corresponding element — the mark itself, the <g class="prism-axes"> wrapper, the <g class="prism-legends"> wrapper, the title <text>, and (only when view.filter is set) a <rect class="prism-view"> background rect sized to the chart frame. raw_css is appended verbatim inside the <style> block, after the generated :root{--prism-*} variable manifest and fixed class selectors. render/html/ inherits both automatically — it wraps render/svg’s own emitters and splices the resulting bytes verbatim, so no separate glue was needed. The Canvas backend does not implement this escape hatch.

Gradients and patterns

gradients and patterns declare named fill definitions on the theme. A mark.fill/stroke, marks.<type>.fill/stroke, or view.background value written as url(#name) — the same convention native SVG fill/stroke use — resolves against these registries: theme.gradients is checked first, then theme.patterns. Any other value (a hex color, a CSS color keyword, "transparent", …) is unaffected and keeps resolving as a plain literal color exactly as before. A resolved reference renders as an actual <linearGradient>/<radialGradient>/<pattern> def, with the resolved attribute rewritten to fill="url(#prism-gradient-<name>)" or fill="url(#prism-pattern-<name>)" (same for stroke, and for the view background rect). A url(#name) value that doesn’t name a registered gradient or pattern fails loud at theme load instead of silently rendering nothing.

Note the per-type marks.<type> block always outranks the global mark block for any field it sets (see Theme structure) — every built-in theme ships a per-type default fill for common marks like bar, so a url(#name) fill usually needs to go on marks.bar.fill (etc.) rather than the global mark.fill to actually take effect.

A GradientDef is either "linear" (oriented by angle, in degrees, 0 = left-to-right, clockwise) or "radial" (centered at cx/cy — fractions of the shape’s bounding box, default 0.5 each — with a radius fraction). Every gradient needs at least two stops, each an { "offset": 0-1, "color": "..." } pair:

{
  "gradients": {
    "brand_fade": {
      "type": "linear",
      "angle": 90,
      "stops": [
        { "offset": 0, "color": "#4c78a8" },
        { "offset": 1, "color": "#f58518" }
      ]
    }
  }
}

A PatternDef is either a built-in catalogue entry — type set to one of diagonal-stripes, dots, cross-hatch, grid, tuned via color, spacing, and size — or a bespoke pattern supplied as raw SVG through content (the inner markup of the <pattern> element, verbatim). Exactly one of type or content must be set. spacing and size default to 8 and 4 (user-space pixels — pattern tiles use patternUnits="userSpaceOnUse", so they stay a fixed physical size regardless of the shape they fill) and color defaults to #000000 for built-in types when unset:

  • diagonal-stripes — a solid stripe of width size per tile (pitch spacing), tile rotated 45°.
  • dots — one centered dot of diameter size per tile (pitch spacing).
  • cross-hatch — an X across the tile (size stroke width, tile size spacing).
  • grid — a lattice of size-wide lines at spacing pitch.
{
  "patterns": {
    "hatch":       { "type": "cross-hatch", "color": "#6b7280", "spacing": 6, "size": 1 },
    "custom_dots": { "content": "<circle cx=\"2\" cy=\"2\" r=\"1\" fill=\"#4c78a8\"/>" }
  }
}

Validation: both maps are checked structurally at theme load (Register, LoadFile/LoadBytes), the same fail-loud entry points as the filter escape hatch. A gradient with an unrecognized type, fewer than 2 stops, an out-of-range offset, or an empty stop color fails with PRISM_THEME_GRADIENT_INVALID. A pattern that sets both type and content (or neither), names a type outside the built-in catalogue, or sets a non-positive spacing/size fails with PRISM_THEME_PATTERN_INVALID. On top of that, every fill/stroke on mark, marks.<type>, and style.<name>, plus background on view, is checked for the url(#name) form; a reference that names neither a gradients nor a patterns entry fails with PRISM_THEME_FILL_REF_UNKNOWN (mirroring PRISM_THEME_FILTER_UNKNOWN for the filter escape hatch).

Trust boundary: content on a PatternDef is the same trust tier as filters/raw_css — developer-authored SVG that Prism does not sanitize. Never route untrusted theme JSON through it.

Rendering: the SVG backend emits one <linearGradient>/ <radialGradient id="prism-gradient-<name>"> element per entry in gradients and one <pattern id="prism-pattern-<name>"> element per entry in patterns, inside the same top-level <defs> block the filter escape hatch uses. A resolved fill/stroke/background gets rewritten to url(#prism-gradient-<name>) / url(#prism-pattern-<name>) on the corresponding element — the mark itself, or (only when view.background resolves) a <rect class="prism-view"> background rect sized to the chart frame. render/html/ inherits this automatically, the same as the filter escape hatch. The Canvas backend does not implement this escape hatch.

Category styles

category_styles is a theme-level, reusable data-driven style map so a chart author doesn’t have to repeat the same per-value styling as a spec-level condition block in every spec that encodes a given field. The shape is a nested map — outer key is a field name, inner key is the field’s stringified value, leaf is a full MarkStyle (not just a color, unlike range, which is color-only and keyed by scale role rather than an actual data value):

{
  "category_styles": {
    "Origin": {
      "USA":    { "fill": "#4c78a8" },
      "Europe": { "fill": "#f58518" },
      "Japan":  { "fill": "#e45756" }
    },
    "Status": {
      "at_risk": { "fill": "#e45756", "stroke": "#7f1d1d", "stroke_width": 2 }
    }
  }
}

A nested field → value → style map is used instead of a flat "field=value" string key on purpose — it avoids inventing a mini string grammar to parse, consistent with the project’s no-expression-language stance (filter/calculate/condition test are all structured JSON built-ins; see Spec format).

Precedence (a spec’s own condition wins): once a chart encodes a field that has a matching category_styles entry, the theme-level style applies automatically as a default layer, with any spec-level condition on the same channel — targeting the same field/value — winning over it if both apply (explicit beats theme default). This mirrors the general cascade order elsewhere in theme/ (a more specific block always outranks a more general one for any field it sets).

Applied at encode time. For every channel bound to a field (encode.categoryStyleFieldsAt walks the same channel set encode/encode_condition.go does — position channels plus color/fill/stroke/opacity/size/shape), the encoder looks up each datum’s value for that field in category_styles[field] and, on a match, merges the resolved MarkStyle onto the mark’s already -resolved style via theme.MergeMarkStyle (encode/encode_category_styles.go, function applyCategoryStyles). Only the fields the theme author set on that entry move — an entry that sets only stroke leaves whatever fill the mark already had untouched. Data whose field value has no matching entry renders with the base/default style, unchanged. applyCategoryStyles always runs immediately before encode.applyConditions in the pipeline, so a spec-level condition targeting the same field/value is applied afterward and overwrites whichever attrs it resolves — giving the condition precedence exactly as designed.

Dark variant pairing

dark_variant names a registered counterpart theme:

{
  "name": "brand_light",
  "base": "light",
  "dark_variant": "brand_dark"
}

Setting dark_variant alone is the opt-in for automatic light/dark rendering — there is no separate flag. A theme that declares one signals the renderer to embed both palettes in a single SVG/HTML output and switch between them at view time via prefers-color-scheme, without a second plot/render call.

Chrome dark-swap (live): CSSVariables() emits the base :root{...} block as before, then — only when dark_variant resolves — a second rule appended inside the same <style> element:

@media (prefers-color-scheme: dark) {
  :root {
    --prism-color-axis: #9ca3af;
    --prism-axis-domain-color: #9ca3af;
    /* ...legend/title/view/selection-state tokens... */
  }
}

This covers every token that already resolved through var() end-to-end before this feature existed: axis strokes/ticks, grid lines, title text, legend text, view background/stroke, and .prism-selected/.prism-deselected opacity — computed by running the same theme/css.go emission helpers against the paired theme. The fixed class selectors (.prism-axis-domain, .prism-grid-line, …) are not duplicated inside the media query — they already read through var(), so only the custom-property values need to swap. When dark_variant is unset (or names a theme that fails to resolve), no media query is emitted and output is byte-identical to a theme with no dark_variant at all.

Mark colors dark-swap too (E4-S3). When dark_variant resolves, encode/encode.go resolves every mark color — both scale-driven (categorical/sequential palette lookups in encode/palette.go) and static per-mark-type theme colors (theme.MarkStyle.Fill/Stroke via applyThemeMarkStyle) — against both the active theme and its dark_variant counterpart. Each distinct (light, dark) pair encountered gets a stable variable name in first-encounter order: --prism-resolved-0, --prism-resolved-1, … (same input spec + theme pairing → same names every render, so golden fixtures stay stable). Both values land in the <style> block — light under the base :root{...}, dark under the @media rule above — and the mark element emits fill="var(--prism-resolved-N)" / stroke="var(--prism-resolved-N)" instead of a baked hex literal:

:root { --prism-resolved-0: #4c78a8; }
@media (prefers-color-scheme: dark) {
  :root { --prism-resolved-0: #4269d0; }
}
<rect class="prism-mark-bar" fill="var(--prism-resolved-0)" .../>

This is carried on the scene IR by scene.Style.FillVar/StrokeVar (a CSS custom-property name, additive alongside the existing Fill/Stroke/FillRef/StrokeRef fields — same “optional ref wins over the baked value” precedent as the gradient/pattern FillRef). render/html/ inherits this automatically, same as every other render/svg emission. When dark_variant is unset, none of this runs: every mark keeps baking a literal hex exactly as before E4-S3. Legend swatches are not yet re-plumbed onto resolved vars — they still bake the light-theme hex, a known gap for a future story.

Validation: a non-empty dark_variant must name a theme already present in the registry — checked at the same fail-loud entry points as the filter/gradient/pattern escape hatches (Register, LoadFile/LoadBytes). An unresolved name fails with PRISM_THEME_DARK_VARIANT_UNKNOWN rather than silently rendering without a dark counterpart. Because validation checks the registry as of registration time, pairing only works in one direction per Register call: the counterpart named by dark_variant must already be registered (built-in themes register in a fixed order at package init() — see theme/registry.go). None of Prism’s built-in themes (light, dark, print, high_contrast, colorblind) set dark_variant on each other in this story; a future story that wants to pair built-ins together needs either a two-pass registration (register all themes first, then a second pass that only sets dark_variant and re-validates) or an explicit PairThemes(a, b string) error helper — not yet implemented.

Color schemes

Prism ships the d3-scale-chromatic catalogue plus four accessibility-focused additions. Reference any scheme by name in scale.scheme or theme.range.*.scheme.

Categorical

category10, tableau10, observable10, accent, dark2, paired, pastel1, pastel2, set1, set2, set3, okabe_ito, tol_bright, tol_vibrant, tol_muted.

Sequential (single-hue)

blues, greens, greys, oranges, purples, reds.

Sequential (multi-hue)

bugn, bupu, gnbu, orrd, pubu, pubugn, purd, rdpu, ylgn, ylgnbu, ylorbr, ylorrd.

Sequential (perceptually uniform)

viridis, magma, plasma, inferno, cividis, turbo, warm, cool.

Diverging (Brewer 9-class)

rdbu, rdylbu, brbg, prgn, piyg, puor, rdgy, rdylgn, spectral.

Cyclic

rainbow, sinebow.

Accessibility note

The four Prism extensions — okabe_ito, tol_bright, tol_vibrant, tol_muted — are colorblind-safe palettes from peer-reviewed sources (Wong 2011, Tol 2018). The default colorblind theme uses okabe_ito for categorical channels and cividis for continuous channels.

Where a scheme sits in the cascade

A named scheme is the second tier of palette resolution, not the first. The full order, highest first:

  1. scale.range on the channel — an inline color list supplied by the spec author. Nothing overrides it.
  2. scale.scheme on the channel — a name from the catalogue above, or from the theme’s own schemes registry (which shadows the global catalogue for that name).
  3. The theme’s range slot for the role — range.category for a discrete channel, range.ramp then range.heatmap for a continuous one.
  4. The theme’s legacy flat color_scheme_categorical / color_scheme_sequential field.
  5. Prism’s built-in default palette (a category10 derivative), or a 9-stop blues ramp on the continuous side.

An unknown scheme name degrades quietly to the next tier so a malformed spec still renders; validate reports it separately as PRISM_SPEC_028. An inline range is the one tier that does not degrade as a unit — unparseable entries are dropped and the rest still win, and only a wholly unparseable range falls through.

Traversing a ramp — scale.interpolate

A continuous ramp — whether it came from a scheme above or from an inline scale.range — is traversed in the colorspace scale.interpolate names: rgb (default), hsl, or lab. Vega-Lite’s hcl is not supported. lab is the one to reach for when a ramp’s steps need to read as evenly spaced rather than merely be evenly spaced in sRGB.

Prism resamples the ramp at encode time, so the stops that reach a renderer already trace the chosen space’s curve. See Choosing the colors in the encoding reference for the full treatment.

Sparse override at spec level

{
  "$schema": "urn:prism:schema:v1:spec",
  "theme": {
    "name": "light",
    "marks": {
      "bar": { "fill": "#2563eb", "corner_radius": 4 }
    },
    "range": {
      "category": { "scheme": "okabe_ito" }
    },
    "axis_y": { "grid_dash": [2, 2] }
  }
}

Every block the theme struct carries is available here, axis_x / axis_y included — each merges against its own counterpart in the base theme, never against the shared axis block.

Spec-level overrides merge over the named base theme without restating the whole struct. Order of precedence:

hardcoded fallback
  ← theme.Mark
  ← theme.Marks[type]
  ← spec.theme overrides
  ← spec.mark.<field> (explicit per-spec override)
  ← per-row encoding

Custom theme via JSON

prism plot bar.json --theme=./brand.theme.json > bar.svg

A theme JSON file is just a theme.Theme document with an optional base field. When base names a registered theme, the file’s fields merge sparsely on top:

{
  "name": "brand",
  "base": "light",
  "marks": {
    "bar": { "fill": "#001eff", "corner_radius": 6 }
  },
  "schemes": {
    "brand": ["#001eff", "#33ffaa", "#ff3366"]
  },
  "range": {
    "category": { "scheme": "brand" }
  }
}

CSS variables emitted

Every SVG (and live web component shadow root) carries a <style> block declaring --prism-* variables for every set token. Override at runtime via DOM style assignment to live-switch theme aspects without re-rendering.

--prism-color-axis        --prism-color-grid     --prism-color-text
--prism-color-bg          --prism-font-sans      --prism-font-mono

--prism-axis-domain-color --prism-axis-tick-size --prism-axis-label-color
--prism-axis-label-padding --prism-axis-title-padding
--prism-grid-color        --prism-grid-width     --prism-grid-dash

--prism-mark-fill         --prism-mark-bar-fill  --prism-mark-line-stroke
--prism-mark-bar-corner-radius --prism-mark-point-size
--prism-mark-progress-fill     --prism-mark-progress_track-fill

--prism-legend-padding    --prism-legend-symbol-size --prism-title-font-size
--prism-view-bg           --prism-view-padding

--prism-selected-opacity  --prism-deselected-opacity

Every marks.<key> block emits one variable per token it sets, named --prism-mark-<key>-<token> — the key verbatim, so the multi-element progress_track block emits --prism-mark-progress_track-fill with its underscore intact.

The full set scales with the tokens the active theme defines — unset tokens omit the variable so renderers fall back to hard-coded defaults inside the CSS class declarations.

When the active theme sets dark_variant (see Dark variant pairing), the chrome-related subset of these variables (axis/grid/legend/title/view/selection-state — not the static --prism-mark-* defaults family) is emitted a second time, inside an @media (prefers-color-scheme: dark) { :root { ... } } rule appended after the base block in the same <style> element. Resolved per-instance mark colors ride a separate --prism-resolved-N family (E4-S3, see Dark variant pairing) that IS doubled the same way — light value in the base block, dark value in the media rule — since those are the actual colors marks paint with once auto-dark is active.

line_height and letter_spacing (see Typography tokens) are the one exception: they render as direct per-element attributes rather than --prism-* custom properties, so they are baked in at render time and are not runtime-overridable via DOM style assignment the way the tokens above are.

--prism-axis-tick-size, --prism-axis-label-padding and --prism-axis-title-padding are emitted, but they are reference only: a CSS variable cannot move an SVG line endpoint or a <text> coordinate, so the geometry those three tokens describe is resolved at render time (see Axis geometry precedence). Overriding them in the DOM restyles nothing.

A theme’s axis_x / axis_y blocks (see Per-axis blocks) emit the same --prism-axis-* / --prism-grid-* names a second time, scoped to .prism-axis-x { ... } / .prism-axis-y { ... } rather than :root. Custom properties inherit, so the scoped declaration wins for that axis’s group and leaves the other axis on the :root value. Both are runtime-overridable the same way — assigning to the group element’s style shadows the theme’s scoped value.

Rendering backends

Three backends consume the same scene.SceneDoc + theme tokens:

BackendPackageMIME typeNotes
svg (default)render/svgimage/svg+xmlCanonical vector output; the <style> block above is emitted inline.
htmlrender/htmltext/htmlWraps render/svg’s own output in a small standalone HTML document (<!doctype html><html>…<div class="prism-html-chart"><svg>…</svg></div>…) — the embedded SVG carries the identical theme <style> block, so a theme picked at plot time (--theme=dark, etc.) looks the same in either backend.
canvasvendored ESM (static/)n/a (DOM)Browser-only web component bridge; not a Go backend.

Select the backend the same way across every surface: prism plot --format html (CLI), format: "html" on the Twirp Plot RPC / the prism_plot MCP tool, or opts.Format = "html" passed to prism.RenderPlan (library). An unsupported or misspelled format returns PRISM_RENDER_FORMAT_UNAVAILABLE from all three surfaces.

A mark can also be unsupported by a specific backend rather than the format itself being unavailable — the SVG backend rejects a top-level table mark (DOM/CSS-driven, no SVG geometry equivalent) with PRISM_RENDER_MARK_UNSUPPORTED, naming the mark and pointing at the html backend instead. See Marks.

Worked examples

Multi-source

Composing N materialized datasets into one chart is a first-class workflow.

Datasets block

Each named dataset carries its rows inline via values (or defers them to a runtime ref resolved by a DataResolver — see below). Prism does not open .pulse files; the host materialises the rows and hands Prism a Pulse-free spec.

{
  "datasets": {
    "current": {"values": [{"brand_id": "a", "score": 0.62}, {"brand_id": "b", "score": 0.55}]},
    "prior":   {"values": [{"brand_id": "a", "score": 0.58}, {"brand_id": "b", "score": 0.57}]},
    "bench":   {"ref": "industry_benchmark"}
  },
  "transform": [
    {"data": "current", "groupby": ["brand_id"],
     "aggregate": [{"op": "mean", "field": "score", "as": "current_score"}],
     "as": "current_agg"},
    {"data": "prior", "groupby": ["brand_id"],
     "aggregate": [{"op": "mean", "field": "score", "as": "prior_score"}],
     "as": "prior_agg"},
    {"join": {"left": "current_agg", "right": "prior_agg", "on": "brand_id"},
     "as": "joined"}
  ],
  "layer": [...]
}

transform.data selects an input by alias. transform.as publishes the transform’s output under a new alias.

Join

In-memory hash join. Kinds: inner (default), left, outer, anti.

{
  "join": {
    "left":  "current_agg",
    "right": "prior_agg",
    "on":    ["brand_id", "region"],
    "kind":  "left"
  },
  "as": "joined"
}

Memory ceiling: PRISM_JOIN_MAX_ROWS = 5_000_000 (env-overridable). Exceeding it raises PRISM_JOIN_003 with a fixup pointing at pre-aggregation, push-to-Pulse, or env override.

Null handling

left and outer joins surface unmatched cells as null, not as the type’s zero value. Downstream consumers see the absence of data instead of a silent 0.0 / "" / false that would look like a genuine measurement:

OpNull policy
countcount(*) counts every row; count(field) skips nulls.
sum, mean, min, max, median, q1, q3, stdev, variance, ci0, ci1Skip nulls.
distinct, modeSkip nulls.
wmean, ratio, lift, shareSkip nulls.
filter predicatesRows where any input is null evaluate to false (matches pandas / Vega-Lite).
calculate expressionsAny null input propagates to a null output.
Inline type inferenceA null carries no type. Each column takes its kind from its first non-null value, wherever that value sits — a leading null no longer decides the column.
Inline column with no non-null valueNothing to infer from, so it resolves to the categorical (string) fallback rather than erroring. Every row is flagged null, so the kind never holds a value. Declare data.fields if a specific kind matters.
Inline column orderThe union of every row’s keys, alphabetical. A field that first appears in a later row still becomes a column, in the slot it would have held had row 0 carried it; the rows above it are null.
Encoding: x, y (scale-bound)The whole row is dropped before scale resolution. See below.
Encoding: color, opacity, text, tooltip, detail, theta, the sankey / geo bindingsThe row is kept. A null there falls back to the channel’s default (default fill, empty label, empty tooltip line) rather than removing a mark.

An explicit data.fields declaration bypasses inference entirely and wins over any observed value. Inference only applies when data.fields is absent, and it still rejects a genuinely mixed column — a real string arriving in a column inferred as numeric raises PRISM_RESOLVE_INLINE_TYPE_MISMATCH with the offending row and field.

Nulls at encode time

A scale-bound channel is one whose raw field values are handed to a resolved scale — today exactly x and y, and only for the marks that go through the standard cartesian scale resolution. Polar (arc, pie, donut), self-scaling (histogram), specialty (sankey, funnel, path, tree, dendrogram, network) and geographic (geoshape, geopoint) marks build their own geometry and never hand a raw field value to a scale, so they are not filtered.

Before any scale is resolved, the encoder drops every row carrying a null in a scale-bound channel and emits PRISM_WARN_NULL_DROPPED carrying the dropped-row count and the offending channel names. The surviving rows render normally:

WARN PRISM_WARN_NULL_DROPPED: 1 rows skipped: encoding channels y carried null values.

Because the filter runs on the table rather than inside each mark encoder, everything downstream sees one consistent row set — scale domains, colour categories, the color / detail row partitioner, tooltips, datum back-references, category styles and conditions all stay aligned. In a layer composite the policy runs per layer, and the warning’s layer field names the layer that shed rows.

A row that is null only in a non-scale-bound channel is kept — a missing tooltip line is not a reason to delete a mark.

Dropping some rows is a warning; dropping all of them is an error. If a bound field is null in every row there is nothing left to draw, so the encoder fails with PRISM_ENCODE_NULL_ALL_ROWS rather than emitting a silently empty chart.

Prism drops null rows rather than breaking a line or area into separate segments around the gap (Vega-Lite’s behaviour). A three-point series with a null in the middle therefore renders as one straight segment from the first point to the third.

Choosing what happens — mark.invalid

mark.invalid picks between the two behaviours. It defaults to "filter", which is what every version before v0.16 did.

ValueEffect
"filter"Drop the row. Its category leaves the scale domain with it, so the axis never mentions it and a line closes over the hole. The default.
"break"Keep the row. Its category holds its slot on the axis, no mark is drawn for it, and a line or area splits into separate segments either side of the gap.
{"mark": {"type": "line", "invalid": "break"}}

Four quarters with FY26 Q1 unmeasured:

"filter"                      "break"
axis:  Q3   Q4   Q2           axis:  Q3   Q4   Q1   Q2
line:  *----*----*            line:  *----*         *
       (asserts continuity)                gap at Q1

A measurement stranded between two gaps is drawn as a dot. A one-point path renders nothing, so emitting one would make "break" hide a row the author explicitly asked to keep — worse than the "filter" it was chosen over, which at least drew that row as part of the line.

"break" is implemented by line, area, point, bar, rule and text. On any other mark it is rejected with PRISM_SPEC_062 rather than quietly falling back to "filter". Marks that bring their own geometry — the polar, histogram, specialty and geographic families — never hand a raw field value to a scale, so the null policy does not reach them and neither mode means anything there.

Both modes emit PRISM_WARN_NULL_DROPPED; the message says which behaviour applied.

Under the default, the dropped row leaves the axis too, not only the path. The filter runs before any scale resolves, so a discrete domain is built from the surviving rows and the missing category never existed as far as the scale is concerned. Four quarters with FY2026 Q1 unmeasured render three evenly spaced points labelled FY2025 Q3, FY2025 Q4, FY2026 Q2 — there is no blank slot, no wider gap, nothing in the drawing from which a reader could recover that a period is missing.

That is the default, and it is a sharper one for line and area than for the rest — which is what "break" above exists to answer. A bar or a point that is simply absent omits a fact; a continuous path drawn across the hole asserts that the series runs uninterrupted, which is a stronger and different claim.

PRISM_WARN_NULL_DROPPED is the only signal, so a consumer has to read it. prism plot and prism scene print it to stderr, but a library caller rendering CompiledPlan.Scene directly will not see it unless it inspects SceneDoc.Warnings (surfaced as CompiledPlan.Diagnostics). The warning names the channels, the fields and the row count, which is enough to annotate the chart or refuse to draw it — but nothing in the rendered output will do that for you. The whole-chart case stays loud (PRISM_ENCODE_NULL_ALL_ROWS is an error); it is the partial case that goes quiet, and it is the harder one to notice precisely because the chart still looks right.

An aggregate group whose every input is null returns null and surfaces PRISM_WARN_NULL_AGG_ALL.

Server-side dataset registry

Wire shared aliases via a JSON config file:

{
  "datasets": {
    "current": "brand_q1",
    "prior":   "brand_q4"
  }
}
prism plot --datasets-config datasets.json spec.json > chart.svg
prism serve --datasets-config datasets.json --addr :8080

Specs that reference {"data": {"name": "current"}} resolve through the registry to an opaque ref, which a caller-supplied DataResolver turns into materialized rows (Prism reads no file itself). Server-side cache deduplicates resolution across requests.

Browser-side dataset registry

<prism-dataset name="current" src="cohorts/brand_q1.rows.json"></prism-dataset>
<prism-dataset name="prior"   src="cohorts/brand_q4.rows.json"></prism-dataset>

<prism-chart spec="overview.prism.json"></prism-chart>
<prism-chart spec="detail.prism.json"></prism-chart>

<prism-dataset> populates a page-level registry. Charts referencing the same dataset share fetches (3 charts × 2 datasets = 2 fetches, not 6).

Runtime data references (data: {ref})

A runtime ref is an opaque identifier resolved by a caller-supplied DataResolver at compile time. The spec describes what to draw; the resolver supplies the data to draw it with. Lets the same spec render in multiple environments (server, browser, test) without modification:

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"ref": "current_window"},
  "mark": "line",
  "encoding": { "x": {"field": "ts", "type": "temporal"},
                "y": {"field": "rate", "type": "quantitative"} }
}

Resolver wiring per environment:

Browser. Register a synchronous callback via prism.setDataResolver:

const data = await fetch("/api/window.json").then(r => r.json());
prism.setDataResolver((ref) => ref === "current_window" ? { values: data } : null);
const svg = prism.execute(specJSON);

The callback must be synchronous — return the dataset object directly (no Promise). Pre-resolve any asynchronous fetches before registering the callback.

Go-native. Pass build.Options.DataResolver:

resolver := resolve.MapDataResolver{
    "current_window": {Values: rows},
}
dag, tip, _ := build.Build(s, build.Options{
    DataResolver: resolver,
    /* ... */
})

resolve.DataResolver is the interface:

type DataResolver interface {
    ResolveData(ctx context.Context, ref string) (*Dataset, error)
}

resolve.MapDataResolver is a map-backed in-memory implementation useful for tests and small fixture data; chain multiple resolvers via resolve.ChainDataResolvers. An unresolved ref surfaces as PRISM_RESOLVE_REF_UNRESOLVED at build time.

VariantDiscriminator keyUse when
data: {values: […]}valuesInline literal rows
data: {ref: "…"}refCaller-resolved opaque identifier (DataResolver)
data: {name: "…"}nameDatasets-block alias
data: {feature_collection: {…}}feature_collectionGeodata basemap

The data: {source: "…"} variant (an external Pulse path) was removed in v0.x: Prism no longer reads .pulse. A spec that still carries a source key is rejected at decode with PRISM_SPEC_039 — inline the rows via values or defer them to a DataResolver via ref.

Partial failure

One Source failing doesn’t kill the whole render. Dependents skip; sibling paths continue; the Scene carries a PRISM_WARN_LAYER_SKIPPED warning for the missing layer. Flip to fail-fast via ExecOpts.AbortOnError (CI image diffs).

Optimizer passes

Five passes run to fixpoint after build:

  1. DedupSources — two reads of the same source collapse to one.
  2. FilterPushdown — filters on joined output push to the side that owns the referenced columns.
  3. ProjectionPruning — only request columns layered/encoded downstream.
  4. AggregateFusion — sibling group-aggregates on the same input merge into one call.
  5. SampleInjection — input rows > PRISM_RENDER_MAX_MARKS (100k default) → auto-sample with PRISM_WARN_DOWNSAMPLE.

Worked examples

Geographic Marks

Prism ships two geo-aware marks for choropleth maps and georeferenced overlays. Both consume a projection block on the spec and resolve boundary geometry from a geodata catalog — no external tile server. The lightweight manifest (feature ids + bounding boxes) is embedded in every build, so validate / plan / inspect work with no setup. The heavier tier geometry is loaded at runtime: the host CLI reads it from a directory you point it at (--geodata-dir / PRISM_GEODATA), and the WASM build fetches it from a configurable URL. See Host CLI vs WASM below.

Marks

MarkChannelsPurpose
geoshapefeature (+ optional color)Country / admin-1 polygon (choropleth).
geopointlongitude, latitude (+ optional color, size)Point overlay (cities, events, sensors).

Spec shape

A basemap (every country in the catalog, no data binding) is one line of data:

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"feature_collection": {"tier": "world-110m"}},
  "mark": "geoshape",
  "projection": {"type": "naturalearth"},
  "encoding": {"feature": {"field": "id", "type": "nominal"}}
}

data.feature_collection synthesizes one row per feature in the tier — the resulting table carries id, name, and parent columns (parent is the admin-0 ISO 3166-1 alpha-3 for admin-1 entries, empty otherwise). Combine with a filter transform to subset:

{
  "data": {"feature_collection": {"tier": "admin1-50m"}},
  "transform": [{"filter": {"op": "eq", "field": "parent", "value": "USA"}}],
  "mark": "geoshape",
  "projection": {"type": "albers_usa", "tier": "admin1-50m"},
  "encoding": {"feature": {"field": "id", "type": "nominal"}}
}

For a choropleth, bind your own data and a color channel:

{
  "data": {"values": [
    {"iso_a3": "USA", "gdp_per_capita": 76300},
    {"iso_a3": "CAN", "gdp_per_capita": 55500},
    {"iso_a3": "GBR", "gdp_per_capita": 46100}
  ]},
  "mark": "geoshape",
  "projection": {"type": "naturalearth"},
  "encoding": {
    "feature": {"field": "iso_a3", "type": "nominal"},
    "color":   {"field": "gdp_per_capita", "type": "quantitative"}
  }
}

The feature channel binds a table column whose values are feature IDs from the geodata catalog. Admin-0 (countries) uses ISO 3166-1 alpha-3 (USA, CAN, GBR); admin-1 (states/provinces) uses ISO 3166-2 (US-CA, CA-ON, GB-ENG). Rows whose id doesn’t match a manifest entry raise PRISM_GEO_001.

Projections

TypeUse case
mercatorClassic web map default. Distorts area near poles; clips above ±85°.
equirectangularPlate carrée. Linear lat/lon → x/y. Useful for heatmaps over geographic grids.
naturalearthTom Patterson’s compromise projection. Smooth global view, low distortion.
albers_usaComposite Albers covering CONUS + Alaska + Hawaii in inset panels.
orthographicGlobe view. Honours rotate: [lambda, phi, gamma] for the view direction.

Per-projection parameters:

{
  "projection": {
    "type": "albers_usa",
    "scale": 1200,
    "translate": [400, 250]
  }
}

Leave scale / translate unset and Prism auto-fits the projection to the requested tier’s bounding box inside the plot rectangle.

Tiers

The geodata catalog ships three tiers:

TierCoverageApprox. on-disk size
world-110mCountries (admin-0) at 1:110m. Default.~200 KB gz
world-50mCountries (admin-0) at 1:50m. Smoother coastlines.~600 KB gz
admin1-50mStates / provinces (admin-1) at 1:50m.~5 MB gz

Select the tier the encoder pulls from:

{
  "projection": {"type": "mercator", "tier": "admin1-50m"}
}

The committed tier files carry 177 countries (110m), 242 countries (50m), and 294 admin-1 regions (50m) sourced from Natural Earth via make geodata. Tier files use a custom compact JSON shape with 3-decimal quantization; geodata/decoder.go documents the wire format. make build itself requires no network — the committed artifacts are the input.

Host CLI vs WASM

Host build (CLI / library): only the manifest (~128 KB) is embedded. Tier geometry is loaded at runtime from a directory you supply — the host binary no longer embeds the three tier files. Point the loader at that directory with the --geodata-dir flag or the PRISM_GEODATA environment variable:

prism plot world.json --geodata-dir ./geodata > world.svg
# or
PRISM_GEODATA=./geodata prism plot world.json > world.svg

The directory must contain the tier files named <tier>.geo.json (world-110m.geo.json, world-50m.geo.json, admin1-50m.geo.json). The flag is available on the leaves that materialise geometry — plot, scene, serve, mcp, and static-bundle. The no-execute leaves (validate, plan, inspect) and the data-only execute leaf use only the embedded manifest and do not take the flag.

Rendering a geoshape / geopoint mark hard-fails when geometry cannot be resolved:

  • PRISM_GEODATA_DIR_UNSET — no directory was configured (neither --geodata-dir nor PRISM_GEODATA is set) and a geo mark needs a tier.
  • PRISM_GEODATA_TIER_MISSING — a directory is configured but it does not contain the requested <tier>.geo.json file.

Getting the tier files

The committed tiers ship in the repo’s geodata/ directory, so a checkout already has them: --geodata-dir ./geodata. For a standalone binary, download the files from the docs site and point --geodata-dir at the folder you saved them in:

mkdir geodata && cd geodata
curl -O https://frankbardon.github.io/prism/static/prism/geodata/world-110m.geo.json
curl -O https://frankbardon.github.io/prism/static/prism/geodata/world-50m.geo.json
curl -O https://frankbardon.github.io/prism/static/prism/geodata/admin1-50m.geo.json
cd ..
prism plot world.json --geodata-dir ./geodata > world.svg

Download only the tiers your specs reference — world-110m alone is enough for a country basemap. You can also emit the files locally with prism static-bundle (see below).

WASM build (browser): only the manifest is embedded (~128 KB). The runtime fetches the tier file from ${origin}/static/prism/geodata/<tier>.geo.json on first encode. Set a custom URL via:

prism.geo.setBundleURL("https://cdn.example.com/geodata/");

prism static-bundle --geodata-dir ./geodata ./public/prism emits the geodata artifacts under <out>/geodata/ so the WASM runtime finds them. Because the host build no longer embeds the tiers, static-bundle sources them from the --geodata-dir directory; if it is unset, the command fails with PRISM_GEODATA_DIR_UNSET.

For pages that inline the tier bytes:

prism.geo.primeTier("world-110m", new Uint8Array(buffer));

Optional eager fetch:

await prism.geo.preload("admin1-50m");

Validation

The PRISM_SPEC_021 rule fires when:

  • mark is geoshape or geopoint but projection is missing or declares an unknown type.
  • A geoshape spec lacks encoding.feature.field.
  • A geopoint spec lacks encoding.longitude.field or encoding.latitude.field.
  • projection.tier is set to a value outside the known tiers.

Runtime errors:

  • PRISM_GEO_001 — feature id in a row is not in the manifest tier.
  • PRISM_GEO_002 — bundle fetch failed (WASM).
  • PRISM_GEODATA_DIR_UNSET — host render of a geo mark with no --geodata-dir / PRISM_GEODATA configured.
  • PRISM_GEODATA_TIER_MISSING — the configured directory does not contain the requested <tier>.geo.json file.

Custom maps

Custom feature sets live outside the v1 scope. The manifest + world-110m.geo.json files use a small documented format (geodata/decoder.go); future work surfaces a public loader so downstream apps can ship their own admin levels (e.g. ZIP codes, census tracts) via the same feature channel.

Browser

Prism runs end-to-end in the browser via a single prism.wasm artifact. The full six-stage pipeline (spec → validate → plan → compile → encode → render) executes client-side; no server round trip is required to produce SVG from a spec.

What ships

prism static-bundle --wasm <out-dir> writes a self-contained bundle:

<out-dir>/
├── prism.wasm           # cmd/prismwasm binary (TinyGo, GOARCH=wasm); ~6.9 MiB raw
├── prism.wasm.gz        # gzipped binary (~2.5 MiB) — what the loader fetches
├── wasm_exec.js         # TinyGo's WASM loader (paired with the TinyGo binary)
├── prism.mjs            # thin bootstrapper + SceneHandle facade
├── prism-element.mjs    # <prism-chart> / <prism-dataset> / <prism-coordinator>
├── prism-resolver.mjs   # page-level dataset registry
├── prism-selection.mjs  # selection state + DOM event wiring
└── index.html           # minimal loader example

The build toolchain

Prism’s WASM module is built by TinyGo — the single, canonical browser artifact:

BuildCommandRawGzippedLoader
TinyGomake build-wasm-tinygo~7.6 MiB (8,022,421 B)~2.4 MiB (2,547,567 B)TinyGo’s wasm_exec.js

TinyGo links a lean runtime and GC, producing a module roughly half the size the standard Go toolchain would emit. make build-wasm-tinygo writes bin/prism.wasm + bin/wasm_exec.js, which are paired — the loader comes from $(tinygo env TINYGOROOT)/targets/wasm_exec.js and is not interchangeable with the Go toolchain’s loader. TinyGo 0.41.1+ is required (brew tap tinygo-org/tools && brew install tinygo); the build uses -stack-size=8MB so the JSON-Schema shape validator’s recursion does not trap. The former standard-Go js/wasm build path was retired, so make build requires no wasm toolchain.

Wire size and the raw/gzip gap

The WASM module is larger uncompressed than on the wire; the size you actually pay depends entirely on compression:

  • The prism static-bundle --wasm bundle ships both prism.wasm and prism.wasm.gz. The standalone loader fetches the .gz and decompresses it in-page via DecompressionStream("gzip"), so the gzipped payload is what crosses the wire even on a dumb static host that does no content-negotiation. The raw prism.wasm stays as a fallback (WebAssembly.instantiateStreaming) for environments without DecompressionStream or where the .gz is absent.
  • If you wire up your own loader, either fetch prism.wasm.gz and decompress as above, or serve prism.wasm with Content-Encoding: gzip/br so the browser decompresses transparently. Do not serve the raw prism.wasm uncompressed. (nginx: add application/wasm to gzip_types; most CDNs negotiate automatically but some skip files over a size cap.)

CI size gate

The TinyGo artifact is guarded by internal/gates/wasm_tinygo_size_test.go, which checks both the gzipped size (PRISM_WASM_TINYGO_MAX_BYTES, 4 MiB) and the raw size (PRISM_WASM_TINYGO_RAW_MAX_BYTES, 12 MiB) so the uncompressed artifact cannot balloon unnoticed behind the gzipped check. CI pins TinyGo 0.41.1 and runs this gate as a hard requirement — it builds a fresh TinyGo module into a temp directory so the measurement is independent of whatever last populated bin/. Locally the gate skips cleanly when tinygo is not on PATH, so make test stays green without the toolchain installed.

Load modes

Three ways to put a chart on a page, each compatible with the others on the same page:

Server-rendered scene (zero client compile)

The host emits Scene IR JSON server-side (via prism scene) and references it from a <prism-chart src=…>:

<prism-chart src="/scenes/brand_score.json"></prism-chart>

Fastest path. The browser fetches the JSON and renders it via WASM. No spec parsing or transform execution in the browser.

Client spec compile (WASM default)

The host passes the spec inline or as a URL on the spec attribute:

<prism-chart spec='{"$schema":"urn:prism:schema:v1:spec",...}'></prism-chart>
<prism-chart spec="/specs/brand_score.prism.json"></prism-chart>

The spec carries its own rows: inline data: {values: [...]} / datasets.*.values, or a datasets attribute on the element. For lazy or large data, register a JS DataResolver via prism.setDataResolver(...) and reference it with data: {ref}. Prism never fetches or decodes a .pulse file in the browser — the host materializes the rows and hands them to Prism. WASM then runs the full pipeline and mounts the resulting SVG.

A mark: {type: "custom", renderer: "..."} reference resolves the same way against a registry — but a per-mark render function instead of a per-ref data source — via prism.registerCustomMark(name, fn). This is the only way a custom mark works against the shared, prebuilt prism.wasm binary (there’s no way to call a Go function to register into its compiled-in registry from JS). See the Custom marks cookbook for the callback shape and — importantly — the security contract around escaping and script execution.

Render backends: SVG vs HTML

The WASM module exposes both host render backends. prism.render(sceneJSON, themeName?) renders through the canonical SVG backend (render/svg) and returns an <svg>...</svg> string — this is what <prism-chart> calls by default. prism.renderHTML(sceneJSON, themeName?) renders the same SceneDoc through the HTML backend (render/html) instead, returning a complete standalone HTML document string:

const sceneJSON  = globalThis.prism.execute(specJSON, datasetsJSON);
const svgString  = globalThis.prism.render(sceneJSON);
const htmlString = globalThis.prism.renderHTML(sceneJSON);

Both accept the same arguments and return the same {ok:false, error} envelope shape on failure. Most marks render identically either way (the HTML backend just wraps the same SVG emitters in a document shell), but two mark shapes have no SVG geometry of their own and are reachable only through prism.renderHTML:

  • The table mark, which renders as a semantic <table> (see marks.md).
  • A mark: {type: "custom", ...} reference whose registered renderer implements HTMLCustomRenderer (its output lands verbatim in the document, including any <script> tag) rather than SVGCustomRenderer — see the Custom marks cookbook. Before this bridge existed, HTMLCustomRenderer marks could only be rendered server-side (prism plot --format html) or via a Twirp round trip; they are now live-renderable in the browser like any other mark.

<prism-table>

<prism-table> is the live counterpart to the server/CLI-only table path (prism plot --format html): it resolves a scene the same two ways <prism-chart> does — a src attribute fetched via PrismResolver.fetchJSON, or a spec attribute (inline JSON or a URL) compiled via executeSpec — then renders it through prism.renderHTML instead of prism.render, since the table mark has no SVG geometry of its own:

<prism-table src="/scenes/accounts_table.json"></prism-table>
<prism-table spec='{"$schema":"urn:prism:schema:v1:spec",...}'></prism-table>

Its observedAttributes are src, spec, and theme (row sort/pagination/selection are all handled client-side by prism-table.mjs’s installTableHandlers, so there’s no attribute for them). On every render it mounts the returned HTML document’s content into its shadow root and calls installTableHandlers(this. shadowRoot), so column-header sort, Prev/Next pagination, and row-click selection (a prism:select CustomEvent, mirroring <prism-chart>’s selection events) work immediately with no further wiring. See prism-table.mjs for the full data-prism-* attribute contract render/html’s table renderer emits and this element’s sort/pagination logic reads.

Server compile (opt-in)

Hosts that prefer to offload the compile stage to a trusted backend add a compile-server attribute:

<prism-chart spec="/specs/brand_score.prism.json"
             compile-server="/prism/scene"></prism-chart>

The browser POSTs the spec + dataset map to the server (prism serve Twirp endpoint from P14) and gets back the resolved Scene IR. WASM still does the final SVG render; the network round-trip only covers compile.

Auto light/dark in the browser

A spec’s theme.dark_variant (see Dark variant pairing) needs no browser-specific wiring — no new <prism-chart> attribute, no prism.mjs export, no compile-server option. Set it directly in the spec JSON exactly as you would for the CLI:

<prism-chart spec='{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"values": [{"category": "alpha", "value": 12}]},
  "mark": "bar",
  "encoding": {
    "x": {"field": "category", "type": "nominal"},
    "y": {"field": "value", "type": "quantitative"}
  },
  "theme": {"dark_variant": "dark"}
}'></prism-chart>

Every code path the element can take — client-side WASM compile, compile-server offload, or a pre-rendered src="/scenes/…json" scene fetched and mounted with zero compile — reaches the same encode.Encode call under the hood, so all three carry the doubled <style> block described in CSS variables emitted without special-casing.

Because the mechanism is a plain @media (prefers-color-scheme: dark) rule embedded in the SVG/HTML payload itself (not something prism.mjs or the shadow DOM applies separately), a live <prism-chart> mounted on a real page repaints immediately when the visitor flips their OS or browser color-scheme setting — the browser re-evaluates the media query on its own. No SceneHandle.update() call, no animator tween, no re-render round trip: this is a lighter-weight mechanism than the animation system below, which exists for a different problem (data changing between successive scenes, not the viewer’s color-scheme preference).

Theme names must resolve client-side too. dark_variant (and theme.name/the render-time themeName argument to prism.render/prism.renderHTML) are looked up by name against the themes compiled into the running prism.wasm binary. The WASM entry exposes no theme.LoadFile/LoadBytes-equivalent export — there is no way to register a custom theme JSON document from JS at runtime — so a spec embedded in a page can only pair with one of the five built-ins (light, dark, print, high_contrast, colorblind) unless the host ships a custom-built prism.wasm with additional theme.Register calls compiled in.

Compile-only mode

Callers (particularly programmatic ones constructing specs from logic) can ask Prism “what would this render produce?” without paying the cost of rasterising. The WASM module exposes a compile export that returns the structured CompiledPlan — the same intermediate representation the render stage consumes, just exposed publicly:

const planJSON = globalThis.prism.compile(specJSON, datasetsJSON, optsJSON);
const plan = JSON.parse(planJSON);
// plan.marks         — flattened mark summary (per layer)
// plan.scales        — resolved scales (channel, type, domain, range)
// plan.data          — dataset bindings (named + resolved)
// plan.layout        — width/height + grid rows/cols
// plan.diagnostics   — PRISM_WARN_* warnings
// plan.scene         — full Scene IR (same as `prism.execute` output)

Cost is dominated by aggregation over the materialized rows (the executor); the flattened plan view itself is light. For specs whose data fits in memory, compile-only typically runs 10–50× faster than a full prism.execute + prism.render pair, since the encode + SVG-emit stages are skipped.

The Go-native API exposes the same surface:

plan, err := prism.Compile(ctx, spec, prism.CompileOptions{})

Use cases:

  • Programmatic introspection — verify that the color encoding bound the field you expected.
  • Plan diffing — compare two CompiledPlans to know what changed between spec edits without rendering both.
  • Pre-render previews — show the user “3 marks across 2 facets” before committing to a render.

Fetch-backed assets

Prism never fetches or decodes data rows in the browser — the host supplies them inline (data.values / datasets.*.values) or through a JS DataResolver registered with prism.setDataResolver(...). The only assets Prism itself fetches are geodata tiers (geoshape / geopoint marks), pulled from ${origin}/static/prism/geodata/ (override via prism.geo.setBundleURL(url)), and any URL-referenced Scene JSON the page loads directly. Those GETs go through a fetch adapter that dedupes by URL and buffers the body for the page lifetime.

A failed asset fetch surfaces as PRISM_WASM_001 (CORS, network, or non-2xx). It arrives in the JS bridge as a standard {ok:false, error} envelope; prism.mjs rethrows it as an Error with prismCode + prismFixups attached.

What’s still in JS

The four .mjs files together total ~10 KiB. They handle the DOM-side work that WASM can’t reach across the bridge cheaply:

FileResponsibility
prism.mjsLoad WASM, marshal JSON, mount SVG, expose SceneHandle
prism-element.mjs<prism-chart> / <prism-dataset> / <prism-coordinator> custom elements
prism-resolver.mjsPage-level dataset registry; dedupes fetches across charts
prism-selection.mjsPointer-event hit testing against data-prism-* attrs; URL-hash persistence

JS-side scale resolution, axis layout, tick generation, palette resolution, and number/time format are all gone — they used to exist as a reimplementation of the Go pipeline in prism.mjs and were deleted in P17 once the WASM path landed. There is one implementation of every Prism stage now, written in Go.

Animation

The spec animation block produces hints in the emitted Scene IR (scene.animation + mark.key). The SVG renderer ignores these fields entirely; only the web component and the WASM runtime tween between successive scenes.

How the animator works

When <prism-chart>’s spec or src attribute changes and the new scene declares an animation block, the element holds the previous SceneHandle alive and calls handle.update(newSceneDoc) instead of the default clear-and-replace path.

SceneHandle.update defers to PrismAnimator (vendored in static/vendor/prism/prism-animator.mjs):

  1. The new scene is rendered through the WASM module into a detached SVG; its visibility is set to hidden so the user keeps seeing the live (previous) SVG.
  2. PrismAnimator indexes both SVGs by data-prism-mark-key and partitions marks into enter / update / exit sets.
  3. A requestAnimationFrame loop interpolates numeric attrs (x/y/width/height/cx/cy/r/opacity/…) on the live SVG, writing target values read from the staged SVG. The tweenable set is per element tag (NUMERIC_ATTRS in prism-animator.mjs) and includes the mark-def style vocabulary: fill-opacity / stroke-opacity on filled and stroked marks, and dx / dy (a text mark’s anchor offset) on <text>. The tween set is per SVG element tag; <path> covers stroke-width alongside the opacity trio, because a line mark with a non-linear interpolate (Marks: Interpolation) renders as <path> rather than <polyline>. Color attrs (fill, stroke) interpolate through OKLab via oklab.mjs for perceptually smooth transitions.
  4. At t = 1 the previous SVG is removed and the staged SVG becomes visible. The exit set fades to opacity=0 along the way.

Fallbacks

The animator skips and snaps to the new scene when any of the following hold:

  • prefers-reduced-motion: reduce is set by the OS / browser. (Silent — this is the correct UX, not a failure.)
  • The previous scene is structurally incompatible with the new scene (different layer count, different mark family per layer, different axis count). SceneHandle dispatches a prism:warn CustomEvent carrying {code: "PRISM_WARN_ANIM_FALLBACK", message} on its root (the shadow root inside <prism-chart>, otherwise the host element). The event bubbles + composes through the shadow boundary so listeners on the host page receive it without extra plumbing.
  • The animate option is explicitly false (handle.update(doc, { animate: false })). (Silent.)
  • The previous handle does not exist yet (first render). (Silent.)

Listening for the warning:

chart.addEventListener("prism:warn", (e) => {
  if (e.detail.code === "PRISM_WARN_ANIM_FALLBACK") {
    console.warn(`tween skipped: ${e.detail.message}`);
  }
});

Public exports

prism.mjs re-exports the animator surface so embedders can drive a tween on a bare SVG without going through SceneHandle:

import {
  PrismAnimator,
  structurallyCompatible,
  prefersReducedMotion,
} from "/static/vendor/prism/prism.mjs";

The tween engine has zero dependencies beyond oklab.mjs. The WASM binary size is unaffected — animation lives entirely in plain JS.

Where to see it

  • The interactive playground routes every edit through SceneHandle.update(). Pick the Animation › Swap bars example and change any score: the bars tween instead of snapping.
  • The gallery/animation/ entries ship spec + initial-frame SVG; live <prism-chart> cards on the gallery live demo demonstrate the tween when the scene-doc swaps.

Cross-implementation parity

The cross-impl harness (internal/devtools/cross-impl-runner/) asserts byte-equal SVG between the host-native Go renderer and the TinyGo-compiled WASM module. Drift signals a non-deterministic stage or a cross-toolchain float-formatting regression.

Run locally:

make build-wasm-tinygo
PRISM_CROSS_IMPL=1 go test ./internal/devtools/

The runner needs node on PATH; no npm install is required.

TinyGo ↔ host float parity

TinyGo (the sole WASM build) links its own strconv, and every SVG coordinate funnels through the single render.FormatFloat helper (render/precision.go, pinned to 3 decimals). If TinyGo rounded or stringified floats differently from the host Go build, the coordinate goldens would drift — this was flagged as the highest risk of the TinyGo migration.

It does not drift. A dedicated parity harness proves it:

PRISM_CROSS_IMPL_TINYGO=1 go test ./internal/devtools/ -run TinyGo
  • TestTinyGoWasmSVGParity builds a TinyGo wasm module from cmd/prismwasm, renders a float-diverse fixture corpus (bars, curves, trigonometric arcs, bezier ribbons, dense rect/box/violin layouts) under Node with TinyGo’s paired wasm_exec.js, and diffs each SVG byte-for-byte against the committed host-Go go.svg. All fixtures are byte-identical.
  • TestTinyGoFloatFormatParity drives render.FormatFloat over an edge-case corpus (half-way rounding, trailing-zero trimming, negative zero, magnitude extremes, NaN/±Inf) in two builds — host-native and TinyGo wasm — and asserts they agree. The host-side pin lives in render/precision_test.go.

Because parity holds unmodified, no float-emission change was needed: the host Go build and TinyGo already produce identical bytes. The harness is opt-in (mirroring PRISM_CROSS_IMPL) because it needs both node and tinygo on PATH.

Standalone HTML demo

prism static-bundle --wasm ./public/prism writes a working index.html to the output directory. Open it directly with a local static server (the browser refuses file:// for WASM):

prism static-bundle --wasm ./public/prism
cd ./public/prism && python -m http.server 8000
# → open http://localhost:8000/

The demo fetches prism.wasm.gz, decompresses it in-page via DecompressionStream("gzip") (falling back to the raw prism.wasm when unavailable), then renders any <prism-chart> it finds. Replace the bundled index.html with your own page to embed Prism in mdBook, Astro, Hugo, or any other static-site generator — keep the .gz + DecompressionStream pattern (or serve the raw .wasm with Content-Encoding) so you ship ~2.2 MiB, not ~6.9 MiB.

Cookbook: multi-source join

Compare two cohorts side-by-side via hash join.

Spec

{
  "$schema": "urn:prism:schema:v1:spec",
  "datasets": {
    "current": {"values": [
      {"brand_id": "alpha", "score": 0.62},
      {"brand_id": "beta",  "score": 0.48},
      {"brand_id": "alpha", "score": 0.66}
    ]},
    "prior": {"values": [
      {"brand_id": "alpha", "score": 0.55},
      {"brand_id": "beta",  "score": 0.51},
      {"brand_id": "beta",  "score": 0.47}
    ]}
  },
  "transform": [
    {"data": "current", "groupby": ["brand_id"],
     "aggregate": [{"op": "mean", "field": "score", "as": "current_score"}],
     "as": "cur"},
    {"data": "prior", "groupby": ["brand_id"],
     "aggregate": [{"op": "mean", "field": "score", "as": "prior_score"}],
     "as": "pri"},
    {"join": {"left": "cur", "right": "pri", "on": "brand_id"}, "as": "joined"},
    {"data": "joined", "calculate": {"op": "sub", "operands": [{"field": "current_score"}, {"field": "prior_score"}]}, "as": "delta"}
  ],
  "mark": "bar",
  "encoding": {
    "x": {"field": "brand_id", "type": "nominal", "sort": "-y"},
    "y": {"field": "delta", "type": "quantitative", "title": "Score delta vs Q4"}
  }
}

Notes

  • Hash join is in-memory. Cardinality ceiling is PRISM_JOIN_MAX_ROWS (5M default; override via env).
  • The optimizer’s AggregateFusion pass would collapse the two group-aggregates if they shared an input; here they’re on different sources so both run in parallel.
  • PRISM_QUERY_WORKERS (defaults to NumCPU) controls the executor worker pool — both group-aggregates run concurrently.
  • The rows here are inlined for illustration; in production the caller materializes each cohort upstream and inlines it via datasets (or supplies a DataResolver bound to a ref).

Cookbook: faceting by data values

Render one mini-chart per partition of a categorical field.

Spec

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"values": [
    {"region": "north", "brand_id": "alpha", "score": 0.62},
    {"region": "north", "brand_id": "beta",  "score": 0.48},
    {"region": "south", "brand_id": "alpha", "score": 0.55},
    {"region": "south", "brand_id": "beta",  "score": 0.51}
  ]},
  "facet": {"column": {"field": "region"}},
  "spec": {
    "$schema": "urn:prism:schema:v1:spec",
    "mark": "bar",
    "encoding": {
      "x": {"field": "brand_id", "type": "nominal"},
      "y": {"field": "score", "type": "quantitative", "aggregate": "mean"}
    }
  },
  "resolve": {"scale": {"y": "shared"}}
}

Notes

  • The upstream Source + transform pipeline runs once; the resulting Table is partitioned at encode time.
  • resolve.scale.y: shared (default for facet) computes the union y-domain so cells are visually comparable. Use independent for per-cell domains.
  • Nested facets work (facet within facet within facet) — the inner spec field is recursive.
  • For grid by field list instead of by data values, use repeat.

Cookbook: custom themes

Brand a chart with company colors + fonts without touching code.

Theme JSON

{
  "name": "brand",
  "extends": "light",
  "overrides": {
    "axis_color": "#0f172a",
    "text_color": "#1e293b",
    "grid_color": "#e2e8f0",
    "font_sans": "Source Sans 3, system-ui, sans-serif",
    "color_scheme_categorical": [
      "#0ea5e9",
      "#a855f7",
      "#22c55e",
      "#f43f5e",
      "#fb923c"
    ]
  }
}

Save as brand.theme.json.

Use it

prism plot bar.json --theme=./brand.theme.json > bar.svg

Sparse override at spec level

If only one chart needs a tweak, override inline:

{
  "theme": {
    "name": "light",
    "overrides": {
      "color_scheme_categorical": ["#0ea5e9", "#22c55e"]
    }
  },
  ...
}

Notes

  • All bundled themes (light, dark, print) live in theme/ and emit identical CSS variable manifests for the SVG + browser ports.
  • Browser theme switching is one DOM attribute away:
    document.querySelector("prism-chart").setAttribute("theme", "dark");
    
    No re-render needed; CSS variables swap.

Cookbook: custom marks

A custom mark is the escape hatch for a visualization Prism’s built-in marks can’t express — a consuming application registers its own render function under a name, and a spec references that name. Prism resolves the name at render time and splices the function’s output into the document.

⚠️ Security: you own escaping and script execution

Prism’s contract for a custom mark is verbatim insertion, and nothing more. Whatever string your RenderSVG / RenderHTML / JS callback returns is spliced into the SVG or HTML document byte-for-byte — no escaping, no tag stripping, no sanitization.

  • You escape row data. If your renderer interpolates a field value into markup (fmt.Sprintf("<div>%s</div>", row["name"])), you are responsible for escaping it (html.EscapeString in Go, a manual escaper or safe DOM APIs in JS). An unescaped value from an untrusted data source is a real XSS hole — Prism will not catch it for you.
  • You own script execution. A <script> tag your renderer returns is inserted verbatim and the browser executes it normally — under the html render backend directly, and under WASM’s <prism-chart> element once its markup mounts. This is intentional (it’s how interactive custom marks attach behavior), but it means a custom mark is exactly as trustworthy as the code you registered under its name. Never register a renderer that builds markup from data you don’t control without escaping it first, and treat mark.renderer name resolution as you would any other plugin-loading surface.

This is not a Prism limitation to be fixed later — it is the design. A custom mark is arbitrary caller code; Prism’s job stops at “call it and place what it returns.”

Registering a renderer (Go)

Implement at least one of two single-method interfaces from the root prism package (thin re-exports of github.com/frankbardon/prism/custommark, which is where the real registry and interfaces live):

// SVGCustomRenderer — raw SVG fragment.
type SVGCustomRenderer interface {
    RenderSVG(rows []table.Row, box scene.Box, tokens *theme.Theme) (string, error)
}

// HTMLCustomRenderer — HTML fragment (scripts execute verbatim).
type HTMLCustomRenderer interface {
    RenderHTML(rows []table.Row, box scene.Box, tokens *theme.Theme) (string, error)
}

A renderer may implement one or both — “optional” is expressed by having two single-method interfaces rather than one interface with two required methods. rows is the mark’s resolved, upstream-filtered/sorted/limited data (one table.Row — a map[string]any — per row); box carries the content area’s W/H in pixels (a custom mark is freeform/document-flow and never resolves a position, so there’s no X/Y); tokens is the active theme, so freeform output can stay visually consistent across light/dark/print.

Register the implementation once, before compiling/rendering any spec that references it (typically from your program’s own init or main):

package main

import (
	"fmt"
	"html"

	"github.com/frankbardon/prism"
	"github.com/frankbardon/prism/encode/scene"
	"github.com/frankbardon/prism/table"
	"github.com/frankbardon/prism/theme"
)

// badge renders the first row's "label" field as a small pill. It
// implements SVGCustomRenderer only.
type badge struct{}

func (badge) RenderSVG(rows []table.Row, box scene.Box, tokens *theme.Theme) (string, error) {
	label := ""
	if len(rows) > 0 {
		if v, ok := rows[0]["label"].(string); ok {
			label = v
		}
	}
	// html.EscapeString is not SVG-specific, but it neutralizes the
	// same &<>"' characters that would otherwise break out of the
	// <text> element or attribute value below. Escaping row data is
	// this renderer's job, not Prism's — see the security note above.
	safe := html.EscapeString(label)
	return fmt.Sprintf(
		`<rect width="%.0f" height="%.0f" rx="6" fill="%s"/><text x="8" y="18" fill="white">%s</text>`,
		box.W, box.H, tokens.AxisColor, safe,
	), nil
}

func main() {
	if err := prism.RegisterCustomMark("badge", badge{}); err != nil {
		panic(err)
	}
	// ...compile/render specs referencing {"mark": {"type": "custom", "renderer": "badge"}}
}

Reference the registered name from a spec — renderer is always a plain string key, never executable code; the spec JSON never carries the implementation, only the name it was registered under (this preserves Prism’s no-expression-language invariant just as filter / calculate do):

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"values": [{"label": "Beta"}]},
  "mark": {"type": "custom", "renderer": "badge"},
  "encoding": {}
}

An unregistered renderer name fails at render time (not decode time) with PRISM_RENDER_CUSTOM_MARK_NOT_FOUND, naming the requested renderer and listing every currently-registered name. Re-registering the same name replaces the prior renderer; custommark.Register is safe for concurrent use.

Worked examples: quote and stat cards

Two more complete examples, in the same style as badge above, showing custom used for the shape of thing it’s most often reached for in practice: an HTML dashboard card. Both are HTMLCustomRenderer only — a pull-quote or a metric tile is inherently HTML-shaped (<blockquote>/<footer>, a bordered <div> with a large number) with no natural non-browser SVG equivalent, unlike badge, which intentionally implements both interfaces to demonstrate the dual-method contract below. Rendered output for both lives in the gallery under Gallery › Custom marks.

Quote card

Renders the first row’s quote field as a pull-quote, with author (and an optional role) as the attribution line:

package main

import (
	"fmt"
	"html"

	"github.com/frankbardon/prism"
	"github.com/frankbardon/prism/encode/scene"
	"github.com/frankbardon/prism/table"
	"github.com/frankbardon/prism/theme"
)

// quoteCard renders row 0's "quote"/"author"/"role" fields as a
// pull-quote block. It implements HTMLCustomRenderer only.
type quoteCard struct{}

func (quoteCard) RenderHTML(rows []table.Row, box scene.Box, tokens *theme.Theme) (string, error) {
	if len(rows) == 0 {
		return "", nil
	}
	quote, _ := rows[0]["quote"].(string)
	author, _ := rows[0]["author"].(string)
	role, _ := rows[0]["role"].(string)

	// Every interpolated field is row data, so every interpolated
	// field gets escaped — same rule as badge's RenderSVG above.
	safeQuote := html.EscapeString(quote)
	attribution := html.EscapeString(author)
	if role != "" {
		attribution = fmt.Sprintf("%s, %s", html.EscapeString(author), html.EscapeString(role))
	}

	return fmt.Sprintf(
		`<blockquote style="margin:0;max-width:%.0fpx;padding:20px 24px;`+
			`border-left:4px solid %s;background:%s;font:italic 16px/1.5 %s;color:%s">`+
			`<p style="margin:0 0 12px 0">&#8220;%s&#8221;</p>`+
			`<footer style="font-style:normal;font-weight:600;font-size:13px;color:%s">&mdash; %s</footer>`+
			`</blockquote>`,
		box.W, tokens.AxisColor, tokens.GridColor, tokens.FontSans, tokens.TextColor,
		safeQuote, tokens.AxisColor, attribution,
	), nil
}

func main() {
	if err := prism.RegisterCustomMark("quote-card", quoteCard{}); err != nil {
		panic(err)
	}
}
{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {
    "values": [{
      "quote": "Design is not just what it looks like and feels like. Design is how it works.",
      "author": "Steve Jobs",
      "role": "Co-founder, Apple & NeXT"
    }]
  },
  "mark": {"type": "custom", "renderer": "quote-card"},
  "encoding": {}
}

Note the literal & in "role" above: it comes through the output as Apple &amp; NeXT, not a raw & — proof the renderer escapes row data rather than trusting it. See render/html/gallery_custom_cards_test.go for the exact tested implementation (with thousands-separator/delta helpers factored out) and its escaping-proof unit tests.

Stat card

Renders the first row as a dashboard-style single-metric tile: a label, a large value, and an optional delta (a signed percent — sign picks the up/down arrow and color):

package main

import (
	"fmt"
	"html"
	"strconv"

	"github.com/frankbardon/prism"
	"github.com/frankbardon/prism/encode/scene"
	"github.com/frankbardon/prism/table"
	"github.com/frankbardon/prism/theme"
)

// statCard renders row 0's "label"/"value"/"delta" fields as a
// metric tile. It implements HTMLCustomRenderer only.
type statCard struct{}

func (statCard) RenderHTML(rows []table.Row, box scene.Box, tokens *theme.Theme) (string, error) {
	if len(rows) == 0 {
		return "", nil
	}
	label, _ := rows[0]["label"].(string)
	safeLabel := html.EscapeString(label)

	value := ""
	switch v := rows[0]["value"].(type) {
	case string:
		value = v
	case float64:
		value = strconv.FormatFloat(v, 'f', -1, 64)
	}
	safeValue := html.EscapeString(value)

	var deltaHTML string
	if d, ok := rows[0]["delta"].(float64); ok {
		arrow, color := "▲", "#16a34a"
		if d < 0 {
			arrow, color = "▼", "#dc2626"
		}
		deltaHTML = fmt.Sprintf(
			`<div style="margin-top:6px;font-size:13px;font-weight:600;color:%s">%s %s</div>`,
			color, arrow, html.EscapeString(fmt.Sprintf("%.1f%%", d)),
		)
	}

	return fmt.Sprintf(
		`<div style="max-width:%.0fpx;padding:16px 20px;border:1px solid %s;border-radius:8px;`+
			`font-family:%s">`+
			`<div style="font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:%s">%s</div>`+
			`<div style="margin-top:4px;font-size:28px;font-weight:700;color:%s">%s</div>%s`+
			`</div>`,
		box.W, tokens.GridColor, tokens.FontSans,
		tokens.AxisColor, safeLabel, tokens.TextColor, safeValue, deltaHTML,
	), nil
}

func main() {
	if err := prism.RegisterCustomMark("stat-card", statCard{}); err != nil {
		panic(err)
	}
}
{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {
    "values": [{"label": "Monthly Active Users", "value": 128400, "delta": 4.2}]
  },
  "mark": {"type": "custom", "renderer": "stat-card"},
  "encoding": {}
}

Note there’s no "unit"/currency formatting here — value is rendered as-is (a real integration would pre-format it, e.g. "128.4K", before it ever reaches the row; recall Prism has no expression language, so any such formatting is the caller’s job upstream, same as everywhere else in Prism).

Every other gallery category pairs a *.prism.json spec with output the shared prism CLI binary produced (prism plot/--format html). That binary has no renderer registered under quote-card or stat-card — registration is the Go-level custommark.Register call shown above, made by a specific process before it renders, not something a bare JSON spec can trigger. Shipping a *.prism.json next to these two .html files would misleadingly imply prism plot custom-marks/quote_card.prism.json works out of the box; it doesn’t, and can’t, without a caller-supplied binary that first calls RegisterCustomMark. The two JSON blocks above are the specs that produced the committed gallery HTML — copy one verbatim, register the matching renderer under the name it references, and prism.RenderPlan (or the equivalent CLI flow in your own binary) produces the same output.

Test isolation

The registry is process-global mutable state — an intentional deviation from the rest of Prism’s hermetic, dependency-threaded convention (see custommark’s package doc comment), accepted for simpler call sites. If your own test suite registers a custom mark, call custommark.ResetForTest(t) at the top of the test: it snapshots the current registry, clears it for the test, and restores the snapshot via t.Cleanup — so a registration made by one test can never bleed into a sibling test that runs later in the same binary.

The SVG / HTML dual-method contract

A custom mark can render under either Go backend (render/svg or render/html — see Themes: Rendering backends) regardless of which method(s) its renderer implements. Each backend prefers its native method when both are implemented, and falls back to wrapping/re-encoding the other when only one is:

Renderer implementsUnder the svg backendUnder the html backend
SVGCustomRenderer onlySpliced directly into the SVG tree — no wrapper.Re-encoded as a standalone <svg>...</svg> fragment and inserted inline.
HTMLCustomRenderer onlyWrapped in <foreignObject> (an inner XHTML-namespaced <div>, since a standalone SVG document is parsed as XML and a bare HTML fragment isn’t well-formed in the SVG namespace).Spliced directly into a wrapping <div> — verbatim, including any <script> tag, which the browser executes normally.
BothSVGCustomRenderer preferred (direct splice).HTMLCustomRenderer preferred (verbatim insertion).
NeitherRejected by custommark.Register before it ever reaches the registry.Same.

That gives four concrete combinations:

  1. SVG-only, under svg — the cheapest path: your fragment becomes part of the SVG document with no wrapper at all.
  2. SVG-only, under html — your fragment is still pure SVG markup, so the HTML backend re-encodes it as an inline <svg>...</svg> element sized to box and inserts that.
  3. HTML-only, under svg — <foreignObject> is how SVG embeds foreign markup. This works in browsers, but has real portability limits: some non-browser SVG viewers and print pipelines don’t support <foreignObject>. Prefer implementing RenderSVG too if your custom mark needs to work as a standalone SVG file outside a browser.
  4. HTML-only, under html — the native case: your HTML (and any <script> it contains) lands in the document exactly as returned.

In every case, Prism positions the fragment (translating it to the mark’s plot origin) but never inspects, escapes, or transforms its contents — see the security note at the top of this page.

Registering a renderer from JS (WASM)

The Go-side registry above is compiled into bin/prism.wasm at build time — a browser page loading that shared, prebuilt binary has no way to call a Go function to register into it. prism.registerCustomMark is the parallel path a browser page can use, with no rebuild:

prism.registerCustomMark("badge", (rows, box) => {
  const label = rows.length > 0 ? String(rows[0].label ?? "") : "";
  // Same rule as the Go example: escaping is the renderer's job.
  const safe = label.replace(/[&<>"']/g, (c) => ({
    "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
  })[c]);
  return `<div style="width:${box.w}px;height:${box.h}px;` +
         `background:#0f172a;color:#fff;border-radius:6px;` +
         `display:flex;align-items:center;padding:0 8px">${safe}</div>`;
});
<prism-chart spec='{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"values": [{"label": "Beta"}]},
  "mark": {"type": "custom", "renderer": "badge"},
  "encoding": {}
}'></prism-chart>

fn must be a synchronous function of shape (rows, box) -> string — a Promise return isn’t awaitable across the WASM bridge and surfaces as a type error at render time. rows is a plain JS array of plain objects (one per data row — the same JSON shape as any other Scene IR data); box is a plain {w, h} object.

A JS-registered renderer bridges only to the HTMLCustomRenderer contract — there is no raw-SVG-fragment JS renderer today. That means a JS-registered custom mark always takes the HTML-only row of the table above: spliced verbatim under the html backend, and <foreignObject>-wrapped under the svg backend. If you need a JS-driven custom mark to render as pure SVG (portable to non-browser viewers), implement it in Go (SVGCustomRenderer) and compile it into your own WASM build instead.

custommark.RegisterJS/Lookup resolve through LookupWithJSFallback: a custom mark’s renderer name is looked up against the Go-side registry first, then — only in the WASM build — against this JS-side registry. That means a name registered only from the browser resolves correctly with no Go-side custommark.Register call at all; it’s what makes custom marks usable against the shared prebuilt bin/prism.wasm runtime rather than requiring a bring-your-own TinyGo build.

Errors

CodeWhen
PRISM_RENDER_CUSTOM_MARK_NOT_FOUNDThe spec’s mark.renderer name has no matching entry in the registry at render time (Go or JS side), or — defensively — matches an entry that implements neither interface (unreachable through Register’s own public API, which already rejects such a value).

Look up any code’s full message and fixups with prism errors lookup PRISM_RENDER_CUSTOM_MARK_NOT_FOUND.

See also

Cookbook: MCP agent integration

Expose Prism to an LLM agent so it can plot, validate, describe, and search example specs as tool calls. Prism ships its Model Context Protocol surface four ways:

  1. The prism mcp CLI — a ready-to-run stdio server (zero Go code).
  2. The SDK-free mcp.Tools(cfg) catalog — mount Prism’s tools on your own MCP server with no Prism-supplied MCP SDK in your build.
  3. The mcpserve runner (Serve / ServeStdio / ServeTransport) — run a fully wired Prism MCP server inside your process, over an io pair, over stdio, or over a transport you supply, without shelling out to the prism binary.
  4. The mcp/gosdk.Register one-call adapter — graft all four tools plus the embedded example resources onto a modelcontextprotocol/go-sdk server you already own.

Start the MCP server

prism mcp

Reads JSON-RPC frames on stdin, writes responses on stdout. Standard MCP stdio transport, backed by the modelcontextprotocol/go-sdk runtime. The same four tools are exposed regardless of which mounting path you use.

Tools exposed

ToolArgsReturns
prism_plot{spec, format?}{bytes (base64), mime, caption, warnings?}
prism_validate{spec}{ok, errors}
prism_describe{spec}{summary}
prism_examples_search{query}{examples: [{name, summary, spec}]}

prism_plot supports svg (default); png returns PRISM_RENDER_FORMAT_UNAVAILABLE. prism_examples_search returns up to five matches by substring on spec name + title.

Configure a host

Add Prism to your agent host’s MCP server config (Claude Desktop, Cursor, Cody, etc.):

{
  "mcpServers": {
    "prism": {
      "command": "prism",
      "args": ["mcp"]
    }
  }
}

Worked invocation

The agent reasons: “user asked for brand-score chart” → invokes prism_plot({spec: ..., format: "svg"}) → receives base64 SVG bytes

  • a natural-language caption. The caption is generated from the parsed spec (mark + encoding fields + dataset names).

For server-mode integrations (HTTP, not stdio), use the Twirp surface at prism serve --addr :8080. Generated clients live under rpc/ — Go is built-in; protoc can regenerate for JS/Python/Rust.

Embed Prism’s tools in your own Go binary

The CLI is a thin adapter over the importable github.com/frankbardon/prism/mcp core and the mcpserve runner that wraps it. Pick the path that matches whether you want an MCP SDK in your dependency graph, and how much of the server you want to own.

SDK-free: mount the Tools(cfg) catalog

mcp.Tools(cfg) returns a slice of transport- and SDK-agnostic ToolDescriptors. Each carries the tool name, description, reflected input/output JSON Schemas (as json.RawMessage), and a type-erased Invoke that unmarshals raw arguments, calls the typed handler, and returns the typed output as any with the facade’s coded error verbatim. Mount them on whatever MCP server you already run — Prism’s core imports no MCP SDK at all, so importing it pulls none into your build.

import (
	"context"
	"encoding/json"

	"github.com/frankbardon/prism/mcp"
	"github.com/frankbardon/prism/rpc"
)

func mountPrism(facade *rpc.PrismServer) {
	cfg := mcp.Config{
		ServerName: "prism",
		Version:    "0.1.0",
		// ExamplesRoot left empty → serve the embedded example corpus.
		// Set it (plus ExamplesFS) to walk an on-disk directory instead.
	}

	for _, d := range mcp.Tools(cfg) {
		// d.Name, d.Description     — register on your server
		// d.InputSchema/.OutputSchema (json.RawMessage) — advertise to the agent
		// d.Invoke(ctx, facade, raw json.RawMessage) (any, error) — dispatch a call
		myServer.Register(d.Name, d.Description, d.InputSchema, d.OutputSchema,
			func(ctx context.Context, raw json.RawMessage) (any, error) {
				return d.Invoke(ctx, facade, raw)
			})
	}
}

The typed handlers (mcp.PlotTool, mcp.ValidateTool, mcp.DescribeTool, mcp.ExamplesSearchTool) and their I/O structs (mcp.PlotInput / mcp.PlotOutput, etc.) are exported too, if you prefer to call them directly against an *rpc.PrismServer rather than through the type-erased descriptors.

Import-firewall guarantee. The github.com/frankbardon/prism/mcp core pulls in no MCP SDK. This is enforced by internal/gates/mcp_firewall_test.go, which fails the build if the package’s transitive imports ever include one. Depending on the catalog never couples your binary to a particular MCP protocol library or version.

In-process: run a server with mcpserve

github.com/frankbardon/prism/mcpserve is the runner the other two paths leave out by design. The catalog and the gosdk adapter only mount tools; neither constructs or runs a server. mcpserve does both: hand it a configured *rpc.PrismServer and it builds a go-sdk server, registers the full Prism surface through gosdk.Register, and serves it — so the dataset registry, the afero filesystem seam, and the executor hooks you set on the facade are all exposed verbatim to the agent. That is more than prism mcp can offer, since the binary only surfaces what its flags reach.

Serve(ctx, facade, opts, in, out) runs over any io.Reader / io.Writer pair and blocks until ctx is cancelled or the transport errors. For an in-process client, wire it with two pipes:

import (
	"context"
	"io"

	"github.com/spf13/afero"

	"github.com/frankbardon/prism/mcpserve"
	"github.com/frankbardon/prism/rpc"
)

// mountInProcess starts a Prism MCP server on a pair of pipes and hands back
// the ends your MCP client talks to: write JSON-RPC frames to reqs, read
// responses from resps. The returned channel carries the server's exit error.
func mountInProcess(ctx context.Context) (reqs io.WriteCloser, resps io.Reader, done <-chan error) {
	facade := &rpc.PrismServer{
		Fs: afero.NewOsFs(),
		// DatasetRegistry and ExecOpts are optional — the zero value works,
		// and a nil facade serves the zero-value server.
	}

	reqR, reqW := io.Pipe()   // client → server
	respR, respW := io.Pipe() // server → client

	exit := make(chan error, 1)
	go func() {
		defer respW.Close()
		exit <- mcpserve.Serve(ctx, facade, mcpserve.Options{
			Version: "1.2.3",
			// ExamplesRoot left empty → serve the embedded example corpus.
			// Set it (plus ExamplesFS) to walk an on-disk directory instead.
		}, reqR, respW)
	}()

	return reqW, respR, exit
}

Serve never closes out — the caller owns its lifetime, which is why the goroutine above closes respW itself.

ServeStdio(facade, opts) is the same thing over the process’s stdin and stdout, taking no ctx; it blocks until stdin closes or the client disconnects. It is a one-liner, and exactly what prism mcp runs:

return mcpserve.ServeStdio(&rpc.PrismServer{Fs: afero.NewOsFs()}, mcpserve.Options{Version: "1.2.3"})

Options carries three fields: Version (the identity advertised during initialize; defaults to 1.0.0 when empty), ExamplesRoot, and ExamplesFS. The server name is always prism.

Bring your own transport: ServeTransport

Serve picks the transport for you — it wraps in and out in a go-sdk IOTransport. ServeTransport(ctx, facade, opts, t) skips that step and runs the server over any mcp.Transport you hand it. Pair it with mcp.NewInMemoryTransports() and the whole session stays inside one program: no pipes, no subprocess, and no JSON-RPC framing to hand-roll, because a real *mcp.Client sits on the other half.

import (
	"context"
	"fmt"

	"github.com/modelcontextprotocol/go-sdk/mcp"
	"github.com/spf13/afero"

	"github.com/frankbardon/prism/mcpserve"
	"github.com/frankbardon/prism/rpc"
)

// mountInMemory serves a Prism MCP on one half of an in-memory transport pair
// and returns a client session already initialized against the other half. The
// returned channel carries the server's exit error; cancelling ctx stops it.
func mountInMemory(ctx context.Context) (*mcp.ClientSession, <-chan error, error) {
	clientT, serverT := mcp.NewInMemoryTransports()

	facade := &rpc.PrismServer{Fs: afero.NewOsFs()}

	// Start the server first: the transports are pipe-backed, so Connect below
	// blocks until the server half is reading.
	exit := make(chan error, 1)
	go func() {
		exit <- mcpserve.ServeTransport(ctx, facade, mcpserve.Options{Version: "1.2.3"}, serverT)
	}()

	client := mcp.NewClient(&mcp.Implementation{Name: "my-host", Version: "0.1.0"}, nil)
	session, err := client.Connect(ctx, clientT, nil)
	if err != nil {
		return nil, nil, fmt.Errorf("connect prism session: %w", err)
	}
	return session, exit, nil
}

session is then an ordinary go-sdk client session: session.ListTools, session.CallTool and session.InitializeResult all work against the in-process Prism.

The caller owns the transport’s lifetime. ServeTransport never closes t and never wraps it; tearing it down is your job — close the client session and cancel ctx, and both halves retire. That is the one behavioural difference from Serve, which wraps the caller’s streams in non-closing adapters exactly so the server loop cannot close streams it does not own.

Which one do I want?

  • ServeStdio / Serve — you want a configured Prism mounted and the transport is not your concern. ServeStdio takes the process’s stdin and stdout, Serve takes any reader/writer pair; both build the transport for you.
  • ServeTransport — you already have a transport: an in-memory pair for a client in the same program, or your own mcp.Transport implementation. Prism runs on it, and it stays yours to close.
  • gosdk.Register (below) — you already run a go-sdk server and want Prism’s tools grafted in beside your own. Register mounts onto the server you built, so the server and the transport stay yours.

go-sdk: graft everything with one Register call

If you already run (or want) a modelcontextprotocol/go-sdk server, github.com/frankbardon/prism/mcp/gosdk mounts all four tools and the embedded example specs (as read-only prism://examples/<stem> resources) in a single call. This is the shape mcpserve wraps, spelled out — build a bare server, Register, then serve — and prism mcp reaches it through mcpserve.ServeStdio:

import (
	gosdk "github.com/modelcontextprotocol/go-sdk/mcp"
	"github.com/spf13/afero"

	"github.com/frankbardon/prism/mcp"
	prismgosdk "github.com/frankbardon/prism/mcp/gosdk"
	"github.com/frankbardon/prism/rpc"
)

func serve(ctx context.Context) error {
	facade := &rpc.PrismServer{Fs: afero.NewOsFs()}
	cfg := mcp.Config{ServerName: "prism", Version: "0.1.0"}

	srv := gosdk.NewServer(&gosdk.Implementation{Name: cfg.ServerName, Version: cfg.Version}, nil)
	if err := prismgosdk.Register(srv, facade, cfg); err != nil {
		return err
	}
	return srv.Run(ctx, &gosdk.StdioTransport{})
}

Register(server, facade, cfg) never constructs or returns a server — it grafts onto the one you pass, so Prism’s tools sit alongside your own.

Embedded example corpus

The curated example specs are embedded in a standalone, stdlib-pure package: github.com/frankbardon/prism/examples. Import it to surface examples as resources on a non-go-sdk server, or anywhere you need spec fixtures without pulling in the pipeline or an MCP SDK:

  • examples.List() []string — sorted stems of every valid spec (e.g. bar_basic, scales/log).
  • examples.Get(name string) ([]byte, bool) — raw spec JSON by stem.
  • examples.Search(query string, limit int) []examples.Result — substring search over stem + title.

The mcp/gosdk adapter uses exactly these accessors to publish each spec as a prism://examples/<stem> resource, so you can mirror that wiring on any transport.

Geographic marks

If the agent will plot geoshape / geopoint charts, give the server a map tier directory: both prism mcp and prism serve accept --geodata-dir <path> (or the PRISM_GEODATA environment variable), pointing at a folder of <tier>.geo.json files. Without it, a geo plot fails with PRISM_GEODATA_DIR_UNSET:

{
  "mcpServers": {
    "prism": {
      "command": "prism",
      "args": ["mcp"],
      "env": {"PRISM_GEODATA": "/path/to/geodata"}
    }
  }
}

See Geographic Marks for the tier files and download link.

Embed Prism in a Static Site

Prism ships as a WebAssembly module that renders charts entirely in the browser. This recipe walks through dropping Prism into a static site (plain HTML, mdBook, Astro, Hugo, GitHub Pages) with no backend.

Prerequisites

  • Go 1.24+ for the host CLI, and TinyGo 0.41.1+ to produce prism.wasm (brew tap tinygo-org/tools && brew install tinygo).
  • A static file server (anything that serves Content-Type: application/wasm correctly — GitHub Pages, Netlify, Vercel, S3, nginx, python -m http.server, all work).

file:// does not work — browsers refuse to instantiate WASM from local file URLs. Run a local server during development.

1. Build the bundle

From a Prism checkout:

make build-wasm-tinygo
./bin/prism static-bundle --wasm ./public/prism

That writes:

public/prism/
├── index.html           # minimal loader example
├── prism.wasm           # TinyGo build; ~6.9 MiB raw / ~2.2 MiB gzipped
├── prism.wasm.gz        # gzipped binary — what the loader fetches
├── wasm_exec.js         # TinyGo runtime loader
├── prism.mjs            # bootstrapper
├── prism-element.mjs    # web components
├── prism-resolver.mjs   # dataset registry
└── prism-selection.mjs  # interaction wiring

Copy public/prism/ into your static site’s deployed root. Or serve it from any path — references inside the bundle are relative, so /static/prism/, /assets/prism/, etc. all work.

2. Drop a chart into a page

<!doctype html>
<html>
<head>
  <link rel="preload" as="fetch" type="application/wasm"
        href="/prism/prism.wasm" crossorigin>
  <script src="/prism/wasm_exec.js"></script>
  <script type="module" src="/prism/prism-element.mjs"></script>
</head>
<body>
  <prism-chart spec='{
    "$schema": "urn:prism:schema:v1:spec",
    "data": {"values": [
      {"region": "NA", "score": 0.82},
      {"region": "EU", "score": 0.74},
      {"region": "APAC", "score": 0.68}
    ]},
    "mark": "bar",
    "encoding": {
      "x": {"field": "region", "type": "nominal"},
      "y": {"field": "score", "type": "quantitative"}
    }
  }'></prism-chart>
</body>
</html>

The spec= attribute accepts inline JSON or a URL pointing at a .prism.json file. The first call to <prism-chart> triggers the WASM download; subsequent charts on the same page reuse the loaded instance.

3. Share datasets across charts

<prism-dataset> declares an alias the WASM bridge resolves at fetch time. The src must serve materialized rows (a JSON array of row objects) — Prism does not decode .pulse files:

<prism-dataset name="current" src="/data/q1.rows.json"></prism-dataset>
<prism-dataset name="bench"   src="/data/industry.rows.json"></prism-dataset>

<prism-chart spec="/specs/actual_vs_benchmark.prism.json"></prism-chart>
<prism-chart spec="/specs/trend.prism.json"></prism-chart>

Both charts share fetches: the page issues one HTTP request per unique src, not one per chart.

4. mdBook integration

Drop the bundle under theme/:

mybook/
├── book.toml
├── src/
│   ├── SUMMARY.md
│   └── chapter1.md
└── theme/
    └── prism/...      # contents of public/prism/

In book.toml, declare the additional JS:

[output.html]
additional-js = [
  "theme/prism/wasm_exec.js",
  "theme/prism/prism-element.mjs"
]

Then in any chapter:

<prism-chart src-spec="/charts/example.prism.json"></prism-chart>

5. Astro / Hugo / static-site generators

Treat the bundle as a static asset directory. In Astro put it under public/prism/; in Hugo under static/prism/. Include the two script tags from step 2 in your base layout. The web components register globally; any page that uses <prism-chart> renders without additional wiring.

Tuning

  • Preload the wasm: <link rel="preload" as="fetch" type="application/wasm" href="prism.wasm" crossorigin> starts the download in parallel with the page parse.
  • Set CORS: when the dataset (or geodata) origin differs from the page origin, that host must return Access-Control-Allow- Origin matching the page. Errors surface as PRISM_WASM_001.
  • Theme switching: set theme="dark" on <prism-chart> — the browser re-runs executeSpec with the new theme. Fast because the WASM instance + dataset cache stay warm.

Limits

  • Initial WASM download is ~2.2 MiB gzipped. Cache aggressively (immutable hashed filename + 1-year Cache-Control).
  • Large inline datasets (>50 MB of rows) parse and aggregate slowly in the browser on mid-range hardware. Pre-aggregate upstream when the chart’s audience is mobile.

Consume Structured Selection Events

Every prism:select CustomEvent carries a structured Event payload (mirrors the Go selection.Event struct). The same shape travels across browser, Go-native, and Twirp contexts so one handler works against any binding.

The event shape

{
  "scene_id":     "scene-0",
  "selection_id": "brush",
  "kind":         "point",          // "point" | "interval" | "lasso"
  "timestamp":    1716826200000,
  "marks": [
    { "mark_index": 0, "instance_key": "layer-0:42" }
  ],
  "data_rows": [
    { "dataset_name": "cohort", "row_index": 42 }
  ],
  "data_extent":  { "x": { "min": 10, "max": 50 } },
  "pixel_extent": { "x": { "min": 120, "max": 480 } },
  "spec_path":    "/selection/brush"
}

mark_index is the layer’s index in the spec’s layer array (or 0 for unlayered charts). instance_key is stable across re-renders for the same source row — derive joins and lookups from it.

Browser: forward selections to a sidebar

<prism-chart id="chart" spec="./bar.prism.json"></prism-chart>
<aside id="sidebar"></aside>

<script type="module">
  const chart   = document.getElementById("chart");
  const sidebar = document.getElementById("sidebar");

  chart.addEventListener("prism:select", (ev) => {
    const e = ev.detail;
    if (e.kind === "point") {
      sidebar.innerHTML = e.marks
        .map(m => `<div>${m.instance_key}</div>`)
        .join("");
    } else if (e.kind === "interval" && e.data_extent?.x) {
      const { min, max } = e.data_extent.x;
      sidebar.textContent = `x ∈ [${min}, ${max}]`;
    }
  });
</script>

The event bubbles + composes through Shadow DOM, so listening on document or any ancestor also works.

Browser: cross-app forwarding (Slack, websocket, postMessage)

Because the event is fully structured, you can serialise it directly:

chart.addEventListener("prism:select", (ev) => {
  socket.send(JSON.stringify(ev.detail));
});

No translation step — the receiver gets the same selection.Event shape the renderer emitted.

Go: build an event from raw input

The Go side exposes the same shape via the selection package. Use it from a Twirp handler, MCP tool, or any server-side selection synthesis path:

import "github.com/frankbardon/prism/selection"

ev, err := selection.Build(selection.BuildInput{
    SceneID:     "scene-0",
    SelectionID: "brush",
    Kind:        selection.KindPoint,
    Points: []selection.PointHit{
        {LayerID: "layer-0", RowID: 42},
    },
}, sceneDoc, spec)
if err != nil {
    return err
}
body, _ := json.Marshal(ev)
// body is byte-identical to the browser-side CustomEvent.detail.

selection.Build walks the SceneDoc to resolve mark_index and dataset_name from the (layer_id, row_id) pair. Unknown layers (stale events after re-render) come back with mark_index = -1 so the consumer can decide whether to drop the entry.

Back-compat

Pre-existing handlers that consumed {id, state} keys still work — those fields are retained on the event payload alongside the new structured ones.

Worked examples

Runtime Data References (setDataResolver)

A {data: {ref: "<name>"}} spec leaves data binding to the rendering environment. The spec describes what to draw; a caller-supplied resolver provides the data to draw it with. Lets one spec render in a browser, server, and test harness without modification.

The spec

{
  "$schema": "urn:prism:schema:v1:spec",
  "data": {"ref": "current_window"},
  "mark": "line",
  "encoding": {
    "x": {"field": "ts",   "type": "temporal"},
    "y": {"field": "rate", "type": "quantitative"}
  }
}

The string current_window is opaque to Prism — it’s whatever identifier the caller’s resolver understands.

Browser: live data from a fetch

Synchronous return is required (Go-WASM cannot await a Promise mid-execute). Pre-resolve the async data and register a sync getter:

<prism-chart id="chart" spec="./live.prism.json"></prism-chart>

<script type="module">
  const data = await fetch("/api/window.json").then(r => r.json());

  prism.setDataResolver((ref) => {
    if (ref === "current_window") return { values: data };
    return null;  // unresolved refs fall back to PRISM_RESOLVE_REF_UNRESOLVED
  });

  document.getElementById("chart").reload();
</script>

The callback returns the same shape as inline data: {values: [...]} — an object with values (row array) and optional fields (column-type hints).

Browser: chart-driven refresh on a timer

async function refresh() {
  const window = await fetchWindow();
  prism.setDataResolver(ref => ref === "current_window"
    ? { values: window } : null);
  chart.reload();
}
setInterval(refresh, 60_000);

Each chart.reload() re-runs the compile pipeline; the resolver is consulted afresh and returns the most recent rows.

Go-native: in-process resolver

import (
    "context"

    prism "github.com/frankbardon/prism"
    "github.com/frankbardon/prism/plan"
    "github.com/frankbardon/prism/plan/build"
    "github.com/frankbardon/prism/resolve"
    "github.com/frankbardon/prism/spec"
)

func compile(ctx context.Context, body []byte, live []map[string]any) (*prism.CompiledPlan, error) {
    s, err := spec.DecodeBytes(body)
    if err != nil {
        return nil, err
    }
    resolver := resolve.MapDataResolver{
        "current_window": {Values: live},
    }
    return prism.Compile(ctx, s, prism.CompileOptions{
        Build: build.Options{DataResolver: resolver},
        Exec:  plan.ExecOpts{Workers: 1},
    })
}

resolve.MapDataResolver is the static map-backed implementation. For dynamic lookups (e.g. database, cache layer) wrap your logic in resolve.DataResolverFunc:

resolver := resolve.DataResolverFunc(func(ctx context.Context, ref string) (*resolve.Dataset, error) {
    rows, err := db.QueryWindow(ctx, ref)
    if err != nil {
        return nil, err
    }
    return &resolve.Dataset{Values: rows}, nil
})

Chain multiple resolvers (e.g. cache → DB → fallback) with resolve.ChainDataResolvers(cache, primary).

Test fixtures

func TestChartShape(t *testing.T) {
    body := mustReadSpec(t, "testdata/live.prism.json")
    plan, err := prism.CompileJSON(context.Background(), body, prism.CompileOptions{
        Build: build.Options{
            DataResolver: resolve.MapDataResolver{
                "current_window": {Values: []map[string]any{
                    {"ts": "2026-01-01", "rate": 0.42},
                }},
            },
            Backend:  inmem.New(),
        },
    })
    if err != nil { t.Fatal(err) }
    if plan.Marks[0].InstanceCount != 1 { t.Errorf("rows = %d", plan.Marks[0].InstanceCount) }
}

The same spec drives every environment. No fixture fork, no URL rewrite.

Error surface

ConditionCode
No resolver installedPRISM_RESOLVE_REF_UNRESOLVED
Resolver returned null / ErrDataRefUnresolvedPRISM_RESOLVE_REF_UNRESOLVED
Resolver returned undecodable JSON (WASM)PRISM_RESOLVE_REF_UNRESOLVED
Async/Promise callbackSurfaces as undecodable → unresolved

Run prism errors lookup PRISM_RESOLVE_REF_UNRESOLVED for fixup guidance.

Incremental Edits with Spec Patches

For interactive scenes — change one encoding, swap a data source, toggle a layer — sending the full spec across the wire is wasteful. Prism speaks RFC 6902 JSON Patch so callers transmit just the delta.

The shape

[
  { "op": "replace", "path": "/mark", "value": "area" },
  { "op": "add",     "path": "/encoding/color",
                     "value": { "field": "category", "type": "nominal" } },
  { "op": "test",    "path": "/data/name", "value": "current_window" },
  { "op": "remove",  "path": "/title" }
]

Six op types — add, remove, replace, move, copy, test. Paths are JSON Pointers (RFC 6901): /encoding/x/field, /layer/0/mark, /datasets/main/values/- (the - token appends to an array).

Browser: optimistic incremental edit

<prism-chart id="chart" spec="./bar.prism.json"></prism-chart>

<script type="module">
  const chart = document.getElementById("chart");
  const initialSpec = chart.getAttribute("spec");

  async function switchToArea() {
    const patch = JSON.stringify([
      { op: "replace", path: "/mark", value: "area" },
    ]);
    const nextSpec = prism.applyPatch(initialSpec, patch);
    chart.setAttribute("spec", nextSpec);  // triggers re-render
  }
</script>

prism.applyPatch returns the patched spec as JSON. Hand it straight back to the chart element or feed it into prism.compile for inspection without re-rendering.

Atomic semantics + test

Either every op applies cleanly or no state changes. Use test to fail-fast on optimistic-concurrency violations:

const patch = JSON.stringify([
  { op: "test",    path: "/encoding/x/field", value: "brand_id" },
  { op: "replace", path: "/encoding/x/field", value: "category" },
]);
const out = prism.applyPatch(currentSpec, patch);
const parsed = JSON.parse(out);
if (parsed.ok === false) {
  // PRISM_SPEC_PATCH_001 — current value drifted; refresh and retry.
}

A failing op surfaces as PRISM_SPEC_PATCH_001 with the offending op’s index in error.Context.OpIndex.

Diff helper — think in specs, transmit deltas

const before = JSON.stringify(originalSpec);
const after  = JSON.stringify(editedSpec);
const patchJSON = prism.diffSpecs(before, after);

// Apply remotely:
socket.send(patchJSON);

prism.diffSpecs produces a correct (but not necessarily minimal) patch. The other side calls prism.applyPatch(local, patchJSON) and lands on the same spec.

Go-native: stateful Scene

The prism.Scene struct wraps a spec + its last compiled plan:

import (
    "context"
    prism "github.com/frankbardon/prism"
)

scn, err := prism.NewScene(ctx, s, prism.CompileOptions{})
if err != nil {
    return err
}

// Swap the mark type — atomic re-compile under the hood.
if err := scn.Apply(prism.Patch{
    {Op: "replace", Path: "/mark", Value: "area"},
}); err != nil {
    // Failed patches leave scn.Spec() and scn.Plan() unchanged.
    return err
}

plan := scn.Plan()  // freshly compiled

scn.ApplyAndRender(patch) is shorthand for Apply + Plan(). Hand the returned plan to a renderer for pixel bytes.

Building a patch from scratch

For programmatic edits, build the patch slice directly:

patch := prism.Patch{
    {Op: "replace", Path: "/data/ref", Value: "live_window"},
    {Op: "add",     Path: "/encoding/color", Value: map[string]any{
        "field": "segment",
        "type":  "nominal",
    }},
}

Or compute it from two known specs:

patch, err := prism.DiffSpecs(before, after)

Performance note

This first cut applies every patch by re-decoding the patched spec and re-running the full compile pipeline. Partial re-validation and per-mark re-compilation (touched layers only) are tracked as a follow-up — the patch API contract is stable; the optimisation lands transparently underneath.

Error reference

prism errors lookup PRISM_SPEC_PATCH_001 lists fixup guidance. The envelope’s Details carries:

KeyMeaning
OpIndexZero-based index of the failing op in the patch array
OpThe op name (add / replace / …)
PathThe JSON Pointer at fault

Playground

Prism ships an interactive playground that runs the full spec → validate → plan → compile → encode → render pipeline in your browser via WASM. No server, no install.

→ Open the playground

What it does

  • Live render. Edit a JSON spec on the left; the rendered SVG on the right updates after a short debounce. Errors surface inline with their canonical PRISM_* code, message, and any attached fixups — the same envelope you get from the CLI or the MCP tool.
  • Curated examples. ~25 specs across basic marks, distributions, composition operators, transforms, scales, and themes. Click an entry in the sidebar to load it.
  • Theme switch. Flip between the light, dark, and print themes without reloading.
  • Inspector tabs. Below the preview: the rendered SVG source, the resolved Scene IR, the plan DAG node list, and the raw spec as the WASM bridge sees it.
  • Share. “Share” copies a URL with the spec encoded in the fragment (deflate-raw + base64url). Past a couple-kilobyte spec it stays comfortably within URL-length budgets, and Discord / Slack / mail clients will preserve it on copy/paste.
  • Local persistence. Edits survive a reload via localStorage; hit “Reset” or click any sidebar entry to reload a clean example.
  • Keyboard. Tab / Shift+Tab indent; Ctrl/⌘+S formats the spec; Ctrl/⌘+Enter forces an immediate render.

What it doesn’t do (yet)

  • External data sources. Every example inlines its rows via data.values / datasets.*.values. Prism only ever consumes already-materialized rows — the playground has no data-fetch path, so the curated examples stay self-contained. To render data from a live source, materialize it upstream and inline the rows (prism plot / prism serve accept the same inline specs locally).
  • Selection events. Pointer hit-testing is part of the <prism-chart> web component (see the gallery live demo). The playground mounts the raw SVG so it stays a focused spec-to-SVG editor; selection wiring lands in v2.

Where the bytes come from

The playground loads the same prism.wasm that powers the gallery (docs/src/static/prism/prism.wasm, served via the docs/src/static symlink to static/vendor/prism/). The WASM binary contains every stage of the pipeline; the playground JS is a ~15 KiB shell that debounces keystrokes, marshals JSON across the bridge, and updates the DOM.

Prism Gallery

129 fixture specs across 18 categories. Each entry pairs a *.prism.json spec with a rendered *.svg. Browse the source to learn the spec shapes; open the SVGs to see what they render. The table/ fixtures are one exception — a top-level table mark renders only through the html backend, so those entries link to a rendered *.html file instead of an <img> preview. The Custom marks section is the other exception, and has no *.prism.json at all — see that section for why.

For live interactive rendering in a browser, see live.html.

Basic marks

Composite marks

Specialty marks

Progress

One value bar per data row on a full-scale track — the multi-row sibling of bullet. See Marks › Progress.

Geographic marks

Table

The table mark renders as an interactive, paginated HTML <table> — not SVG geometry — so these fixtures link to a rendered *.html file rather than showing an <img> preview. See Marks › Table.

SpecPreview
table_accountstable_accounts.html — plain columns, paginated (page_size: 5 over 7 rows)
table_revenue_trendtable_revenue_trend.html — a sparkline sub-mark column

Custom marks

A custom mark (mark: {"type": "custom", "renderer": "…"}) resolves its renderer name against a Go-level registry (custommark.Register) at render time — a mechanism only a caller-supplied binary can exercise, not the shared prism CLI, which has nothing registered under any name. That means these two fixtures have no *.prism.json companion (unlike every other category on this page, table/ included): a spec referencing quote-card or stat-card cannot be plotted by prism plot as committed. See Cookbook › Custom marks: Why no gallery *.prism.json for these two for the full explanation, the exact spec JSON that produced each file below, and the registered HTMLCustomRenderer implementation’s full source.

ExamplePreview
Quote card (worked example)quote_card.html — pull-quote + attribution
Stat card (worked example)stat_card.html — metric tile with a label, value, and up/down delta

Composition (layer / concat / facet / repeat)

Multi-source

Transforms

Scales

Axes

Legends

Everything the legend block controls — content (title, values, label_limit), layout (orient, direction, symbol_type, symbol_size), and the gradient legend a continuous colour channel builds. See Encoding › Legends.

Selections

Conditions

Per-channel condition clauses switch a channel’s value based on a selection or a structured predicate test. See Encoding › Conditions.

Tree

Rooted hierarchies laid out with tidy-tree, plus the dendrogram variant (step links, hidden nodes). All three bind the text channel to label their nodes. See Marks › Tree / dendrogram / network.

Network

Force-directed node-link diagrams with deterministic seeded layouts. Bind the text channel to label the nodes. See Marks › Tree / dendrogram / network.

Themes

Themes are applied via the --theme CLI flag at plot time. Each spec below is identical; only the rendering theme differs.

bar_dark_variant is different from the row above it: its spec sets theme: {"dark_variant": "dark"} instead of picking a theme with --theme, so the one rendered SVG embeds both the light and dark palettes and switches between them via @media (prefers-color-scheme: dark) — see Dark variant pairing. The static preview above can only show the light rendering (the one your browser is currently in), so verify the effect one of two ways:

  • Toggle your OS or browser dark-mode setting while viewing the <img> above (or open the raw SVG directly) — the axes, grid, legend, and bar colors all swap without a re-render.
  • View source on themes/bar_dark_variant.svg and look for the @media (prefers-color-scheme: dark) block inside the <style> element, plus fill="var(--prism-resolved-N)" on the <rect> elements — those are the doubled CSS custom properties described in the docs.

bar_category_styles demonstrates theme.category_styles: the theme maps each quarter value to its own MarkStyle (Q1/Q2/Q3/Q4 each get a distinct fill), and every bar picks up its category’s color automatically — no spec-level condition block at all. See Category styles for the field→value→style shape and how a spec-level condition on the same field/value would win if one were present.

bar_per_axis_grid demonstrates theme.axis_x / theme.axis_y: the shared axis block sets one grid colour, then each per-axis block overrides it for one orientation only — the vertical grid lines (drawn by the x axis) become dashed amber, the horizontal ones (drawn by the y axis) stay slate. It also exercises the axis-title gap: the y channel’s spec-level axis.title_padding outranks the axis_y.title_padding the theme asks for, while the x axis, which nobody configures, keeps the built-in 8 px. See Per-axis blocks and Axis geometry precedence.

Animation

Each spec declares an animation block plus a key: true channel; the SVG previews show the initial frame. The tween fires in the browser web component / WASM runtime when the spec swaps or a new dataset arrives — see Spec › Animation and Browser › Animation.

SpecPreview
swap_bars
race_bars