Skip to main content

Chart spec

Every chart in this package is described by one JSON object — the ChartSpec. Tools build it from their simplified inputs; the headless renderer, the browser widget and the code generator all consume it.

tool input ──(recipe)──► ChartSpec ──┬──► headless renderer ──► SVG / PNG
├──► browser widget ──► HTML / inline widget
└──► code generator ──► framework source

Because the spec is plain JSON, you can obtain one with outputType: "config", edit it, and render it directly — which is how you reach Unovis options the tool schemas deliberately don't expose.

Shape

interface ChartSpec {
specVersion?: string // contract version, e.g. "0.1" (see below)
container: 'xy' | 'single'
width: number
height: number
theme: 'light' | 'dark'
title?: string
containerConfig?: Record<string, unknown> // extra container options
components: { type: string; config: Record<string, unknown> }[]
xAxis?: Record<string, unknown> // XY containers only
yAxis?: Record<string, unknown>
colors?: string[]
locale?: string // BCP-47, for date/number formatting
legend?: { name: string; color?: string; paletteIndex?: number }[]
data: unknown
}
  • container: 'xy' holds any number of XY components (line, area, bars, scatter, timeline, boxplot, plus Plotband/Plotline decorations) on shared, domain-synchronized scales, plus optional axes.
  • container: 'single' holds exactly one component (donut, sankey, heatmap, treemap, chord, graph, map, …).
  • components renders in array order, so later entries draw on top.

Validating hand-built specs

The contract ships as JSON Schema — validate before rendering instead of discovering a typo as a blank chart or a runtime error:

import Ajv from 'ajv'
import schema from '@unovis/mcp/chart-spec.schema.json'

const validate = new Ajv({ strict: false }).compile(schema)
if (!validate(spec)) throw new Error(JSON.stringify(validate.errors))

Top-level fields and accessor descriptors are validated strictly (unknown component types and misspelled top-level keys are refused); config and containerConfig stay open by design — they're the escape hatch to Unovis options the tools don't expose.

Versioning

The spec is a persistence format — apps store specs and commit generated embed documents — so it carries a version: SPEC_VERSION (importable from @unovis/mcp/spec), currently 0.1, stamped into every spec the recipes produce as major.minor.

The 0 major is deliberate: the contract is still settling, and while the major is 0, breaking changes are allowed and bump the minor (0.1 → 0.2). Additions never require a bump. Declaring 1.0 will be the deliberate act of adopting the additive-only promise — from then on, only the major breaks. A renderer or widget given a newer spec refuses with an explicit error instead of drawing a wrong or blank chart, and the widget reports { version, specVersion } in its unovis:ready handshake so hosts can assert compatibility up front. Specs without specVersion are treated as current.

A version bump is always a deliberate act, never an accident — the published schema is guarded against silent contract changes. It also resolves at its $id: https://unovis.dev/schema/chart-spec.v0.1.json.

A complete example

{
"container": "xy",
"width": 800,
"height": 480,
"theme": "light",
"title": "Revenue vs Target",
"components": [
{
"type": "GroupedBar",
"config": {
"x": { "$index": true },
"y": { "$field": "revenue", "as": "number" },
"roundedCorners": 3
}
},
{
"type": "Line",
"config": {
"x": { "$index": true },
"y": { "$field": "target", "as": "number" },
"lineWidth": 3,
"color": "var(--vis-color1)"
}
}
],
"xAxis": { "tickFormat": { "$lookup": ["Jan", "Feb", "Mar"] }, "tickValues": [0, 1, 2] },
"yAxis": { "gridLine": true, "tickFormat": { "$numTickFormat": true } },
"legend": [
{ "name": "Revenue", "paletteIndex": 0 },
{ "name": "Target", "paletteIndex": 1 }
],
"data": [
{ "revenue": 420, "target": 430 },
{ "revenue": 510, "target": 460 },
{ "revenue": 470, "target": 490 }
]
}

That spec is a bar + line combo chart — something no single tool emits today, but which the spec layer has always supported.

Accessor references

Unovis accessors are functions, which JSON can't hold, so the spec uses descriptors that the renderer converts into real functions. Nothing in a spec is ever evaluated as code — field names are looked up as properties, and that's the whole mechanism.

DescriptorBecomesUse
{ "$field": "sales" }d => d.salesRead a field
{ "$field": "sales", "as": "number" }numeric coercionNumeric fields arriving as strings
{ "$field": "date", "as": "date" }epoch millisecondsTime axes
{ "$index": true }(d, i) => iCategorical x positions
{ "$const": 5 }() => 5Constant value
{ "$lookup": ["Jan", "Feb"] }index → labelCategory tick labels
{ "$numTickFormat": true }thousands separatorsNumeric axis labels
{ "$dateTickFormat": true }short date labelsTime axis labels
{ "$format": { "field": "value", "prefix": "$", "suffix": " USD" } }formatted stringSankey sub-labels, etc.
{ "$mapField": { "field": "group", "mapping": { "a": "#4D8CFD" }, "fallback": "#ccc" } }category → valueColor by category
{ "$unovisMap": "WorldMapTopoJSON" }the topojson payloadMap charts
{ "$mapProjection": "AlbersUsa" }MapProjection.AlbersUsa()Map projections

Descriptors can appear anywhere in a component config, including nested inside objects and arrays. y: [{ "$field": "a" }, { "$field": "b" }] is a two-series chart.

Component types

XY components (container: "xy"): Line, Area, GroupedBar, StackedBar, Scatter, Timeline, Boxplot, XYLabels, Plotband, Plotline.

Single components (container: "single"): Donut, NestedDonut, RadialBar, Sankey, Heatmap, Treemap, ChordDiagram, Graph, TopoJSONMap.

Any option from the corresponding Unovis config passes straight through, as long as it's JSON-serializable or expressible as a descriptor. Interaction options (events, tooltips, brushes) are meaningless for static output and are ignored; the interactive widget adds its own.

Rendering a spec yourself

import { renderChart } from '@unovis/mcp'

const { svg, width, height, warnings } = await renderChart(spec)

Charts always render with duration: 0 so output is deterministic, and the renderer waits for the container's onRenderComplete — plus the component's own completion signal for asynchronous layouts like force-directed graphs.

Deliberate limits

  • Functions can't be smuggled in. If you need a bespoke callback, use the library directly with renderToSvg.
  • One coordinate system per chart. Components in an XY container share synchronized domains, so mixing wildly different units (revenue and percentages) will flatten one of them. True dual axes aren't supported yet.
  • Graph layouts are limited to force, circular and concentric in the tools; dagre and elk work through a hand-written spec in Node but not in the browser widget (see Troubleshooting).