Docs · Chart spec

One JSON object in, a living chart out

Everything the engine draws is described by one spec. This page is the full reference — the same contract whether the spec arrives through a maker, an embed, an MCP tool call, or the render API.

{
  "type": "column",
  "theme": "editorial",
  "title": "Weekly signups",
  "source": "Product analytics",
  "data": {
    "labels": [
      "Mon",
      "Tue",
      "Wed",
      "Thu",
      "Fri"
    ],
    "series": [
      {
        "name": "Signups",
        "values": [
          12,
          19,
          14,
          27,
          22
        ]
      }
    ]
  }
}

That chart is this exact spec, rendered live.


The shape

Two required fields — type and data — and sensible defaults for the rest. Most types use labels and series. The status matrix takes data.columns and data.cells, the live table data.columns and data.rows; hierarchy, rank, stream, distribution and flow types use the formats described below. Unknown chart types, missing required data and non-numeric values are rejected with structured errors — nothing fails silently. For labels-and-series data, every series carries exactly one value per label: a series with the wrong count is refused, never padded with zeros or trimmed. A value written as null is a gap — a point with no data — drawn as a break in a line, no bar or dot, "No data" in the tooltip and the hidden table (not allowed on number and dotplot).

Field Required What it is
typeyesOne of the twenty-five types below
themeno (defaults to editorial)editorial · glass · ink · mint · neon (unknown names fall back to editorial)
data.labelsno (defaults to 1, 2, 3…)Category names, one per position
data.seriesyesOne or more { "name"?, "values": (number | null)[] } rows — one value per label; null is a gap
title, sourcenoChrome above and below the chart
options.variantno"light" · "dark" · "auto" — "auto" follows the host page's own dark-mode toggle (a data-theme or a dark class on html/body, or color-scheme), then the OS, and switches live; see Themes and variants below
options.targetnoA dashed reference line across cartesian types
options.precisionnoDecimals (0–4) for value labels, the tooltip and the big number; omitted = the decimals in the data
options.formatnoValue formatting: "percent", "currency:XXX" or affix:["before","after"]
options.notesnoNotes: { "label", "text", "kind"? } per noted label — a ring (ⓘ; ! for "warn") on bar, column, lollipop, line, area, scatter, the donut legend and the big number; hover or tap the ring for the full text, click to keep it, Escape to release; the text also sits in the accessible data table
options.legendnoMatrix only: human labels for the status vocabulary
data.columns, data.cellsmatrix onlyFeature columns and one status row per label
data.columns, data.rowstable onlyTyped columns (text · number · status · note) and one array of cells per row
options.sort, options.stickyno (table only)Initial order { column, dir }; pinned header and first column
options.mobileno (table only)Under 480 px of container width: "scroll" (default — a compact table that scrolls sideways under a sticky first column) or "cards" (one card per row)
options.fontno (table only)"inherit" sets the table in the host page's own font instead of the theme's — for a table dropped into an article. A table also takes its own height unless the page gives it one.
options.height, options.searchno (table only)height (px, 120–4000) caps a long table: the rows scroll inside it under the sticky header. search: true adds a box above the rows that narrows them by any text they show — label, cells, status labels, notes — together with the status chips; Reset clears both.
options.pageSizeno (table only)Rows per page, 5–1000: the table shows that many at a time with Previous / Next under the rows, an honest "Rows 11–20 of 63" line and a one-click Show all. Chips, search and sort narrow and order the rows first, then the pages follow; "Chart this column" draws the rows on view across every page. A table with no more rows than the page size shows no pager.

Negative values are rejected for every type except number — bars and areas measured from a zero baseline cannot show them honestly, and a structured error beats a misleading picture. A negative target is rejected everywhere.


Sankey flows

data.links contains source, target and value. Nodes are inferred from the links. Optional data.nodes supplies IDs, labels and input order. Weights are positive or null; duplicate links sum. Cycles and self-links return errors with row paths.

{
  "type": "sankey",
  "data": {
    "links": [
      {
        "source": "Income",
        "target": "Budget",
        "value": 120
      },
      {
        "source": "Budget",
        "target": "Spending",
        "value": 100
      }
    ]
  }
}

Node height uses the larger of inflow and outflow. The tooltip reports any difference without inventing links. Use up to 50 nodes, 100 links and eight stages. Group small flows into Other when you reach the cap.

options.flowColor: source (default) or stage. options.flowOrder: minimize (default) or input. options.showValues: true by default. Names remain when values are hidden. The diagram scrolls inside its box on narrow screens, with a collapsed Links table below. Open Sankey maker.

Ridgeline: grouped observations

Use data.groups with id, label and observations for each group. Values may be negative; null means missing. Keep up to 12 groups and 2,000 observations locally. Hosted rendering retains its plan's existing point cap.

{
  "type": "ridgeline",
  "data": {
    "groups": [
      {
        "id": "monday",
        "label": "Monday",
        "observations": [
          12,
          15,
          16,
          19,
          24
        ]
      }
    ]
  },
  "options": {
    "bandwidth": 2
  }
}

Gaussian kernel density estimates use one Silverman bandwidth from within-group deviations by default. Set a positive options.bandwidth to override it. The shared X range is the data minimum and maximum plus four bandwidths on each side. Fewer than five known observations, or constant readings, show dots. Empty groups say No data. Null observations stay in the table and are excluded from density.

Heights share a density scale. Set options.ridgeScale: "own" to scale each group to its own peak; the chart prints a notice. options.sortByMedian: true sorts groups by median, with empty groups last. Hover, pin and arrow keys expose n, median and the middle 50%. Open Ridgeline maker.

Streamgraph: layers of a changing flow

Use ordinary period labels and series, as for Line or Stacked columns. Streamgraph accepts 1–6 layers and 2–100 periods. Every value must be finite and non-negative. Null is an error; use Line to show gaps.

The silhouette baseline is minus half of each period’s total. Layers accumulate in input order. Smooth curves interpolate non-negative layer thicknesses and keep every seam shared; values stay exact at each period. Set options.curve: "linear" for straight boundaries. There is no numeric Y axis. Hover or use arrow keys for exact values and period shares. Layer names appear where they fit, with a legend as fallback.

Legend switches are disabled because hidden layers would leave misleading gaps. Value edits tween bands; switching to Line or Stacked columns crossfades. Open Streamgraph maker .

Bump: ranks across periods

Use data.labels for periods and one named series per entity. Set options.rankMode explicitly: values computes competition ranks (1, 2, 2, 4); ranks accepts positive integer positions as supplied. First place is at the top. A null observation breaks the line.

In Values mode, rankDirection: "descending" ranks larger values first (default); "ascending" ranks smaller values first. showRanks defaults to false; set it to true to show point ranks. Optional topN (1–12) limits visible positions without inventing ranks when an entity leaves or returns. Values must be finite and non-negative. Use 1–12 uniquely named entities and 3–24 uniquely named periods, with names up to 120 characters. Errors identify the field and correction.

{
  "type": "bump",
  "data": {
    "labels": [
      "Jan",
      "Feb",
      "Mar"
    ],
    "series": [
      {
        "name": "Atlas",
        "values": [
          40,
          30,
          45
        ]
      },
      {
        "name": "Birch",
        "values": [
          35,
          42,
          45
        ]
      }
    ]
  },
  "options": {
    "rankMode": "values",
    "rankDirection": "descending"
  }
}

Hover a line to highlight its entity. Left and right move across periods; up and down move through places in a period. Click or Enter pins a point; Shift adds a comparison. The accessible table retains every period, rank and source value. PNG, SVG, render-api and live embeds use the same scene. Choose Bump chart maker to edit the data. Line shows value gaps; Slope suits two periods.

Treemap and Sunburst: groups and items

Treemap and Sunburst read data.nodes. Groups have an ID and label; items name their group with parentId and supply a positive finite value or null for No data. Group totals are calculated from the items. Use up to 6 groups and 36 items in two levels. IDs must be unique. Missing parents, cycles, deeper nesting, empty groups, zero and negative values return an error with the node path and a correction.

{
  "type": "treemap",
  "title": "City budget",
  "data": {
    "nodes": [
      {
        "id": "spaces",
        "label": "Public spaces"
      },
      {
        "id": "parks",
        "parentId": "spaces",
        "label": "Parks",
        "value": 32
      },
      {
        "id": "libraries",
        "parentId": "spaces",
        "label": "Libraries",
        "value": 18
      }
    ]
  },
  "options": {
    "showValues": true,
    "precision": 0,
    "variant": "auto"
  }
}

Open a group with click, tap or Enter. All, Escape or Backspace returns to the whole chart. This view state is not saved in the spec. Missing and subpixel items remain in the data table and keyboard tooltips. Use options.showValues: false to hide tile values; names stay visible when they fit. Format, precision, theme and variant follow the same rules as other charts. Sunburst uses the same nodes: change type to sunburst. Its inner ring shows groups and its outer ring shows items; the center shows their total. Labels appear only when they fit. Edit a treemap or make a sunburst.

The twenty-five types

Type Data it reads Live examples
sankey Directed links: source, target, positive value or null; 50 nodes, 100 links, 8 stages see it live
ridgeline 1–12 groups of raw observations; 2,000 readings total see it live
streamgraph 1–6 layers, 2–100 periods; non-negative values with no gaps see it live
column labels + 1..n series see it live
bar labels + 1..n series, horizontal see it live
line labels + 1..n series see it live
area labels + 1..n series, filled to zero see it live
stacked labels + 1..n series, summed per label see it live
donut labels + the FIRST series only see it live
scatter labels + 1..n series, one dot per value see it live
dotplot labels + counts, one drawn dot each (fractional counts are floored) see it live
number the first value of the first series; signed see it live
lollipop labels + 1..3 series — stems and dots; 2-3 series drop the stems (Cleveland dots) see it live
dumbbell labels + exactly 2 series — two dots per row joined by a link, the change labeled see it live
range labels + exactly 2 series (low, high; high ≥ low) — one band per row see it live
waterfall labels + 1 series of steps ≥ 0 — floating columns from the running total; options.total closes it see it live
bullet labels + actuals (+ per-row targets as series 2) over options.bands see it live
slope exactly 2 labels (then, now) + 1..n series — one straight line per series, named at both ends see it live
heatmap labels (columns) + series (rows) — every cell shaded by its value; options.scale pins the range see it live
waffle labels + the FIRST series only — parts of a whole as options.units squares (default 100) see it live
matrix labels (rows) + columns + cells of yes | no | partial | unknown | na see it live
sunburst the same two-level nodes as treemap, drawn as rings see it live
bump 3–24 periods, 1–12 named entities; explicit values or ranks mode see it live
treemap two levels of nodes: groups and positive-valued items (or null) see it live
table typed columns + rows of text, numbers, statuses and notes — rendered as a live DOM table, not a canvas see it live

Changing type on a mounted chart does not redraw it — the engine morphs the existing shapes into the new ones. That is the whole point of the project.

For column with one series, options.histogram: true draws square bins in one palette color, touching with a 1px gap; omit it for ordinary columns.

{ "type": "column", "options": { "histogram": true },
  "data": { "labels": ["0–10", "10–20", "20–30"], "series": [{ "values": [9, 27, 38] }] } }

Themes and variants

Five themes, each in a light and a dark variant — ten looks in total. options.variant picks one: "light" and "dark" pin it, and "auto" follows the host page first — a data-theme attribute or a dark class on html or body, or its color-scheme — then the visitor's OS, and switches live when either changes. Omitting the field does neither — each theme then shows its own canonical side (editorial, glass and mint are light by nature; ink and neon are dark). A page that resolves its theme itself can still pass "light" or "dark" explicitly.


Annotations

Honesty as a feature: options.notes pins a caveat to a label — a cut-off date, a known-part-only total, an unverified claim. Each noted label grows a small ring on bar, column, lollipop, line, area, donut, scatter and number charts (an i glyph; ! for "warn" — a "partial" note keeps the warn tint); the matrix marks the row's cells with a corner dot. Hover or tap the ring to read the full text, click to keep it open; it also sits in the hidden accessible table, so the fine print travels with the chart instead of dying in a footnote.

"options": {
  "notes": [
    { "label": "Vanilla", "text": "Updates stop from 1 January 2025", "kind": "warn" },
    { "label": "Circle", "text": "Known part only; processing is extra", "kind": "partial" }
  ]
}

The status matrix

matrix reads cells instead of series: rows come from data.labels, features from data.columns, and every cell is a status from the fixed vocabulary yes | no | partial | unknown | na — a string, or { "status", "note" } to attach fine print to one cell. Statuses draw as shapes (check, cross, half-disc, question mark, dash), never as color alone, and options.legend renames them for the legend, tooltip and accessible table. Unknown and not-applicable are distinct states on purpose — the chart never dresses a missing fact as a no.

{
  "type": "matrix",
  "data": {
    "labels": ["Discourse", "Flarum"],
    "columns": ["Licence fee", "Server", "Updates"],
    "cells": [
      ["yes", "no", "yes"],
      ["yes", "no", { "status": "partial", "note": "Community builds only" }]
    ]
  },
  "options": { "legend": { "yes": "None to pay / included", "no": "On the team / paid" } }
}

The live table

table is the one type that is not a picture: <live-chart> mounts a real HTML table (loaded as a second small file only when a table is on the page). Columns carry a kind — text, number (with the usual format), status (the matrix vocabulary, drawn as a chip with a shape and an optional label) or note — and each cell is a string, a number, null, { "status", "label", "note" } or { "text", "kind", "note" } (note is optional). The table sorts on a header click, filters by status chips, draws a mini-bar behind every number, expands notes on click, and scrolls sideways below 480 px of width. Set options.mobile: "cards" to use cards instead. Its "Chart" button hands a numeric column — or a status column whose chips carry numbers — to the engine as a lollipop. There is no SVG export (a structured refusal says so); PNG renders through the local MCP (Pro).

{
  "type": "table",
  "title": "What you are buying",
  "data": {
    "columns": [
      { "name": "Product" },
      { "name": "Licence cost", "kind": "status" },
      { "name": "Monthly", "kind": "number", "format": "currency:USD" },
      { "name": "What still lands on the team", "kind": "note" }
    ],
    "rows": [
      ["Discourse", { "status": "yes", "label": "None" }, 100, "Server and an SMTP mail service"],
      ["Vanilla", "yes", null, { "text": "Updates stop from 1 January 2025", "kind": "warn" }]
    ]
  },
  "options": { "sort": { "column": 2, "dir": "desc" }, "sticky": true }
}

In a text or note column, { "text": "Limited claim", "kind": "warn", "note": "Full explanation" } keeps the claim visible and opens the explanation with the ! button. Use note, warn or partial for kind. The explanation is plain text, up to 4000 Unicode characters; noteText and detail return validation errors. Without note, text remains visible and the glyph explains its meaning. A status cell's existing + note still works. Paste the complete table JSON into the table maker to preserve typed cells and layout; Download JSON saves them without the share-link size limit.


Target lines

A goal, a limit, an SLA: options.target draws a dashed line at a value across column, bar, line, area, stacked, scatter and dotplot charts, with an optional label. The scale always stretches to include it.

"options": {
  "target": { "value": 120, "label": "Goal" }
}

Lines and parts

options.curve shapes a line or area between its points: "smooth" (the default), "linear" for straight segments, "step" for stairs — every value a plateau centered on its label, the riser midway between labels (see step charts live). A slope takes exactly two labels, the two states, and names every series at both ends with its exact value; a null on either side gaps that line and reads "—". A heatmap uses the ordinary data shape — labels are the columns, series are the rows — and shades each cell from the theme background to its first palette color; options.scale { "min", "max" } pins that range (values outside clamp), a null cell stays flat and unlabeled. A waffle lays the first series out as options.units squares (1–400, default 100) by largest remainders, so the squares always sum to the units; its legend is the donut's, and hovering any square lights the whole part.

{ "type": "line",    "options": { "curve": "step" } }
{ "type": "slope",   "data": { "labels": ["2024", "2025"], "series": [{ "name": "Search", "values": [31, 44] }] } }
{ "type": "heatmap", "options": { "scale": { "min": 0, "max": 50 } },
  "data": { "labels": ["Mon", "Tue"], "series": [{ "name": "North", "values": [12, 30] }, { "name": "South", "values": [8, null] }] } }
{ "type": "waffle",  "options": { "units": 100 }, "data": { "labels": ["Yes", "No"], "series": [{ "values": [73, 27] }] } }

Number formats

options.format runs through every surface at once — axis ticks, value labels, stacked totals, the big number, tooltips. "percent" adds the sign; "currency:XXX" prefixes $, € or £ for USD, EUR and GBP and falls back to the code itself for anything else (CAD 1,234.5). A unit of your own is affix:["before","after"]: affix:[""," lb"] reads 1,234.5 lb, affix:["~",""] reads ~1,234.5 — up to 12 characters a side, a space included, and the values are never converted. Numbers a reader reads are exact: value labels, tooltips, the big number and table cells keep the decimals the data carries (99.98% reads 99.98%, $201.5 reads $201.50), group thousands (12,345) and never shorten; only axis ticks stay compact ($48k). options.precision (0–4) sets the decimals by hand. Two deliberate exceptions stay plain: a donut slice's share (already a percentage) and a dot plot's counts (neither money nor a share).

"options": { "format": "percent" }        // 99.98 -> 99.98% on the bar, 100% on the axis
"options": { "format": "currency:USD" }   // 48200 -> $48,200 on the bar, $48k on the axis
"options": { "precision": 2 }             // 5 -> 5.00 everywhere a reader reads it

Errors are structured

Parsing never throws and never guesses. A bad spec comes back as a list of {path, message, hint} objects pointing at the exact field — the same shape in the browser, over MCP and from the render API, designed to be read by people and agents alike.

{
  "ok": false,
  "errors": [{
    "path": "data.series[0].values[2]",
    "message": "Value \"abc\" is not a finite number",
    "hint": "Use numeric values, e.g. 42 or 3.14"
  }]
}