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

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
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

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.
  • cornerRadiuscorner_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 comparisonseq, 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 membershipone_of / not_one_of — tests a field against a non-empty candidate set:

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

Inclusive rangebetween — keeps rows where lo <= field <= hi:

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

Null checksis_null / not_null — take only a field:

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

Boolean combinatorsand / 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.

Strict by default

  • Unknown fields error (typos like xfield vs x.field caught at parse).
  • Semantic violations error (agg op on incompatible field type, etc.).
  • 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

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.
lineContinuous trends; ordered x-axis.
areaFilled trends. Supports negative values + stacks.
pointScatter, dot plots.
circle, squareConvenience aliases for point with shape preset.
tickStrip plots, ranking dot plots.
rectHeatmap cells, custom rectangular layouts.
ruleReference lines, benchmarks, ranges.
textInline labels, annotations.
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 (whiskers) + point (outliers).
violinarea symmetric around centerline (Epanechnikov KDE).
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-height up/down micro-bars by the sign of y (>0 up, <0 down, ==0 flat). Magnitude is ignored — only direction encodes.
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.
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.

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 horizontal normal-range band, spanning the full spark width between the two value-axis bounds, 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"}
  }
}

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.
  • color, fill, stroke, opacity, size — standard mark props.

Mark-def options:

  • orientvertical (default), horizontal, radial.
  • link_shapestep (default), curve, straight.
  • node_shapecircle (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.

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.
  • orientationhorizontal (default) or vertical.
{
  "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).

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).

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.

Worked examples

Every mark above has a fixture in the gallery. Start with:

Encoding

The encoding object binds data fields to visual channels.

Channels

FamilyChannels
Positionx, y, x2, y2, theta, theta2, radius, radius2
Color & opacitycolor, fill, stroke, opacity
Size & shapesize, shape
Text & ordertext, tooltip, order, detail
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 (title, format, grid, tick_count, label_angle, …).
legendLegend config (title, orient, direction, …).
formatd3-format string for label formatting.
sort"ascending" / "descending" / "-y" / [explicit, order, ...].
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).

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.

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), major + minor ticks, grid toggle, label rotation, overlap handling, gradient + symbol legends.

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+.

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

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.

Scale resolution

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

ValueBehavior
shared (default for x/y)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.

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,
    "title_color":   "#111827",
    "title_font_size": 12,
    "title_padding": 8
  },

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

  "title": {
    "color":      "#111827",
    "font_size":  16,
    "font_weight":"600",
    "anchor":     "start"
  },

  "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 }
  }
}

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, …).
axisAxis domain, ticks, grid, labels, titles.
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.

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.

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" }
    }
  }
}

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-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-legend-padding    --prism-legend-symbol-size --prism-title-font-size
--prism-view-bg           --prism-view-padding

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

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.

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.

The encoder collects null rows it drops and emits PRISM_WARN_NULL_DROPPED carrying the count + offending channels. 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.2 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~6.9 MiB (7,239,767 B)~2.2 MiB (2,232,605 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.

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.

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. 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 index.html 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: 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 three 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 mcp/gosdk.Register one-call adapter — graft all four tools plus the embedded example resources onto a modelcontextprotocol/go-sdk server.

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 package. Pick the path that matches whether you want an MCP SDK in your dependency graph.

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.

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 exactly what prism mcp does internally — build a bare server, Register, then serve:

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). 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

90 fixture specs across 13 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.

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

Basic marks

Composite marks

Specialty marks

Geographic marks

Composition (layer / concat / facet / repeat)

Multi-source

Transforms

Scales

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). See Marks › Tree / dendrogram / network.

Network

Force-directed node-link diagrams with deterministic seeded layouts. 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.

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