Prism
Prism is a visualization library for .pulse files. It compiles
declarative JSON specs into charts — server-side SVG/PNG/PDF via Go,
and live in-browser via web components — using Vega-Lite-inspired
vocabulary with snake_case naming and Pulse expression syntax.
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, first chart, editor setup.
- Playground — live spec editor (WASM, no install).
- Gallery — 59 fixture specs with rendered SVGs.
- Concepts — Spec, marks, encoding, composition, selections, themes, multi-source.
- Reference — spec field reference + error code catalog.
- Cookbook — recipes for common patterns.
- Migration from Vega-Lite.
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
Editor setup
Each entry in .prism/editor/ has a header comment with install
instructions. The fastest path:
- VSCode — copy
.prism/editor/vscode-settings.jsoninto.vscode/settings.json.*.prism.jsonfiles get autocomplete + inline validation from the embedded schema. - JetBrains — copy
.prism/editor/jetbrains.xmlto.idea/jsonSchemas.xml. - Neovim — paste the
.prism/editor/neovim.luasnippet into yourinit.lua(requiresnvim-lspconfig). - Vim — paste the
.prism/editor/vim.alelintblock into your.vimrc(requiresdense-analysis/aleandprismin 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 my-chart.prism.json --format pdf > chart.pdf
prism plot dashboard.json --format pdf --paginate > dashboard.pdf
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.
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
./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
- Browse the gallery for spec patterns.
- Read Spec concepts to learn the data → transform → mark → encoding pipeline.
- See Multi-source to join multiple
.pulsefiles in one chart. - See Browser / WASM for the standalone client-side rendering path.
- Read Migration from Vega-Lite if you already know Vega-Lite.
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-Lite | Prism | Why divergence |
|---|---|---|
data.url | data.source | Pulse refs aren’t URLs (could be cohort ID, GCS path, archive#shard). |
transform[].aggregate | same shape | identical |
op: "mean" | same | friendly aliases match Vega-Lite verbatim |
mark, encoding | same vocabulary | same |
type: "quantitative" | same | nominal/ordinal/quantitative/temporal |
scale.scheme | same | same color schemes |
selection | same shape | point + interval supported v1 |
params / signals | dropped | no reactive runtime |
layer, concat, facet, repeat | same | full composition v1 |
condition encodings | dropped v1 | post-v1 feature |
strokeWidth (camelCase) | stroke_width | snake_case throughout |
| Vega expression language | Pulse expression syntax | one 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-Lite | Prism |
|---|---|
strokeWidth | stroke_width |
cornerRadius | corner_radius |
fontSize | font_size |
tickCount | tick_count |
labelOverlap | label_overlap |
Pulse expression syntax (D005)
filter predicates and calculate computed columns use Pulse
expression syntax, not Vega’s JS-like language.
| Vega-Lite | Prism |
|---|---|
"filter": "datum.score > 50" | "filter": "score > 50" |
"filter": "datum.region === 'NA'" | "filter": "region == 'NA'" |
"calculate": "datum.x * 2", "as": "y" | "calculate": "x * 2", "as": "y" |
No datum. prefix. == instead of ===. No JS function calls.
Aggregate aliases (D003)
Vega-Lite parity:
count sum mean median min max stdev variance q1 q3 ci0 ci1
Pulse adds: distinct mode.
Cohort-analytics extensions (Prism-only): wmean ratio lift share.
Dropped features (v1)
params/ signals — no reactive runtime.conditionencodings — post-v1.- Inline Vega expressions everywhere — use Pulse expressions or
pre-compute via a
calculatetransform. - Vega-Lite tooltip template strings — Prism tooltips are
pre-formatted
TooltipLinelists.
Added features
datasetsblock + per-layerdataoverrides — 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,sparklinemarks — 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": {"source": "cars.pulse"},
"transform": [{"filter": "Horsepower > 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→data.source;.json→.pulse.filter: dropdatum.prefix.cornerRadius→corner_radius.colorchannel: explicittype(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 / PDF)
Minimum viable spec
{
"$schema": "urn:prism:schema:v1:spec",
"data": {"source": "cohort.pulse"},
"mark": "bar",
"encoding": {
"x": {"field": "brand_id", "type": "nominal"},
"y": {"field": "score", "type": "quantitative", "aggregate": "mean"}
}
}
Five top-level keys are typically present:
| Key | Purpose |
|---|---|
$schema | URN identifier (urn:prism:schema:v1:spec) for editor autocomplete + version pinning. |
data | Where to read rows from — a .pulse source, an inline values array, a named alias, etc. |
transform | Optional array of row-level operations (filter, calculate, aggregate, sort, …). |
mark | What to draw — bar, line, point, pie, sankey, … |
encoding | How 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
config 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 and PDF output is unaffected — both renderers
ignore 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:
| Field | Default | Notes |
|---|---|---|
duration_ms | 400 | Total tween length, capped at 5000. |
easing | cubic_in_out | One of linear, cubic_*, quad_*, sine_*, expo_* (× in/out/in_out). |
stagger_ms | 0 | Per-mark delay applied in document order. |
enter | fade | fade or none. Marks that appear at scene-swap time. |
exit | fade | fade 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 haskey: true.PRISM_SPEC_024— more than one channel carrieskey: true.
Strict by default
- Unknown fields error (typos like
xfieldvsx.fieldcaught at parse). - Semantic violations error (agg op on incompatible field type, etc.).
- 24+
PRISM_SPEC_*rules cover field-existence, channel-for-mark, selection refs, expression parsing, scale type compatibility, animation easing / key constraints, and more. Runprism 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, Encoding, Composition.
- Spec field reference — every field with type + description.
- Gallery — 59 worked examples.
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)
| Mark | When to use |
|---|---|
bar | Compare categories. The default. |
line | Continuous trends; ordered x-axis. |
area | Filled trends. Supports negative values + stacks. |
point | Scatter, dot plots. |
circle, square | Convenience aliases for point with shape preset. |
tick | Strip plots, ranking dot plots. |
rect | Heatmap cells, custom rectangular layouts. |
rule | Reference lines, benchmarks, ranges. |
text | Inline labels, annotations. |
arc | Primitive for pie / donut / sankey links. |
Composite marks
| Mark | Internally expands to |
|---|---|
histogram | bar + auto-bin transform. |
heatmap | rect + 2D bin + sequential color scale. |
boxplot | rect (IQR) + rule (whiskers) + point (outliers). |
violin | area symmetric around centerline (Epanechnikov KDE). |
pie | arc with theta computed from share. |
donut | arc with inner_radius_ratio > 0. |
Specialty marks
| Mark | When to use |
|---|---|
sankey | Flow diagrams (source/target/value table). |
funnel | Conversion funnels — stacked trapezoids. |
sparkline | Inline micro-line charts, no axes. |
image | Sprites / data-URL images at position. |
path | Raw SVG path data — escape hatch. |
geoshape | Country / admin-1 polygons (choropleth). See Geographic Marks. |
geopoint | Lon/lat → point overlay. See Geographic Marks. |
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 and PDF renderers handle them without
new geometry types.
| Mark | When to use |
|---|---|
tree | Rooted hierarchy (org charts, decision trees). Reingold-Tilford tidy layout. |
dendrogram | Clustering tree — tree variant with link_shape: step + node_shape: none defaults. |
network | Undirected / 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:
orient—vertical(default),horizontal,radial.link_shape—step(default),curve,straight.node_shape—circle(default),rect,none.node_size— base radius / side length (default 6).layout(network) —force(default),random.iterations,link_distance,charge,seed(network).
Validate rules: PRISM_SPEC_028 (missing source/target),
PRISM_SPEC_029 (multi-root tree). Encode-time:
PRISM_ENCODE_TREE_CYCLE, PRISM_ENCODE_NETWORK_NONFINITE,
PRISM_WARN_NETWORK_CYCLE.
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
| Family | Channels |
|---|---|
| Position | x, y, x2, y2, theta, theta2, radius, radius2 |
| Color & opacity | color, fill, stroke, opacity |
| Size & shape | size, shape |
| Text & order | text, tooltip, order, detail |
| Facet | row, column |
| Sankey | source, target, value |
Channel shape
"x": {
"field": "score",
"type": "quantitative",
"aggregate": "mean",
"scale": {"type": "log"},
"axis": {"title": "Average score", "format": ".2f"},
"sort": "-y"
}
| Key | Purpose |
|---|---|
field | Column from the source (or transform output). |
type | One of nominal, ordinal, quantitative, temporal. |
aggregate | Friendly alias: mean, sum, count, median, q1, q3, min, max, stdev, variance, ci0, ci1, distinct, mode, plus wmean, ratio, lift, share. |
scale | Scale spec (type, domain, range, scheme, padding, …). |
axis | Axis config (title, format, grid, tick_count, label_angle, …). |
legend | Legend config (title, orient, direction, …). |
format | d3-format string for label formatting. |
sort | "ascending" / "descending" / "-y" / [explicit, order, ...]. |
key | true to mark this channel as the animation join key — see Spec › Animation. At most one channel per encoding may set this; only valid on position channels (x, y, x2, y2, theta, radius) and mark channels (color, fill, stroke, opacity, size, shape, sankey source/target/value, geo longitude/latitude/feature). |
Conditions
A channel can carry a condition clause that switches its visual
value based on a declared selection or a Pulse
expression test. The channel’s own value / field supplies the
fallback (“otherwise”) branch.
"color": {
"condition": [
{"selection": "brush", "value": "#22c55e"},
{"test": "score < 0", "value": "#ef4444"}
],
"value": "#94a3b8"
}
Rules:
selectionreferences a name declared in the spec’sselectionblock (validate rulePRISM_SPEC_025).testis a Pulse expression evaluated at encode time (PRISM_SPEC_026); the same parser that powersfilterandcalculatetransforms.- Each entry needs exactly one of
valueorfield. Aselection-form entry withoutvalueinherits the channel’s own field binding (PRISM_SPEC_027). - Entries evaluate top-down; the first match wins.
Where the work happens:
test-driven entries are evaluated server-side at encode time and baked directly into the mark’s resolved style. SVG and PDF output reflect them with no client involvement.selection-driven entries land in the scene-IR as aMark.Conditions[]slice. The browser-sideprism-selectionmodule flips the matching SVG attribute when the named selection becomes active, and reverts to the resolved “otherwise” branch when it clears.- PDF renders the “otherwise” branch for selection entries (PDFs
are static); a
PRISM_WARN_PDF_CONDITION_FLATTENEDwarning fires when this would have changed the page.
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
- Spec field reference — every channel property exhaustively.
- Themes — how scale color schemes resolve.
Composition
Prism supports five composition primitives, all v1:
| Op | What | Multi-source? |
|---|---|---|
layer | Stack marks on shared axes | per-layer data allowed |
concat / hconcat / vconcat | Side-by-side panels | per-panel data allowed |
facet | Grid by data values (one cell per partition) | usually single source |
repeat | Grid 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:
| Value | Behavior |
|---|---|
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
- layer_actual_vs_benchmark — bar + rule overlay.
- vconcat_metrics — 3-row stack.
- facet_by_region — 3×3 grid.
- facet_nested — recursion proof.
- repeat_metrics — 1×4 over 4 metrics.
- dashboard — 4-cell vconcat showcasing mixed marks.
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
| Kind | Trigger | State |
|---|---|---|
point | Click on a mark | {points: [{layerID, rowID}]} |
interval | Drag-brush on plot region | {range: {channel, min, max}} |
Reactive modes
| Mode | Loop |
|---|---|
client | Brush/click → DOM class toggle on marks. Zero network. |
server | Brush/click → POST /prism/scene with synthesized filter → re-render. |
both | Apply 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.pulse", "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 across all renderers. Single Go struct as the source of truth; emitted as CSS variables for the SVG and browser ports.
Bundled themes
| Name | When to use |
|---|---|
light (default) | Standard web pages, light backgrounds. |
dark | Dark dashboards, terminal embeds. |
print | Reports, PDF output. Black ink, no gradients, no transparency. |
Pick at plot time
prism plot bar.json --theme=dark > bar-dark.svg
Sparse override at spec level
{
"$schema": "urn:prism:schema:v1:spec",
"theme": {
"name": "light",
"overrides": {
"axis_color": "#1f2937",
"color_scheme_categorical": ["#2563eb", "#10b981", "#f97316"]
}
},
...
}
Spec-level overrides merge onto the named theme without redefining the whole struct.
Custom theme via JSON
prism plot bar.json --theme=./brand.theme.json > bar.svg
Theme JSON shape:
{
"name": "brand",
"extends": "light",
"overrides": {
"axis_color": "#0f172a",
"font_sans": "Source Sans 3, sans-serif",
"color_scheme_categorical": ["#0ea5e9", "#a855f7", "#22c55e", "#f43f5e"]
}
}
CSS variables emitted
Every SVG (and live web component shadow root) carries a <style>
block with these variables:
--prism-color-axis --prism-color-grid --prism-color-text
--prism-color-bg --prism-color-selected
--prism-font-sans --prism-font-mono
--prism-font-size-label --prism-font-size-title --prism-font-size-axis-title
Override at runtime via DOM style assignment to live-switch themes without re-render.
Worked examples
Multi-source
Composing N Pulse queries into one chart is a first-class workflow.
Datasets block
{
"datasets": {
"current": {"source": "cohorts/q1.pulse"},
"prior": {"source": "cohorts/q4_2025.pulse"},
"bench": {"source": "benchmarks/industry.pulse"}
},
"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:
| Op | Null policy |
|---|---|
count | count(*) counts every row; count(field) skips nulls. |
sum, mean, min, max, median, q1, q3, stdev, variance, ci0, ci1 | Skip nulls. |
distinct, mode | Skip nulls. |
wmean, ratio, lift, share | Skip nulls. |
filter predicates | Rows where any input is null evaluate to false (matches pandas / Vega-Lite). |
calculate expressions | Any 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": "cohorts/brand_q1.pulse",
"prior": "cohorts/brand_q4.pulse"
}
}
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. Server-side cache deduplicates fetches across requests.
Browser-side dataset registry
<prism-dataset name="current" src="cohorts/brand_q1.pulse"></prism-dataset>
<prism-dataset name="prior" src="cohorts/brand_q4.pulse"></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.
| Variant | Discriminator key | Use when |
|---|---|---|
data: {source: "…"} | source | Static Pulse path / archive shard |
data: {name: "…"} | name | Datasets-block alias |
data: {ref: "…"} | ref | Caller-resolved opaque identifier |
data: {values: […]} | values | Inline literal rows |
data: {feature_collection: {…}} | feature_collection | Geodata basemap |
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
Six passes run to fixpoint after build:
DedupSources— two reads of the same.pulsecollapse to one.FilterPushdown— filters on joined output push to the side that owns the referenced columns.ProjectionPruning— only request columns layered/encoded downstream.AggregateFusion— sibling group-aggregates on the same input merge into one call.PulseChainFusion— a source-rooted linear chain (Filter/Calculate/GroupAggregate/Sort, in that order) collapses into a singlepulse.ProcessChaincall. Pulse pushes filters down at the source reader and returns only the final aggregated rows, so Prism never materialises the full cohort into atable.Table. The pass requires aGroupAggregate(the win condition) and skips chains rooted atcohort:<id>orgs://refs in v1. Aggregate aliases that are not Pulse-backed (lift,share), not scalar-emitting (mode), or sibling-dependent (wmean,ratio,ci0,ci1) keep the in-memory backend path. If Pulse rejects a stage at execute time the chain node surfacesPRISM_PLAN_CHAIN_NOT_MERGEABLE.SampleInjection— input rows >PRISM_RENDER_MAX_MARKS(100k default) → auto-sample withPRISM_WARN_DOWNSAMPLE.
Worked examples
- actual_vs_benchmark — two Pulse sources, hash join, overlay.
- multi_source_join — N-way join.
- layer_actual_vs_benchmark — two-layer composition.
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 an embedded geodata catalog — no external tile
server, no runtime network dependency for the host CLI.
Marks
| Mark | Channels | Purpose |
|---|---|---|
geoshape | feature (+ optional color) | Country / admin-1 polygon (choropleth). |
geopoint | longitude, 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": "parent == \"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": {"source": "country_metrics.pulse"},
"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
| Type | Use case |
|---|---|
mercator | Classic web map default. Distorts area near poles; clips above ±85°. |
equirectangular | Plate carrée. Linear lat/lon → x/y. Useful for heatmaps over geographic grids. |
naturalearth | Tom Patterson’s compromise projection. Smooth global view, low distortion. |
albers_usa | Composite Albers covering CONUS + Alaska + Hawaii in inset panels. |
orthographic | Globe 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:
| Tier | Coverage | Approx. on-disk size |
|---|---|---|
world-110m | Countries (admin-0) at 1:110m. Default. | ~200 KB gz |
world-50m | Countries (admin-0) at 1:50m. Smoother coastlines. | ~600 KB gz |
admin1-50m | States / 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): every tier is embedded in the binary
via //go:embed. Geoshape charts work out-of-the-box; no sideload
required.
WASM build (browser): only the manifest is embedded (~100 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 ./public/prism always emits the geodata
artifacts under <out>/geodata/ so the WASM runtime finds them.
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:
markisgeoshapeorgeopointbutprojectionis missing or declares an unknowntype.- A geoshape spec lacks
encoding.feature.field. - A geopoint spec lacks
encoding.longitude.fieldorencoding.latitude.field. projection.tieris 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) or embed missing (host).
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 (GOOS=js GOARCH=wasm)
├── wasm_exec.js # toolchain-pinned WASM loader (Go runtime)
├── 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
Total wire size at v1: ~12 MiB gzipped (prism.wasm) + ~17 KiB
(wasm_exec.js) + ~10 KiB (the four .mjs files). A static host
that serves the directory with Content-Type: application/wasm
gets streaming instantiation; everything else falls back to the
buffered WebAssembly.instantiate path automatically.
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 browser fetches any referenced .pulse files via the
fetch-backed afero.Fs, runs the full pipeline
in WASM, and mounts the resulting SVG. Inline data
(data: {values: [...]}) skips the fetch path entirely.
Server compile (opt-in)
Hosts that prefer to keep Pulse loading behind 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 data I/O + aggregation (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 Fs
Dataset references resolve through an afero.Fs adapter backed
by browser fetch. The first access to a .pulse URL issues a
GET and buffers the body in memory; subsequent opens reuse the
cached bytes for the page lifetime.
Inline data and short single-shard cohorts work out of the box.
Archive shard references (archive.pulse#shard.pulse) work when
the origin serves the archive in one response — random-access
range reads are a v2 enhancement (see
BACKLOG.md).
Two error codes surface fetch problems:
PRISM_WASM_001— fetch failure (CORS, network, non-2xx).PRISM_WASM_002— origin server rejectsRange:requests (only matters once range support lands).
Both arrive in the JS bridge as standard {ok:false, error}
envelopes; prism.mjs rethrows them as Error instances 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:
| File | Responsibility |
|---|---|
prism.mjs | Load WASM, marshal JSON, mount SVG, expose SceneHandle |
prism-element.mjs | <prism-chart> / <prism-dataset> / <prism-coordinator> custom elements |
prism-resolver.mjs | Page-level dataset registry; dedupes fetches across charts |
prism-selection.mjs | Pointer-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). SVG and PDF
renderers ignore 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):
- The new scene is rendered through the WASM module into a detached
SVG; its
visibilityis set tohiddenso the user keeps seeing the live (previous) SVG. PrismAnimatorindexes both SVGs bydata-prism-mark-keyand partitions marks into enter / update / exit sets.- A
requestAnimationFrameloop 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 viaoklab.mjsfor perceptually smooth transitions. - At
t = 1the previous SVG is removed and the staged SVG becomes visible. The exit set fades toopacity=0along the way.
Fallbacks
The animator skips and snaps to the new scene when any of the following hold:
prefers-reduced-motion: reduceis 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).
SceneHandledispatches aprism:warnCustomEvent 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
animateoption is explicitlyfalse(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 galleryindex.htmldemonstrate 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 Go-compiled WASM module. Drift signals a non-deterministic
stage or a Go toolchain regression — not a JS port mistake.
Run locally:
make build-wasm
PRISM_CROSS_IMPL=1 go test ./internal/devtools/
The runner needs node on PATH; no npm install is required.
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 loads prism.wasm, 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.
Cookbook: multi-source join
Compare two cohorts side-by-side via hash join.
Spec
{
"$schema": "urn:prism:schema:v1:spec",
"datasets": {
"current": {"source": "cohorts/q1.pulse"},
"prior": {"source": "cohorts/q4_2025.pulse"}
},
"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": "current_score - 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
AggregateFusionpass would collapse the two group-aggregates if they shared an input; here they’re on different sources so both run in parallel. parallel.PRISM_QUERY_WORKERS(defaults toNumCPU) controls the worker pool — both Pulse opens run concurrently.
Cookbook: faceting by data values
Render one mini-chart per partition of a categorical field.
Spec
{
"$schema": "urn:prism:schema:v1:spec",
"data": {"source": "cohorts/q1.pulse"},
"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. Useindependentfor per-cell domains.- Nested facets work (facet within facet within facet) — the inner
specfield 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 intheme/and emit identical CSS variable manifests for the SVG + browser ports. - Browser theme switching is one DOM attribute away:
No re-render needed; CSS variables swap.document.querySelector("prism-chart").setAttribute("theme", "dark");
Cookbook: MCP agent integration
Expose Prism to an LLM agent so it can plot, validate, and describe specs as tool calls.
Start the MCP server
prism mcp
Reads JSON-RPC frames on stdin, writes responses on stdout. Standard MCP stdio transport.
Tools exposed
| Tool | Args | Returns |
|---|---|---|
prism_plot | {spec, format?} | {bytes (base64), mime, caption} |
prism_validate | {spec} | {ok, errors} |
prism_describe | {spec} | {summary} |
prism_examples_search | {query} | {examples: [{name, summary, spec}]} |
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 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+ to produce
prism.wasmand host CLI. - A static file server (anything that serves
Content-Type: application/wasmcorrectly — 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
./bin/prism static-bundle --wasm ./public/prism
That writes:
public/prism/
├── index.html # minimal loader example
├── prism.wasm # ~12 MiB gzipped
├── wasm_exec.js # Go toolchain runtime
├── 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:
<prism-dataset name="current" src="/data/q1.pulse"></prism-dataset>
<prism-dataset name="bench" src="/data/industry.pulse"></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 origin differs from the page
origin, the
.pulsehost must returnAccess-Control-Allow- Originmatching the page. Errors surface asPRISM_WASM_001. - Theme switching: set
theme="dark"on<prism-chart>— the browser re-runsexecuteSpecwith the new theme. Fast because the WASM instance + dataset cache stay warm.
Limits
- Initial WASM download is ~12 MiB gzipped. Cache aggressively (immutable hashed filename + 1-year Cache-Control).
- Large
.pulsefiles (>50 MB) decode slowly in the browser on mid-range hardware. Pre-aggregate at build time when the chart’s audience is mobile. - No PDF renderer in the browser.
prism plot --format pdfis host-only; serve pre-rendered PDFs as static files if the page needs them.
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.pulse", "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
- Highlight-on-brush — wires a selection to conditional encoding on the same chart.
- Selection point bar fixture — minimal spec that emits point selection events.
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
| Condition | Code |
|---|---|
| No resolver installed | PRISM_RESOLVE_REF_UNRESOLVED |
Resolver returned null / ErrDataRefUnresolved | PRISM_RESOLVE_REF_UNRESOLVED |
| Resolver returned undecodable JSON (WASM) | PRISM_RESOLVE_REF_UNRESOLVED |
| Async/Promise callback | Surfaces 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:
| Key | Meaning |
|---|---|
OpIndex | Zero-based index of the failing op in the patch array |
Op | The op name (add / replace / …) |
Path | The 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.
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, andprintthemes 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+Tabindent;Ctrl/⌘+Sformats the spec;Ctrl/⌘+Enterforces an immediate render.
What it doesn’t do (yet)
- Pulse-backed datasets. All examples use inline
valuesarrays. Pointing the playground at a.pulseURL needs CORS setup that the docs site doesn’t ship, so the curated examples stay inline. Useprism plotorprism servelocally to feed Pulse archives. - 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
70 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
| Spec | Preview |
|---|---|
| bar_basic | |
| line_basic | |
| area_basic | |
| area_with_negatives | |
| point_scatter | |
| rule_basic | |
| text_basic | |
| tick_strip | |
| arc_basic | |
| rect_heatmap_lite | |
| multi_series_line |
Composite marks
| Spec | Preview |
|---|---|
| histogram | |
| histogram_long_tail | |
| heatmap | |
| boxplot | |
| violin_score | |
| pie | |
| donut | |
| donut_traffic |
Specialty marks
| Spec | Preview |
|---|---|
| sankey_user_flow | |
| funnel_signup | |
| sparkline_inline | |
| sparkline_inline_grid | |
| image_logo | |
| path_arbitrary |
Geographic marks
| Spec | Preview |
|---|---|
| world_basic | |
| world_choropleth | |
| usa_states |
Composition (layer / concat / facet / repeat)
Multi-source
| Spec | Preview |
|---|---|
| actual_vs_benchmark | |
| multi_source_join | |
| bar_pulse_backed |
Scales
Selections
| Spec | Preview |
|---|---|
| selection_point | |
| selection_interval | |
| selection_point_bar | |
| selection_interval_brush | |
| selection_cross_chart_overview | |
| selection_cross_chart_detail |
Conditions
Per-channel condition clauses switch a channel’s value based on a
selection or Pulse expression. See
Encoding › Conditions.
| Spec | Preview |
|---|---|
| brush_highlight | |
| test_predicate |
Tree
Rooted hierarchies laid out with tidy-tree. See Marks › Tree.
| Spec | Preview |
|---|---|
| org_chart | |
| decision_tree |
Network
Force-directed node-link diagrams with deterministic seeded layouts. See Marks › Tree / dendrogram / network.
| Spec | Preview |
|---|---|
| citation_network | |
| dependency_graph |
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.