diff --git a/.changeset/public-chart-json.md b/.changeset/public-chart-json.md
new file mode 100644
index 00000000..417eeb24
--- /dev/null
+++ b/.changeset/public-chart-json.md
@@ -0,0 +1,6 @@
+---
+'@tanstack/charts': minor
+---
+
+Add the fixed first-party Chart JSON interchange format, its `chartFromJson`
+interpreter, SemVer compatibility, published schema, and editable workbench.
diff --git a/API-FRICTION.md b/API-FRICTION.md
index 51c8c1f7..edf08de0 100644
--- a/API-FRICTION.md
+++ b/API-FRICTION.md
@@ -329,6 +329,7 @@ Each entry records:
| F-290 | Public examples imported a private workspace package | Tooling | resolved |
| F-291 | Renderer capability injection depended on module identity | API/Tooling | resolved |
| F-292 | Fixed preview paints ignored the selected site theme | Tooling | resolved |
+| F-293 | Portable calls did not match live constructor semantics | API/Docs/Tooling | resolved |
## Findings
@@ -3557,8 +3558,10 @@ Each entry records:
deterministic SHA-256 allowlist, safe repository source paths, recursive
imports, debug-only comparison roots, and role-aware authored-source
closures. Focused tests reject unsafe paths, unreferenced assets, public
- comparison modules, and inconsistent source totals, roles, or paths. The
- loading gate checks every TanStack root's static closure for reference cases
+ comparison modules, and inconsistent source totals, roles, or paths.
+ Imported authored JSON participates in the same closure while `case.json`
+ metadata stays excluded. The loading gate checks every TanStack root's
+ static closure for reference cases
or competitor packages and proves raw source remains lazy and unpublished.
Main-branch CI uploads the validated artifact and publishes only
`catalog.json` and `assets/*.js` to `catalog-dist`. The publication workflow
@@ -8429,3 +8432,75 @@ Each entry records:
- Follow-up verification: the generator contract covers all four tokens in both
palettes, and regenerated ShadCN donut, radial, radar, and authored-label
previews render readable dark text and theme-matched separators.
+
+### F-293 — Portable calls did not match live constructor semantics
+
+- Status: resolved
+- Severity: high
+- Owner: API/Documentation/Tooling
+- Observed in: implementing the first-party JSON interchange format
+- Friction: the design note serialized public JavaScript calls directly. Its
+ scale examples invoked factories with unsupported option objects, turned an
+ inferable scale factory into a configured empty instance, placed tick
+ formatting on a removed property, and proposed a separate adapter `data`
+ prop after definition identity had become the application update boundary.
+ Callback-bearing marks, transforms, and host extensions also had no stable
+ language-neutral meaning.
+- Breadth friction: implementing every callback-free Charts operation produced
+ 292 built-in capabilities, 18 JSON subpaths, 8,041 lines of production JSON
+ source, 2,715 test lines, and 1,106 lines of canonical documentation. The
+ result was a second complete Charts API rather than a bounded interchange
+ profile. Runtime JSON Schema interpretation, separate inspection,
+ validation, and compilation traversals, two demo systems, and overlapping
+ manuals amplified that breadth.
+- Intermediate friction: reducing the catalog to 42 standard operations and
+ two DOM operations still left 4,863 production lines, 1,348 test lines,
+ three entrypoints, two envelopes, four expression forms, a capability author
+ API, a contract algebra, policy and inspection surfaces, and 473 lines of
+ canonical documentation. The platform remained much larger than the common
+ charts it needed to transport.
+- Final decision: ship one fixed first-party dialect rather than an extension
+ platform. Its 16 operations cover eight Cartesian marks, including
+ full-plot rules and positioned text annotations, one compound pie/donut
+ mark, four inferable scales, ISO-date access, grouped intervals, and a
+ categorical color legend. Publish only `@tanstack/charts/json`.
+ Specialized transforms, callbacks, tooltips, interactions, configured
+ scales, and other chart families remain host code until a demonstrated
+ interchange requirement justifies extending the dialect.
+- Wire decision: use one closed envelope with `chartsVersion`, `spec`, optional
+ `data`, optional accessible `metadata`, and optional `$schema`. `$data`
+ references a named dataset only in a mark's `data` field. `$call` invokes one
+ fixed operation with named arguments as sibling properties. Remove `kind`,
+ `inputs`, `options`, `$ref`, `$context`, and the nested `args` object. Host
+ datasets replace bundled defaults by name.
+- Runtime decision: `chartFromJson(jsonText, { data, exactVersion })` parses,
+ validates, translates, and returns an ordinary static definition in one
+ call. It throws `ChartJsonError` with stable issues. Remove registries,
+ capability constructors, policies, inspection, catalogs, executable schema
+ contracts, compile/bind programs, host generics, and custom compatibility
+ callbacks. The runtime and published schema share the same internal fixed
+ operation descriptors; the schema itself is the complete model contract.
+- Version decision: release synchronization keeps the dialect in lockstep with
+ `@tanstack/charts`; `0.15.0` is the immutable first published dialect.
+- Schema verification deep-compares the runtime `chartJsonSchema` with the sole
+ published `schemas/chart.json`, locks all 16 operation branches,
+ keeps the chart spec closed, and executes the sole bundled example through
+ scene creation. Release and packed-package gates require both assets.
+- Runtime verification covers direct-scene parity, bundled arrays, host
+ iterable overrides, metadata, exact and backward SemVer behavior, every
+ retained operation, role placement, unknown calls and arguments, obsolete
+ grammar, missing data, hostile result shapes, and direct DOM/native-compatible
+ static typing. Root TypeScript and the focused JSON suite pass.
+- Demo verification uses one editable React workbench with bar, donut, and
+ annotation sources. Apply and Reset preserve the last valid preview after
+ structured errors; Replace data reruns the same source with a host override.
+ Component tests, the production build, and desktop and 390-pixel browser
+ checks cover exact bar, arc, vertical-rule, horizontal-rule, and text-label
+ geometry, accessible naming, live status, invalid edits, overflow, and
+ console errors.
+- Reduction verification: production JSON source is 1,612 lines across eight
+ files, down 67% from the pared platform and 80% from the 8,041-line breadth
+ spike. Tests are 1,029 lines across two files, down 24% from the pared
+ platform and 62% from the breadth spike.
+ The public surface is four runtime values, six types, one entrypoint, one
+ schema, one fixture, one 233-line canonical page, and one demo.
diff --git a/MARKETING.md b/MARKETING.md
index b9433ae9..9ebbc5bc 100644
--- a/MARKETING.md
+++ b/MARKETING.md
@@ -199,7 +199,7 @@ registry or side-effectful feature installation.
| [Chart.js](https://www.chartjs.org/) and [Apache ECharts](https://echarts.apache.org/en/index.html) | Broad catalogs and Canvas-oriented rendering | Smaller in the current like-for-like consumer benchmark, SVG by default with opt-in Canvas, composable, and designed for product-specific charts |
| [uPlot](https://github.com/leeoniya/uPlot), [Chartist](https://chartist.dev/), [Frappe Charts](https://github.com/frappe/charts), and [Lightweight Charts](https://github.com/tradingview/lightweight-charts) | Deliberately small or specialized charting surfaces | Competitive in the small-bundle class with a broader grammar; TanStack should claim capability-scaled size, not absolute size leadership |
| [AG Charts](https://www.ag-grid.com/charts/) | Enterprise breadth, specialized charts, dense data, controls, and support | Open grammar, D3 interoperability, actual SVG SSR, heterogeneous layers, and no global registry |
-| [Vega and Vega-Lite](https://vega.github.io/) | Portable declarative specifications and analysis tooling | Ordinary TypeScript and application integration instead of a JSON visualization runtime |
+| [Vega and Vega-Lite](https://vega.github.io/) | Portable declarative specifications and analysis tooling | Typed application authoring plus a small opt-in JSON dialect for common Cartesian and circular charts that translates into an ordinary definition |
### Primary comparisons
@@ -450,8 +450,8 @@ describe the library as designed by AI or autonomously built.
This is a transparent account of how the implementation was produced, not the
main marketing wedge. Agent-ready, task-oriented documentation remains
supporting proof. The useful product claim is that agents generate and maintain
-ordinary TypeScript through the same public API as humans. Avoid generic claims
-about being "AI-native."
+ordinary TypeScript or first-party Chart JSON through the same public contracts
+as humans. Avoid generic claims about being "AI-native."
## Objections
@@ -472,8 +472,8 @@ TanStack Charts is not currently the best choice for teams that:
chart types immediately.
- Need proven million-point streaming throughput, built-in downsampling, or a
GPU rendering pipeline.
-- Want a no-code dashboard builder.
-- Need a portable JSON visualization specification.
+- Need a no-code dashboard, query, or refresh runtime beyond interpreting a
+ portable chart definition in a JavaScript host.
- Want a library to choose every scale and chart decision.
- Need commercial support, LTS, or contractual response times.
- Have three permanently standard React charts and value the shortest possible
diff --git a/NATIVE-PLATFORM-SUPPORT-SPIKE.md b/NATIVE-PLATFORM-SUPPORT-SPIKE.md
index a1ad4832..d9a53e7d 100644
--- a/NATIVE-PLATFORM-SUPPORT-SPIKE.md
+++ b/NATIVE-PLATFORM-SUPPORT-SPIKE.md
@@ -157,9 +157,9 @@ Do not freeze the current `ChartScene` as a cross-language wire format.
Compose, SwiftUI, and Flutter can consume this, but live callbacks stay in
their host language.
-The proposed [`PORTABLE-CHART-SPEC.md`](./PORTABLE-CHART-SPEC.md) registry
-model is the right definition-level companion. `$call` and `$data` can describe
-a supported portable subset. It should never claim to serialize arbitrary
+The first-party [Chart JSON interchange](./docs/reference/json-interchange.md)
+is the definition-level companion. Its fixed operations cover a small portable
+subset of Cartesian and circular charts. It does not serialize arbitrary
JavaScript accessors, custom tooltip components, or renderer plugins.
### Core refactor
diff --git a/PLAN.md b/PLAN.md
index 7eb9d881..53df8773 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -36,7 +36,11 @@ concerns that Plot intentionally leaves to its host:
- Optional export and motion
- Predictable, task-oriented documentation for humans and AI
-The code is the protocol. AI should generate and maintain ordinary TypeScript and JSX or TSRX rather than a JSON chart language, a proprietary DSL, or an agent-specific runtime.
+Typed code remains the primary authoring protocol. The first-party Chart JSON
+interchange format transports a fixed common-chart subset for AI and system
+boundaries; it must translate to an ordinary definition and must not become an
+extension platform, second renderer, data-reactivity model, global registry,
+or agent runtime.
## Product charter
@@ -1477,8 +1481,8 @@ These must live behind explicit entry points or dynamic imports.
### Explicit non-goals
-- A proprietary chart specification language
-- A JSON visualization DSL
+- A second normalized chart-type grammar that diverges from chart definitions
+- Dashboard sources, queries, refresh policy, or layout inside Chart JSON
- A casually divergent clone that inherits Plot’s API surface without its
behavior or tests
- Runtime data profiling or cleaning
diff --git a/PORTABLE-CHART-SPEC.md b/PORTABLE-CHART-SPEC.md
deleted file mode 100644
index 2ab5ffea..00000000
--- a/PORTABLE-CHART-SPEC.md
+++ /dev/null
@@ -1,104 +0,0 @@
-# Portable chart definitions
-
-Some users need to communicate a visualization as JSON. A chart definition
-currently contains live functions, so it cannot be serialized directly.
-
-The portable format should contain only chart instructions. Data is supplied
-when the definition is rendered.
-
-```json
-{
- "$schema": "https://tanstack.com/charts/schema/definition-v1.json",
- "definition": {
- "marks": [
- {
- "$call": ["lineY", { "$data": "rows" }, { "x": "date", "y": "value" }]
- }
- ],
- "x": {
- "scale": {
- "$call": [
- "scaleUtc",
- {
- "domain": {
- "$call": ["extent", { "$data": "rows" }, "date"]
- }
- }
- ]
- }
- },
- "y": {
- "scale": { "$call": ["scaleLinear", { "domain": [0, 100] }] },
- "format": {
- "$call": ["formatCurrency", { "currency": "USD" }]
- }
- }
- }
-}
-```
-
-`defineChartDefinition` validates the spec and resolves calls through the core
-registry plus any application extensions:
-
-```tsx
-const definition = defineChartDefinition(spec, {
- registry: { formatCurrency },
-})
-
-
-```
-
-The definition is created once. `$data` references are resolved from the
-current `data` prop, so changing time-series data does not reparse or recreate
-the definition.
-
-## Delivery
-
-A definition can travel alone, or a self-contained document can bundle the
-same definition and data:
-
-```json
-{
- "$schema": "https://tanstack.com/charts/schema/document-v1.json",
- "definition": {},
- "data": {}
-}
-```
-
-The document is a transport convenience, not a different definition model.
-Dashboards—with sources, queries, refresh behavior, layout, and multiple
-panels—remain a separate layer.
-
-## Rules and remaining edges
-
-- `{ "$call": [name, ...args] }` and `{ "$data": name }` are reserved nodes.
- Ordinary arrays remain literals, avoiding Mapbox-style literal ambiguity.
-- Calls may be nested. Static calls can resolve once; calls depending on data
- resolve during chart construction.
-- Registry entries are synchronous, allowlisted contracts. Unknown calls,
- missing data, invalid arguments, and conflicting registrations fail with an
- exact JSON path.
-- No code strings, evaluation, or JSON-controlled dynamic imports.
-- External data may contain runtime values such as `Date` or typed arrays.
- Bundled document data must use JSON-compatible encodings.
-- Formatter and accessor factories can come from the registry. Host effects
- such as navigation or application event handlers stay outside the definition.
-- Streaming and incremental data updates are renderer concerns, not definition
- syntax.
-
-Before v1, we should decide whether registry names need namespaces or versions,
-and whether responsive definitions need a third reserved node for chart
-context such as width, height, or theme.
-
-## Prior art
-
-- [deck.gl JSON](https://deck.gl/docs/api-reference/json/overview) validates the
- application-supplied registry model.
-- [Mapbox expressions](https://docs.mapbox.com/style-spec/reference/expressions/)
- validate call-shaped JSON expressions and show why literal arrays must remain
- distinguishable.
-- [Vega-Lite data](https://vega.github.io/vega-lite/docs/data.html) supports
- both inline data and named runtime-bound data.
-- [Vega's View API](https://vega.github.io/vega/docs/api/view/) and
- [Plotly streaming](https://plotly.com/javascript/streaming/) keep incremental
- data updates separate from the original visualization description.
diff --git a/README.md b/README.md
index d303e8ca..9da3e26e 100644
--- a/README.md
+++ b/README.md
@@ -202,6 +202,7 @@ pattern.
| [`docs/concepts/scales-and-d3.md`](./docs/concepts/scales-and-d3.md) | Compact scale path and D3 upgrade boundary |
| [`docs/examples/index.md`](./docs/examples/index.md) | Curated chart-family and interaction examples |
| [`docs/guides/ai-authoring.md`](./docs/guides/ai-authoring.md) | Deterministic authoring and validation for coding agents |
+| [`docs/reference/json-interchange.md`](./docs/reference/json-interchange.md) | Fixed Chart JSON schema, interpreter, and AI recipe |
| [`docs/reference/index.md`](./docs/reference/index.md) | Complete public API map |
| [`llms.txt`](./llms.txt) | Generated documentation routing index |
diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json
index 312ce1b7..ef89f721 100644
--- a/benchmarks/comparison/bundle-baseline.json
+++ b/benchmarks/comparison/bundle-baseline.json
@@ -1,8 +1,8 @@
{
"schemaVersion": 4,
- "generatedAt": "2026-08-15T07:14:39.579Z",
+ "generatedAt": "2026-08-20T22:44:45.775Z",
"packageVersions": {
- "tanstack": "0.13.0",
+ "tanstack": "0.14.0",
"chartjs": "4.5.1",
"echarts": "6.1.0",
"recharts": "3.10.1",
@@ -11,8 +11,8 @@
"sources": {
"tanstack": {
"kind": "workspace",
- "revision": "91e2eef1772dc06118ce5aa1e0b388d12bf91918",
- "inputDigest": "sha256:07856952e86ead8b51b7517ecd2bcfd8af9fb47ff152125855b752cd51f2f94c"
+ "revision": "d6644db9e244ff67a43591b73d1697b3dbeaca6a",
+ "inputDigest": "sha256:73a57d6bc6681bbf5b12f0832bddb052b941427faf28a565b28516906d084bc4"
},
"chartjs": {
"kind": "package",
diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json
index 7d1e4d4b..c7ce74da 100644
--- a/benchmarks/conformance/previews/manifest.json
+++ b/benchmarks/conformance/previews/manifest.json
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"width": 288,
"height": 192,
- "sourceHash": "136ba5c0be5fdc8fc8c7e18e0663f3ddecfc851acaf585a175f2031ad1468d32",
+ "sourceHash": "d8b29c81b5b55857afc9b09aba86dc6f1a456264049d5412c9f6a7c597d6c43c",
"assets": [
{
"id": "01-line-gaps",
diff --git a/docs/comparison.md b/docs/comparison.md
index 61dfd381..6048fe4c 100644
--- a/docs/comparison.md
+++ b/docs/comparison.md
@@ -12,14 +12,14 @@ turning untested behavior into a checkmark.
| Library | Package | Measured source |
| -------------------------------------------------------------------------------------- | -------------------- | ------------------- |
-| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `91e2eef` |
+| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `d6644db` |
| [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` |
| [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` |
| [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` |
| [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` |
The competitor versions are exact package pins, not latest versions inferred
-at page render time. The measured TanStack workspace revision is `91e2eef`.
+at page render time. The measured TanStack workspace revision is `d6644db`.
## Capability matrix
@@ -90,7 +90,7 @@ output model.
## Bundle snapshot
-Baseline date: `2026-08-15`.
+Baseline date: `2026-08-20`.
Controlled ranges cover 12 independently built, minified browser consumers:
line, bar, area, and scatter at basic, interactive, and advanced tiers. Only
diff --git a/docs/concepts/chart-definitions.md b/docs/concepts/chart-definitions.md
index 065e8121..4809a448 100644
--- a/docs/concepts/chart-definitions.md
+++ b/docs/concepts/chart-definitions.md
@@ -158,3 +158,10 @@ host.update({
Charts owns surface measurement, scene construction, and keyed reconciliation.
It does not own application equality or data reactivity.
+
+## Definitions from Chart JSON
+
+`chartFromJson()` interprets callback-free JSON as the same `ChartDefinition`
+used everywhere else. Host data may replace bundled data while callbacks and
+interaction remain application code. See
+[Chart JSON](../reference/json-interchange.md).
diff --git a/docs/config.json b/docs/config.json
index 72d0734a..94df8009 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -1069,6 +1069,10 @@
"label": "Chart Definition API",
"to": "reference/chart-definitions"
},
+ {
+ "label": "Chart JSON",
+ "to": "reference/json-interchange"
+ },
{
"label": "Chart Spec",
"to": "reference/chart-spec"
diff --git a/docs/guides/ai-authoring.md b/docs/guides/ai-authoring.md
index 77590fdc..443e1c96 100644
--- a/docs/guides/ai-authoring.md
+++ b/docs/guides/ai-authoring.md
@@ -7,6 +7,13 @@ TanStack Charts uses a small grammar so an agent can reason from data and
intent instead of selecting a monolithic chart component. The safest authoring
path is explicit and repeatable.
+## Choose code or Chart JSON
+
+Ask for TypeScript when the result must include application callbacks,
+framework composition, or custom renderer code. Ask for Chart JSON when the
+application should validate and interpret a model's output. Give the model the
+fixed schema; see [Chart JSON](../reference/json-interchange.md).
+
## The authoring sequence
1. State the analytical question in one sentence.
@@ -37,7 +44,9 @@ Use one documentation owner for each decision:
- large-data representation:
[Large Data](./large-data.md);
- exact signatures and options:
- [API Reference](../reference/index.md).
+ [API Reference](../reference/index.md);
+- fixed Chart JSON schema and interpreter:
+ [Chart JSON](../reference/json-interchange.md).
Do not reconstruct an API from an example when the reference owns the
signature. Do not restate D3 behavior when the D3 bridge links to its
diff --git a/docs/reference/index.md b/docs/reference/index.md
index af31ef73..1fe8378b 100644
--- a/docs/reference/index.md
+++ b/docs/reference/index.md
@@ -14,6 +14,7 @@ and application-owned interaction.
| Area | Reference |
| ----------------------------------------------------------- | --------------------------------------------------------- |
| Object and responsive definitions | [Chart Definition API](./chart-definitions.md) |
+| Public JSON format, interpreter, operations, and versions | [Chart JSON](./json-interchange.md) |
| The `ChartSpec` object | [Chart spec](./chart-spec.md) |
| Positional scales, axes, color, legends, and gradients | [Scales, guides, and color](./scales-guides-and-color.md) |
| Vanilla DOM mounting and responsive sizing | [DOM host](./dom-host.md) |
@@ -94,6 +95,7 @@ aggregate `/scales` export.
| Import | Public values |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@tanstack/charts` | Common marks including `crosshair`, legends, D3 curve bridges, definitions, runtime, scene, DOM host, static SVG, and focus-presentation helpers |
+| `@tanstack/charts/json` | `chartFromJson`, the fixed schema, version, structured error, and public types |
| `@tanstack/charts/scales/band` | `scaleBand`, `BandScale` |
| `@tanstack/charts/scales/linear` | `scaleLinear`, `LinearScale` |
| `@tanstack/charts/scales/ordinal` | `scaleOrdinal`, `OrdinalScale` |
diff --git a/docs/reference/json-interchange.md b/docs/reference/json-interchange.md
new file mode 100644
index 00000000..aeaae71e
--- /dev/null
+++ b/docs/reference/json-interchange.md
@@ -0,0 +1,233 @@
+---
+title: Chart JSON
+description: Author, validate, and render the fixed TanStack Charts JSON interchange format.
+---
+
+Chart JSON is a callback-free interchange format for common Cartesian and
+circular charts. The schema and interpreter ship from `@tanstack/charts/json`.
+
+## Example
+
+```json
+{
+ "$schema": "https://unpkg.com/@tanstack/charts@0.14.0/schemas/chart.json",
+ "chartsVersion": "0.14.0",
+ "spec": {
+ "marks": [
+ {
+ "$call": "tanstack.mark.bar-y",
+ "data": { "$data": "rows" },
+ "x": "category",
+ "y": "value"
+ }
+ ],
+ "x": { "scale": { "$call": "tanstack.scale.band" } },
+ "y": {
+ "scale": { "$call": "tanstack.scale.linear" },
+ "nice": true,
+ "grid": true
+ }
+ },
+ "data": {
+ "rows": [
+ { "category": "Alpha", "value": 8 },
+ { "category": "Beta", "value": 13 },
+ { "category": "Gamma", "value": 5 }
+ ]
+ },
+ "metadata": {
+ "title": "Category totals",
+ "description": "Values for Alpha, Beta, and Gamma."
+ }
+}
+```
+
+The envelope is closed:
+
+| Property | Contract |
+| --------------- | --------------------------------------------------------------- |
+| `$schema` | Optional canonical schema URL for the authored `chartsVersion` |
+| `chartsVersion` | Required TanStack Charts SemVer |
+| `spec` | Required marks; Cartesian specs also require `x` and `y` axes |
+| `data` | Optional named JSON row arrays used by mark `data` references |
+| `metadata` | Optional `title` and `description` for host-accessible labeling |
+
+There is one envelope. A producer may bundle `data`; a host may supply or
+replace it when interpreting the same source.
+
+## Expressions
+
+`$data` reads a named row array. It is valid only as a mark's `data` argument:
+
+```json
+{ "$data": "rows" }
+```
+
+`$call` invokes one fixed operation. Arguments are sibling properties, not a
+nested `args` object:
+
+```json
+{
+ "$call": "tanstack.mark.line-y",
+ "data": { "$data": "rows" },
+ "x": {
+ "$call": "tanstack.accessor.iso-date",
+ "field": "recordedAt"
+ },
+ "y": "revenue"
+}
+```
+
+Expression objects are exact. Ordinary expression keys cannot start with `$`.
+Objects inside `data` are opaque and may contain reserved-looking keys.
+
+## Fixed operations
+
+The fixed dialect contains 16 operations:
+
+| ID | Produces |
+| ---------------------------- | ------------------------------ |
+| `tanstack.accessor.iso-date` | Strict ISO-date field accessor |
+| `tanstack.legend.color` | Categorical color legend |
+| `tanstack.layout.group` | Grouped interval layout |
+| `tanstack.mark.area-y` | Vertical area mark |
+| `tanstack.mark.bar-x` | Horizontal bar mark |
+| `tanstack.mark.bar-y` | Vertical bar mark |
+| `tanstack.mark.dot` | Dot mark |
+| `tanstack.mark.line-y` | Vertical line mark |
+| `tanstack.mark.pie` | Pie or donut mark |
+| `tanstack.mark.rule-x` | Vertical full-plot rule |
+| `tanstack.mark.rule-y` | Horizontal full-plot rule |
+| `tanstack.mark.text` | Positioned text labels |
+| `tanstack.scale.band` | Band scale |
+| `tanstack.scale.linear` | Linear scale |
+| `tanstack.scale.point` | Point scale |
+| `tanstack.scale.utc` | UTC time scale |
+
+`chartJsonSchema` is the authoritative contract for each operation's named
+arguments and allowed placement. Unknown calls and arguments are rejected.
+
+`rule-x` draws a vertical rule at each x value, while `rule-y` draws a
+horizontal rule at each y value. Layer `text` with either rule and supply its
+label coordinates as ordinary data. Targets, event dates, and label rows are
+prepared data; the operations do not calculate them.
+
+A spec is either Cartesian or circular. A circular spec contains one
+`tanstack.mark.pie` mark and omits `x`, `y`, and `guides`; it cannot mix the pie
+mark with Cartesian marks. Set `innerRadiusRatio` to `0` for a pie or greater
+than `0` and less than `1` for a donut.
+
+## Interpret JSON
+
+```ts
+import {
+ ChartJsonError,
+ chartFromJson,
+ chartJsonSchema,
+} from '@tanstack/charts/json'
+import { createChartScene } from '@tanstack/charts'
+import { renderChartSvg } from '@tanstack/charts/svg'
+
+try {
+ const definition = chartFromJson(jsonText)
+ const scene = createChartScene(definition, { width: 640, height: 400 })
+ const svg = renderChartSvg(scene, {
+ ariaLabel: definition.metadata?.title ?? 'Chart',
+ })
+} catch (error) {
+ if (error instanceof ChartJsonError) {
+ console.error(error.issues)
+ } else {
+ throw error
+ }
+}
+```
+
+`chartFromJson()` accepts JSON text and returns an ordinary `ChartDefinition`.
+It parses, validates, and evaluates the fixed operations synchronously. A
+`ChartJsonError` contains structured issues with a stable code and JSON Pointer
+path. Its human-readable message is diagnostic text, not a compatibility key.
+
+Host data replaces bundled values with the same name:
+
+```ts
+const definition = chartFromJson(jsonText, {
+ data: { rows: preparedRows },
+})
+```
+
+This boundary keeps filtering, joins, database work, and application objects
+in host code. The host may also add callbacks and interactions to the returned
+definition:
+
+```ts
+const parsed = chartFromJson(jsonText, { data: { rows: preparedRows } })
+const definition = {
+ ...parsed,
+ focus: 'nearest-x' as const,
+ pointer: true,
+ keyboard: true,
+}
+```
+
+Navigation, mutation handlers, controlled state, portal targets, and custom
+renderers are not part of Chart JSON.
+
+## Give the contract to a model
+
+Send `chartJsonSchema` with the analytical question and field meanings. Ask
+for one JSON value without Markdown or commentary, and require the schema's
+exact `chartsVersion`.
+
+```ts
+const modelInput = {
+ question: 'Show daily revenue over time.',
+ fields: {
+ recordedAt: 'ISO 8601 UTC timestamp',
+ revenue: 'US dollars',
+ },
+ schema: chartJsonSchema,
+}
+
+const definition = chartFromJson(modelResponseText, {
+ data: { rows: revenueRows },
+})
+```
+
+Structural validation cannot determine whether a chart answers the analytical
+question. The receiving application remains responsible for reviewing the
+chosen fields, encodings, accessible description, and host behavior.
+
+## Versions
+
+`chartsVersion` follows the `@tanstack/charts` package version. By default,
+the reader accepts supported earlier dialect versions and rejects future
+versions. Before 1.0, author and reader versions must both use major `0`;
+after 1.0, their majors must match.
+
+Use exact matching when the producer and reader must run the same release:
+
+```ts
+const definition = chartFromJson(jsonText, { exactVersion: true })
+```
+
+The published schema is also available as `chartJsonSchema`; its `$id` is the
+immutable release URL.
+
+## Public API
+
+Runtime values are `chartFromJson`, `chartJsonSchema`, `chartJsonVersion`, and
+`ChartJsonError`. Public types are `ChartJson`, `ChartJsonDefinition`,
+`ChartFromJsonOptions`, `ChartJsonMetadata`, `ChartJsonIssue`,
+and `ChartJsonIssueCode`.
+
+## Workbench
+
+Run the editable React workbench:
+
+```sh
+pnpm --filter @charts-poc/conformance-example dev
+```
+
+Open `http://localhost:5194/json/`. The workbench keeps the last valid preview
+after rejected edits and demonstrates host data replacement.
diff --git a/examples/conformance/src/json-demo.test.tsx b/examples/conformance/src/json-demo.test.tsx
new file mode 100644
index 00000000..4bceb1e1
--- /dev/null
+++ b/examples/conformance/src/json-demo.test.tsx
@@ -0,0 +1,203 @@
+import * as React from 'react'
+import { act } from 'react'
+import { createRoot } from 'react-dom/client'
+import { afterEach, describe, expect, it } from 'vitest'
+import { ChartJsonDemo } from './json-demo'
+
+const mounted: Array<() => void> = []
+
+afterEach(async () => {
+ while (mounted.length) await act(async () => mounted.pop()?.())
+ document.body.replaceChildren()
+})
+
+describe('Chart JSON workbench', () => {
+ it('retains the last valid chart and exposes structured issues', async () => {
+ const target = await mountDemo()
+ const editor = target.querySelector('textarea')
+ expect(editor?.getAttribute('aria-invalid')).toBe('false')
+ expect(target.querySelectorAll('g.ts-chart__bar-y > rect')).toHaveLength(3)
+ expect(target.querySelector('svg')?.getAttribute('aria-label')).toBe(
+ 'Category totals',
+ )
+
+ await act(async () => {
+ setNativeValue(editor!, '{')
+ editor?.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ await act(async () => button(target, 'Apply').click())
+
+ expect(editor?.getAttribute('aria-invalid')).toBe('true')
+ expect(target.textContent).toContain('invalid-json')
+ expect(target.textContent).toContain('/')
+ expect(target.textContent).toContain('previous preview retained')
+ expect(target.querySelectorAll('g.ts-chart__bar-y > rect')).toHaveLength(3)
+ expect(button(target, 'Replace data').disabled).toBe(true)
+ })
+
+ it('rebuilds the same source with host-supplied replacement data', async () => {
+ const target = await mountDemo()
+ const initialGeometry = barGeometry(target)
+
+ await act(async () => button(target, 'Replace data').click())
+
+ expect(target.textContent).toContain('Applied · host data override 1')
+ expect(barGeometry(target)).not.toEqual(initialGeometry)
+ expect(target.querySelectorAll('[role="tab"], table')).toHaveLength(0)
+ })
+
+ it('loads a donut source that can be edited into a pie', async () => {
+ const target = await mountDemo()
+ const example = target.querySelector(
+ 'select[aria-label="Example source"]',
+ )
+ const editor = target.querySelector('textarea')
+
+ await act(async () => {
+ example!.value = 'donut'
+ example?.dispatchEvent(new Event('change', { bubbles: true }))
+ })
+
+ expect(example?.value).toBe('donut')
+ expect(editor?.value).toContain('"$call": "tanstack.mark.pie"')
+ expect(editor?.value).toContain('"innerRadiusRatio": 0.55')
+ expect(target.querySelector('svg')?.getAttribute('aria-label')).toBe(
+ 'Category share',
+ )
+ expect(target.textContent).toContain(
+ 'Shares for Alpha, Beta, and Gamma. Set innerRadiusRatio to 0 for a pie.',
+ )
+ expect(target.querySelector('g.ts-chart__legend')?.textContent).toContain(
+ 'Category',
+ )
+ const donutGeometry = arcGeometry(target)
+ expect(donutGeometry).toHaveLength(3)
+
+ await act(async () => button(target, 'Replace data').click())
+ expect(arcGeometry(target)).not.toEqual(donutGeometry)
+
+ await act(async () => {
+ setNativeValue(
+ editor!,
+ editor!.value.replace(
+ '"innerRadiusRatio": 0.55',
+ '"innerRadiusRatio": 0',
+ ),
+ )
+ editor?.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ const overriddenDonutGeometry = arcGeometry(target)
+ await act(async () => button(target, 'Apply').click())
+
+ expect(editor?.getAttribute('aria-invalid')).toBe('false')
+ expect(arcGeometry(target)).not.toEqual(overriddenDonutGeometry)
+
+ await act(async () => button(target, 'Reset').click())
+ expect(example?.value).toBe('donut')
+ expect(editor?.value).toContain('"innerRadiusRatio": 0.55')
+ expect(target.textContent).toContain('Applied · bundled data')
+ })
+
+ it('layers a threshold, an event marker, and labels', async () => {
+ const target = await mountDemo()
+ const example = target.querySelector(
+ 'select[aria-label="Example source"]',
+ )
+ const editor = target.querySelector('textarea')
+
+ await act(async () => {
+ example!.value = 'annotations'
+ example?.dispatchEvent(new Event('change', { bubbles: true }))
+ })
+
+ expect(example?.value).toBe('annotations')
+ expect(editor?.value).toContain('"$call": "tanstack.mark.rule-x"')
+ expect(editor?.value).toContain('"$call": "tanstack.mark.rule-y"')
+ expect(editor?.value).toContain('"$call": "tanstack.mark.text"')
+ expect(target.querySelector('svg')?.getAttribute('aria-label')).toBe(
+ 'Annotated signups',
+ )
+
+ const verticalRule = target.querySelector(
+ 'g.ts-chart__rule-x line',
+ )
+ const horizontalRule = target.querySelector(
+ 'g.ts-chart__rule-y line',
+ )
+ expect(verticalRule?.getAttribute('x1')).toBe(
+ verticalRule?.getAttribute('x2'),
+ )
+ expect(verticalRule?.getAttribute('y1')).not.toBe(
+ verticalRule?.getAttribute('y2'),
+ )
+ expect(horizontalRule?.getAttribute('x1')).not.toBe(
+ horizontalRule?.getAttribute('x2'),
+ )
+ expect(horizontalRule?.getAttribute('y1')).toBe(
+ horizontalRule?.getAttribute('y2'),
+ )
+ expect(annotationLabels(target)).toEqual(
+ expect.arrayContaining(['Target 10', 'Launch']),
+ )
+
+ const initialLine = lineGeometry(target)
+ await act(async () => button(target, 'Replace data').click())
+
+ expect(target.textContent).toContain('Applied · host data override 1')
+ expect(lineGeometry(target)).not.toBe(initialLine)
+ expect(target.querySelectorAll('g.ts-chart__rule-x line')).toHaveLength(1)
+ expect(target.querySelectorAll('g.ts-chart__rule-y line')).toHaveLength(1)
+ expect(annotationLabels(target)).toEqual(
+ expect.arrayContaining(['Target 10', 'Launch']),
+ )
+ })
+})
+
+async function mountDemo(): Promise {
+ const target = document.createElement('div')
+ document.body.append(target)
+ const root = createRoot(target)
+ mounted.push(() => root.unmount())
+ await act(async () => root.render())
+ return target
+}
+
+function barGeometry(container: HTMLElement): readonly (string | null)[] {
+ return [
+ ...container.querySelectorAll('g.ts-chart__bar-y > rect'),
+ ].flatMap((bar) => [bar.getAttribute('y'), bar.getAttribute('height')])
+}
+
+function arcGeometry(container: HTMLElement): readonly (string | null)[] {
+ return [
+ ...container.querySelectorAll('g.ts-chart__arc path'),
+ ].map((arc) => arc.getAttribute('d'))
+}
+
+function lineGeometry(container: HTMLElement): string | null | undefined {
+ return container
+ .querySelector('g.ts-chart__line path')
+ ?.getAttribute('d')
+}
+
+function annotationLabels(container: HTMLElement): readonly string[] {
+ return [
+ ...container.querySelectorAll('g.ts-chart__text text'),
+ ].map((label) => label.textContent ?? '')
+}
+
+function button(container: HTMLElement, name: string): HTMLButtonElement {
+ const match = [
+ ...container.querySelectorAll('button'),
+ ].find((entry) => entry.textContent?.trim() === name)
+ if (!match) throw new Error(`Missing button ${JSON.stringify(name)}`)
+ return match
+}
+
+function setNativeValue(element: HTMLTextAreaElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(
+ HTMLTextAreaElement.prototype,
+ 'value',
+ )?.set
+ setter?.call(element, value)
+}
diff --git a/examples/conformance/src/json-demo.tsx b/examples/conformance/src/json-demo.tsx
new file mode 100644
index 00000000..f1eb3cdd
--- /dev/null
+++ b/examples/conformance/src/json-demo.tsx
@@ -0,0 +1,369 @@
+import * as React from 'react'
+import { Chart } from '@tanstack/charts/react'
+import {
+ ChartJsonError,
+ chartFromJson,
+ chartJsonVersion,
+ type ChartJsonIssue,
+} from '@tanstack/charts/json'
+import barSource from '../../../packages/charts-core/schemas/example.json?raw'
+
+interface DemoRow {
+ readonly category: string
+ readonly date: string
+ readonly value: number
+}
+
+const replacementRows: readonly (readonly DemoRow[])[] = [
+ [
+ { category: 'Alpha', date: '2026-01-01', value: 11 },
+ { category: 'Beta', date: '2026-01-02', value: 6 },
+ { category: 'Gamma', date: '2026-01-03', value: 15 },
+ ],
+ [
+ { category: 'Alpha', date: '2026-01-01', value: 4 },
+ { category: 'Beta', date: '2026-01-02', value: 17 },
+ { category: 'Gamma', date: '2026-01-03', value: 9 },
+ ],
+]
+
+const donutSource = JSON.stringify(
+ {
+ $schema: `https://unpkg.com/@tanstack/charts@${chartJsonVersion}/schemas/chart.json`,
+ chartsVersion: chartJsonVersion,
+ spec: {
+ marks: [
+ {
+ $call: 'tanstack.mark.pie',
+ data: { $data: 'rows' },
+ category: 'category',
+ value: 'value',
+ innerRadiusRatio: 0.55,
+ },
+ ],
+ color: {
+ legend: {
+ $call: 'tanstack.legend.color',
+ label: 'Category',
+ placement: 'bottom',
+ },
+ },
+ },
+ data: {
+ rows: [
+ { category: 'Alpha', value: 8 },
+ { category: 'Beta', value: 13 },
+ { category: 'Gamma', value: 5 },
+ ],
+ },
+ metadata: {
+ title: 'Category share',
+ description:
+ 'Shares for Alpha, Beta, and Gamma. Set innerRadiusRatio to 0 for a pie.',
+ },
+ },
+ null,
+ 2,
+)
+
+const annotationsSource = JSON.stringify(
+ {
+ $schema: `https://unpkg.com/@tanstack/charts@${chartJsonVersion}/schemas/chart.json`,
+ chartsVersion: chartJsonVersion,
+ spec: {
+ marks: [
+ {
+ $call: 'tanstack.mark.line-y',
+ data: { $data: 'rows' },
+ x: {
+ $call: 'tanstack.accessor.iso-date',
+ field: 'date',
+ },
+ y: 'value',
+ points: true,
+ strokeWidth: 2,
+ },
+ {
+ $call: 'tanstack.mark.rule-y',
+ data: { $data: 'thresholds' },
+ y: 'value',
+ stroke: '#dc2626',
+ strokeWidth: 1.5,
+ strokeDasharray: '6 4',
+ },
+ {
+ $call: 'tanstack.mark.rule-x',
+ data: { $data: 'events' },
+ x: {
+ $call: 'tanstack.accessor.iso-date',
+ field: 'date',
+ },
+ stroke: '#7c3aed',
+ strokeWidth: 1.5,
+ strokeDasharray: '2 3',
+ },
+ {
+ $call: 'tanstack.mark.text',
+ data: { $data: 'labels' },
+ x: {
+ $call: 'tanstack.accessor.iso-date',
+ field: 'date',
+ },
+ y: 'value',
+ text: 'label',
+ key: 'label',
+ anchor: 'start',
+ dx: 6,
+ dy: -8,
+ fontSize: 12,
+ fontWeight: 600,
+ },
+ ],
+ x: { scale: { $call: 'tanstack.scale.utc' } },
+ y: {
+ scale: { $call: 'tanstack.scale.linear' },
+ nice: true,
+ grid: true,
+ },
+ },
+ data: {
+ rows: [
+ { date: '2026-01-01', value: 7 },
+ { date: '2026-01-02', value: 12 },
+ { date: '2026-01-03', value: 9 },
+ ],
+ thresholds: [{ value: 10 }],
+ events: [{ date: '2026-01-02' }],
+ labels: [
+ { date: '2026-01-01', value: 10, label: 'Target 10' },
+ { date: '2026-01-02', value: 13, label: 'Launch' },
+ ],
+ },
+ metadata: {
+ title: 'Annotated signups',
+ description:
+ 'Daily signups with a target at 10 and a launch marker on January 2.',
+ },
+ },
+ null,
+ 2,
+)
+
+const exampleSources = {
+ bar: barSource,
+ donut: donutSource,
+ annotations: annotationsSource,
+} as const
+
+type ExampleSource = keyof typeof exampleSources
+
+const initialDefinition = chartFromJson(barSource)
+
+export function ChartJsonDemo() {
+ const editorId = React.useId()
+ const statusId = React.useId()
+ const previewTitleId = React.useId()
+ const [exampleSource, setExampleSource] = React.useState('bar')
+ const [draft, setDraft] = React.useState(barSource)
+ const [appliedSource, setAppliedSource] = React.useState(barSource)
+ const [definition, setDefinition] = React.useState(initialDefinition)
+ const [issues, setIssues] = React.useState([])
+ const [dataRevision, setDataRevision] = React.useState(0)
+ const title = definition.metadata?.title ?? 'Chart preview'
+ const description =
+ definition.metadata?.description ?? 'Chart rendered from JSON.'
+
+ const apply = () => {
+ try {
+ const next = chartFromJson(
+ draft,
+ dataRevision === 0
+ ? undefined
+ : { data: { rows: rowsForRevision(dataRevision) } },
+ )
+ setDefinition(next)
+ setAppliedSource(draft)
+ setIssues([])
+ } catch (error) {
+ setIssues(chartJsonIssues(error))
+ }
+ }
+
+ const loadExample = (nextExample: ExampleSource) => {
+ const nextSource = exampleSources[nextExample]
+ try {
+ const next = chartFromJson(nextSource)
+ setExampleSource(nextExample)
+ setDraft(nextSource)
+ setAppliedSource(nextSource)
+ setDefinition(next)
+ setIssues([])
+ setDataRevision(0)
+ } catch (error) {
+ setIssues(chartJsonIssues(error))
+ }
+ }
+
+ const reset = () => loadExample(exampleSource)
+
+ const replaceData = () => {
+ const nextRevision = (dataRevision % replacementRows.length) + 1
+ try {
+ setDefinition(
+ chartFromJson(appliedSource, {
+ data: { rows: rowsForRevision(nextRevision) },
+ }),
+ )
+ setDataRevision(nextRevision)
+ } catch (error) {
+ setIssues(chartJsonIssues(error))
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
{title}
+
{description}
+
+
+
+
+
+
+
+ )
+}
+
+function IssueList({ issues }: { readonly issues: readonly ChartJsonIssue[] }) {
+ return (
+
+
+ {issues.map((issue, index) => (
+ -
+
+ {issue.code}
+ {issue.path}
+
+ {issue.message}
+
+ ))}
+
+
+ )
+}
+
+interface PreviewErrorBoundaryProps {
+ readonly resetKey: unknown
+ readonly children: React.ReactNode
+}
+
+class PreviewErrorBoundary extends React.Component<
+ PreviewErrorBoundaryProps,
+ { readonly error?: Error }
+> {
+ state: { readonly error?: Error } = {}
+
+ static getDerivedStateFromError(error: Error) {
+ return { error }
+ }
+
+ componentDidUpdate(previous: PreviewErrorBoundaryProps) {
+ if (previous.resetKey !== this.props.resetKey && this.state.error) {
+ this.setState({ error: undefined })
+ }
+ }
+
+ render() {
+ if (this.state.error) {
+ return (
+
+ Preview failed: {this.state.error.message}
+
+ )
+ }
+ return this.props.children
+ }
+}
+
+function rowsForRevision(revision: number): readonly DemoRow[] {
+ return replacementRows[(revision - 1) % replacementRows.length] ?? []
+}
+
+function readExample(value: string): ExampleSource {
+ if (value === 'donut' || value === 'annotations') return value
+ return 'bar'
+}
+
+function chartJsonIssues(error: unknown): readonly ChartJsonIssue[] {
+ if (error instanceof ChartJsonError) return error.issues
+ throw error
+}
diff --git a/examples/conformance/src/main.tsx b/examples/conformance/src/main.tsx
index 28419351..11e9ccfe 100644
--- a/examples/conformance/src/main.tsx
+++ b/examples/conformance/src/main.tsx
@@ -1,4 +1,6 @@
import {
+ lazy,
+ Suspense,
useEffect,
useMemo,
useRef,
@@ -54,6 +56,11 @@ const families = [
let comparisonCatalogPromise:
| Promise
| undefined
+const LazyChartJsonDemo = lazy(() =>
+ import('./json-demo').then(({ ChartJsonDemo }) => ({
+ default: ChartJsonDemo,
+ })),
+)
function CatalogApp() {
const [location, setLocation] = useState(readCatalogLocation)
@@ -96,7 +103,7 @@ function CatalogApp() {
const routeHref = (nextRoute: Exclude) =>
withCatalogComparisonMode(
catalogRouteHref(nextRoute, basePath),
- comparisonMode && nextRoute.view !== 'embed',
+ comparisonMode && nextRoute.view !== 'embed' && nextRoute.view !== 'json',
)
const link = (nextRoute: Exclude) => ({
href: routeHref(nextRoute),
@@ -136,6 +143,10 @@ function CatalogApp() {
)
}
+ if (route.view === 'json') {
+ return
+ }
+
if (route.view === 'collection') {
return route.collectionId === 'shadcn' ? (
@@ -170,6 +181,38 @@ function CatalogApp() {
return
}
+function JsonWorkbenchPage({
+ dark,
+ link,
+ setDark,
+}: {
+ dark: boolean
+ link: RouteLinkFactory
+ setDark: (value: boolean) => void
+}) {
+ useDocumentMeta(
+ 'Chart JSON workbench · TanStack Charts',
+ 'Edit, validate, and render the official TanStack Charts JSON interchange format.',
+ )
+
+ return (
+ <>
+
+
+
+ Chart JSON workbench
+
+
+ Loading workbench…
}>
+
+
+
+ >
+ )
+}
+
function ShadcnCollectionPage({
dark,
link,
@@ -1074,7 +1117,7 @@ function SiteHeader({
active,
link,
}: {
- active?: 'catalog' | 'all' | 'shadcn'
+ active?: 'catalog' | 'all' | 'json' | 'shadcn'
link: RouteLinkFactory
}) {
return (
@@ -1114,6 +1157,12 @@ function SiteHeader({
>
shadcn
+
+ Chart JSON
+
)
diff --git a/examples/conformance/src/routes.test.ts b/examples/conformance/src/routes.test.ts
index f7212c46..a7336db7 100644
--- a/examples/conformance/src/routes.test.ts
+++ b/examples/conformance/src/routes.test.ts
@@ -6,6 +6,7 @@ describe('catalog routes', () => {
['/', { view: 'index' }],
['/all/', { view: 'all' }],
['/collections/shadcn/', { view: 'collection', collectionId: 'shadcn' }],
+ ['/json/', { view: 'json' }],
['/charts/01-line/', { view: 'case', caseId: '01-line' }],
['/embed/01-line/', { view: 'embed', caseId: '01-line' }],
['/unknown/', { view: 'not-found' }],
@@ -37,6 +38,14 @@ describe('catalog routes', () => {
).toBe('/catalog/collections/shadcn%20charts/')
})
+ it('creates the Chart JSON workbench link below a deployment base', () => {
+ const basePath = '/charts/catalog/'
+ const href = catalogRouteHref({ view: 'json' }, basePath)
+
+ expect(href).toBe('/charts/catalog/json/')
+ expect(parseCatalogRoute(href, basePath)).toEqual({ view: 'json' })
+ })
+
it('preserves the production catalog base for direct embed routes', () => {
const basePath = '/charts/catalog/'
const href = catalogRouteHref(
diff --git a/examples/conformance/src/routes.ts b/examples/conformance/src/routes.ts
index 765d04e5..c94f0eb7 100644
--- a/examples/conformance/src/routes.ts
+++ b/examples/conformance/src/routes.ts
@@ -2,6 +2,7 @@ export type CatalogRoute =
| { view: 'index' }
| { view: 'all' }
| { view: 'collection'; collectionId: string }
+ | { view: 'json' }
| { view: 'case'; caseId: string }
| { view: 'embed'; caseId: string }
| { view: 'not-found' }
@@ -24,6 +25,7 @@ export function parseCatalogRoute(
if (segments.length === 0) return { view: 'index' }
if (segments.length === 1 && segments[0] === 'all') return { view: 'all' }
+ if (segments.length === 1 && segments[0] === 'json') return { view: 'json' }
if (segments.length === 2 && segments[0] === 'collections' && segments[1]) {
return { view: 'collection', collectionId: segments[1] }
@@ -51,6 +53,7 @@ export function catalogRouteHref(
if (route.view === 'collection') {
return `${base}collections/${encodeURIComponent(route.collectionId)}/`
}
+ if (route.view === 'json') return `${base}json/`
if (route.view === 'case') {
return `${base}charts/${encodeURIComponent(route.caseId)}/`
}
diff --git a/examples/conformance/src/styles.css b/examples/conformance/src/styles.css
index a43ba5a1..16dedc91 100644
--- a/examples/conformance/src/styles.css
+++ b/examples/conformance/src/styles.css
@@ -73,7 +73,8 @@ a {
button,
input,
-select {
+select,
+textarea {
font: inherit;
}
@@ -955,6 +956,226 @@ body.shadcn-catalog {
color: var(--accent);
}
+.json-demo-page {
+ width: min(100% - 32px, 1680px);
+ margin-inline: auto;
+ padding-bottom: 80px;
+}
+
+.json-demo-heading {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 48px 0 28px;
+}
+
+.json-demo-heading h1 {
+ margin: 0;
+ font-size: clamp(2.3rem, 5vw, 4.8rem);
+}
+
+.json-demo-heading > button {
+ flex: none;
+}
+
+.json-workbench {
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ background: var(--panel);
+ box-shadow: 0 18px 60px rgb(15 23 42 / 7%);
+}
+
+.json-demo-surface {
+ display: grid;
+ grid-template-columns: minmax(0, 1.04fr) minmax(420px, 0.96fr);
+}
+
+.json-demo-editor,
+.json-demo-preview {
+ min-width: 0;
+}
+
+.json-demo-editor {
+ display: flex;
+ flex-direction: column;
+ border-right: 1px solid var(--border);
+ background: var(--panel-muted);
+}
+
+.json-demo-panel-header {
+ display: flex;
+ min-height: 66px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--border);
+}
+
+.json-demo-panel-header h2 {
+ margin: 0;
+ font-size: 0.9rem;
+ letter-spacing: -0.015em;
+}
+
+.json-demo-editor .json-demo-panel-header h2 {
+ flex: none;
+ white-space: nowrap;
+}
+
+.json-demo-panel-header p {
+ margin: 4px 0 0;
+ color: var(--muted);
+ font-size: 0.75rem;
+ line-height: 1.4;
+}
+
+.json-demo-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.json-demo-actions button,
+.json-demo-actions select,
+.json-demo-preview .json-demo-panel-header > button {
+ flex: none;
+ min-height: 34px;
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
+.json-demo-actions button,
+.json-demo-actions select {
+ padding-inline: 10px;
+}
+
+.json-demo-preview .json-demo-panel-header > button:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+.json-demo-actions .json-demo-apply {
+ border-color: var(--accent);
+ color: #fff;
+ background: #2563eb;
+}
+
+.json-demo-editor textarea {
+ display: block;
+ width: 100%;
+ min-height: 590px;
+ flex: 1;
+ resize: vertical;
+ border: 0;
+ border-radius: 0;
+ outline: 0;
+ color: #dbe7ff;
+ background: #0d1320;
+ padding: 18px;
+ font:
+ 0.78rem/1.58 ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ Consolas,
+ monospace;
+ tab-size: 2;
+}
+
+.json-demo-editor textarea:focus-visible {
+ box-shadow: inset 0 0 0 2px var(--accent);
+}
+
+.json-demo-editor textarea[aria-invalid='true'] {
+ box-shadow: inset 0 0 0 2px #dc2626;
+}
+
+.json-demo-status {
+ display: block;
+ min-height: 42px;
+ padding: 13px 18px;
+ border-top: 1px solid var(--border);
+ color: var(--muted);
+ background: var(--panel);
+ font:
+ 0.72rem ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ monospace;
+}
+
+.json-demo-issues {
+ max-height: 220px;
+ overflow: auto;
+ border-top: 1px solid #dc2626;
+ background: color-mix(in srgb, #dc2626 7%, var(--panel));
+}
+
+.json-demo-issues ol {
+ display: grid;
+ gap: 12px;
+ margin: 0;
+ padding: 14px 18px 16px 36px;
+}
+
+.json-demo-issues li > div {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.json-demo-issues code {
+ color: #b42318;
+ font-size: 0.72rem;
+ font-weight: 700;
+}
+
+:root[data-theme='dark'] .json-demo-issues code {
+ color: #ff9b91;
+}
+
+.json-demo-issues p {
+ margin: 5px 0 0;
+ color: var(--muted);
+ font-size: 0.76rem;
+ line-height: 1.45;
+}
+
+.json-demo-preview {
+ align-self: start;
+ background: var(--panel);
+}
+
+.json-demo-chart {
+ min-height: 400px;
+ padding: 24px 18px 18px;
+}
+
+.json-demo-chart .ts-chart-host {
+ max-width: 100%;
+}
+
+.json-demo-preview-error {
+ display: grid;
+ min-height: 320px;
+ place-items: center;
+ margin: 0;
+ padding: 32px;
+ color: #b42318;
+ text-align: center;
+}
+
+button:focus-visible,
+input:focus-visible,
+select:focus-visible,
+a:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
html.embed-mode,
body.embed-mode {
min-width: 0;
@@ -1072,4 +1293,45 @@ body.embed-mode {
.not-found {
width: min(100% - 20px, 1680px);
}
+
+ .site-header nav {
+ gap: 2px;
+ }
+
+ .site-header nav a {
+ padding-inline: 6px;
+ font-size: 0.75rem;
+ }
+
+ .json-demo-page {
+ width: min(100% - 20px, 1680px);
+ }
+
+ .json-demo-heading {
+ align-items: stretch;
+ flex-direction: column;
+ padding-top: 38px;
+ }
+
+ .json-demo-heading > button {
+ align-self: start;
+ }
+
+ .json-demo-surface {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .json-demo-editor {
+ border-right: 0;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .json-demo-editor textarea {
+ min-height: 480px;
+ }
+
+ .json-demo-chart {
+ min-height: 280px;
+ padding-inline: 10px;
+ }
}
diff --git a/llms.txt b/llms.txt
index cb2b658c..1591e60f 100644
--- a/llms.txt
+++ b/llms.txt
@@ -61,6 +61,7 @@ Read the canonical pages below. Each concept is documented once; guides and exam
- docs/examples/themes-and-motion.md — Themes and Motion: Apply inherited palettes and optional motion while keeping chart definitions separate from card layout and controls.
- docs/reference/index.md — API Reference: Reference for TanStack Charts definitions, marks, scales, runtime, rendering, interaction, and framework adapters.
- docs/reference/chart-definitions.md — Chart Definition API: Reference static and responsive chart definitions, build context, and identity-based updates.
+- docs/reference/json-interchange.md — Chart JSON: Author, validate, and render the fixed TanStack Charts JSON interchange format.
- docs/reference/chart-spec.md — Chart Spec: Reference for marks, axes, color, gradients, clipping, margins, guides, and themes in a TanStack Charts spec.
- docs/reference/view-composition.md — View Composition: Compose complete chart definitions with deterministic grid, layer, fill, and inset layouts.
- docs/reference/transforms.md — Data Transforms: Typed, eager transforms for reusable application data.
diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md
index 61dfd381..6048fe4c 100644
--- a/packages/charts-core/docs/comparison.md
+++ b/packages/charts-core/docs/comparison.md
@@ -12,14 +12,14 @@ turning untested behavior into a checkmark.
| Library | Package | Measured source |
| -------------------------------------------------------------------------------------- | -------------------- | ------------------- |
-| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `91e2eef` |
+| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `d6644db` |
| [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` |
| [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` |
| [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` |
| [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` |
The competitor versions are exact package pins, not latest versions inferred
-at page render time. The measured TanStack workspace revision is `91e2eef`.
+at page render time. The measured TanStack workspace revision is `d6644db`.
## Capability matrix
@@ -90,7 +90,7 @@ output model.
## Bundle snapshot
-Baseline date: `2026-08-15`.
+Baseline date: `2026-08-20`.
Controlled ranges cover 12 independently built, minified browser consumers:
line, bar, area, and scatter at basic, interactive, and advanced tiers. Only
diff --git a/packages/charts-core/docs/concepts/chart-definitions.md b/packages/charts-core/docs/concepts/chart-definitions.md
index 065e8121..4809a448 100644
--- a/packages/charts-core/docs/concepts/chart-definitions.md
+++ b/packages/charts-core/docs/concepts/chart-definitions.md
@@ -158,3 +158,10 @@ host.update({
Charts owns surface measurement, scene construction, and keyed reconciliation.
It does not own application equality or data reactivity.
+
+## Definitions from Chart JSON
+
+`chartFromJson()` interprets callback-free JSON as the same `ChartDefinition`
+used everywhere else. Host data may replace bundled data while callbacks and
+interaction remain application code. See
+[Chart JSON](../reference/json-interchange.md).
diff --git a/packages/charts-core/docs/config.json b/packages/charts-core/docs/config.json
index 72d0734a..94df8009 100644
--- a/packages/charts-core/docs/config.json
+++ b/packages/charts-core/docs/config.json
@@ -1069,6 +1069,10 @@
"label": "Chart Definition API",
"to": "reference/chart-definitions"
},
+ {
+ "label": "Chart JSON",
+ "to": "reference/json-interchange"
+ },
{
"label": "Chart Spec",
"to": "reference/chart-spec"
diff --git a/packages/charts-core/docs/guides/ai-authoring.md b/packages/charts-core/docs/guides/ai-authoring.md
index 77590fdc..443e1c96 100644
--- a/packages/charts-core/docs/guides/ai-authoring.md
+++ b/packages/charts-core/docs/guides/ai-authoring.md
@@ -7,6 +7,13 @@ TanStack Charts uses a small grammar so an agent can reason from data and
intent instead of selecting a monolithic chart component. The safest authoring
path is explicit and repeatable.
+## Choose code or Chart JSON
+
+Ask for TypeScript when the result must include application callbacks,
+framework composition, or custom renderer code. Ask for Chart JSON when the
+application should validate and interpret a model's output. Give the model the
+fixed schema; see [Chart JSON](../reference/json-interchange.md).
+
## The authoring sequence
1. State the analytical question in one sentence.
@@ -37,7 +44,9 @@ Use one documentation owner for each decision:
- large-data representation:
[Large Data](./large-data.md);
- exact signatures and options:
- [API Reference](../reference/index.md).
+ [API Reference](../reference/index.md);
+- fixed Chart JSON schema and interpreter:
+ [Chart JSON](../reference/json-interchange.md).
Do not reconstruct an API from an example when the reference owns the
signature. Do not restate D3 behavior when the D3 bridge links to its
diff --git a/packages/charts-core/docs/reference/index.md b/packages/charts-core/docs/reference/index.md
index af31ef73..1fe8378b 100644
--- a/packages/charts-core/docs/reference/index.md
+++ b/packages/charts-core/docs/reference/index.md
@@ -14,6 +14,7 @@ and application-owned interaction.
| Area | Reference |
| ----------------------------------------------------------- | --------------------------------------------------------- |
| Object and responsive definitions | [Chart Definition API](./chart-definitions.md) |
+| Public JSON format, interpreter, operations, and versions | [Chart JSON](./json-interchange.md) |
| The `ChartSpec` object | [Chart spec](./chart-spec.md) |
| Positional scales, axes, color, legends, and gradients | [Scales, guides, and color](./scales-guides-and-color.md) |
| Vanilla DOM mounting and responsive sizing | [DOM host](./dom-host.md) |
@@ -94,6 +95,7 @@ aggregate `/scales` export.
| Import | Public values |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@tanstack/charts` | Common marks including `crosshair`, legends, D3 curve bridges, definitions, runtime, scene, DOM host, static SVG, and focus-presentation helpers |
+| `@tanstack/charts/json` | `chartFromJson`, the fixed schema, version, structured error, and public types |
| `@tanstack/charts/scales/band` | `scaleBand`, `BandScale` |
| `@tanstack/charts/scales/linear` | `scaleLinear`, `LinearScale` |
| `@tanstack/charts/scales/ordinal` | `scaleOrdinal`, `OrdinalScale` |
diff --git a/packages/charts-core/docs/reference/json-interchange.md b/packages/charts-core/docs/reference/json-interchange.md
new file mode 100644
index 00000000..aeaae71e
--- /dev/null
+++ b/packages/charts-core/docs/reference/json-interchange.md
@@ -0,0 +1,233 @@
+---
+title: Chart JSON
+description: Author, validate, and render the fixed TanStack Charts JSON interchange format.
+---
+
+Chart JSON is a callback-free interchange format for common Cartesian and
+circular charts. The schema and interpreter ship from `@tanstack/charts/json`.
+
+## Example
+
+```json
+{
+ "$schema": "https://unpkg.com/@tanstack/charts@0.14.0/schemas/chart.json",
+ "chartsVersion": "0.14.0",
+ "spec": {
+ "marks": [
+ {
+ "$call": "tanstack.mark.bar-y",
+ "data": { "$data": "rows" },
+ "x": "category",
+ "y": "value"
+ }
+ ],
+ "x": { "scale": { "$call": "tanstack.scale.band" } },
+ "y": {
+ "scale": { "$call": "tanstack.scale.linear" },
+ "nice": true,
+ "grid": true
+ }
+ },
+ "data": {
+ "rows": [
+ { "category": "Alpha", "value": 8 },
+ { "category": "Beta", "value": 13 },
+ { "category": "Gamma", "value": 5 }
+ ]
+ },
+ "metadata": {
+ "title": "Category totals",
+ "description": "Values for Alpha, Beta, and Gamma."
+ }
+}
+```
+
+The envelope is closed:
+
+| Property | Contract |
+| --------------- | --------------------------------------------------------------- |
+| `$schema` | Optional canonical schema URL for the authored `chartsVersion` |
+| `chartsVersion` | Required TanStack Charts SemVer |
+| `spec` | Required marks; Cartesian specs also require `x` and `y` axes |
+| `data` | Optional named JSON row arrays used by mark `data` references |
+| `metadata` | Optional `title` and `description` for host-accessible labeling |
+
+There is one envelope. A producer may bundle `data`; a host may supply or
+replace it when interpreting the same source.
+
+## Expressions
+
+`$data` reads a named row array. It is valid only as a mark's `data` argument:
+
+```json
+{ "$data": "rows" }
+```
+
+`$call` invokes one fixed operation. Arguments are sibling properties, not a
+nested `args` object:
+
+```json
+{
+ "$call": "tanstack.mark.line-y",
+ "data": { "$data": "rows" },
+ "x": {
+ "$call": "tanstack.accessor.iso-date",
+ "field": "recordedAt"
+ },
+ "y": "revenue"
+}
+```
+
+Expression objects are exact. Ordinary expression keys cannot start with `$`.
+Objects inside `data` are opaque and may contain reserved-looking keys.
+
+## Fixed operations
+
+The fixed dialect contains 16 operations:
+
+| ID | Produces |
+| ---------------------------- | ------------------------------ |
+| `tanstack.accessor.iso-date` | Strict ISO-date field accessor |
+| `tanstack.legend.color` | Categorical color legend |
+| `tanstack.layout.group` | Grouped interval layout |
+| `tanstack.mark.area-y` | Vertical area mark |
+| `tanstack.mark.bar-x` | Horizontal bar mark |
+| `tanstack.mark.bar-y` | Vertical bar mark |
+| `tanstack.mark.dot` | Dot mark |
+| `tanstack.mark.line-y` | Vertical line mark |
+| `tanstack.mark.pie` | Pie or donut mark |
+| `tanstack.mark.rule-x` | Vertical full-plot rule |
+| `tanstack.mark.rule-y` | Horizontal full-plot rule |
+| `tanstack.mark.text` | Positioned text labels |
+| `tanstack.scale.band` | Band scale |
+| `tanstack.scale.linear` | Linear scale |
+| `tanstack.scale.point` | Point scale |
+| `tanstack.scale.utc` | UTC time scale |
+
+`chartJsonSchema` is the authoritative contract for each operation's named
+arguments and allowed placement. Unknown calls and arguments are rejected.
+
+`rule-x` draws a vertical rule at each x value, while `rule-y` draws a
+horizontal rule at each y value. Layer `text` with either rule and supply its
+label coordinates as ordinary data. Targets, event dates, and label rows are
+prepared data; the operations do not calculate them.
+
+A spec is either Cartesian or circular. A circular spec contains one
+`tanstack.mark.pie` mark and omits `x`, `y`, and `guides`; it cannot mix the pie
+mark with Cartesian marks. Set `innerRadiusRatio` to `0` for a pie or greater
+than `0` and less than `1` for a donut.
+
+## Interpret JSON
+
+```ts
+import {
+ ChartJsonError,
+ chartFromJson,
+ chartJsonSchema,
+} from '@tanstack/charts/json'
+import { createChartScene } from '@tanstack/charts'
+import { renderChartSvg } from '@tanstack/charts/svg'
+
+try {
+ const definition = chartFromJson(jsonText)
+ const scene = createChartScene(definition, { width: 640, height: 400 })
+ const svg = renderChartSvg(scene, {
+ ariaLabel: definition.metadata?.title ?? 'Chart',
+ })
+} catch (error) {
+ if (error instanceof ChartJsonError) {
+ console.error(error.issues)
+ } else {
+ throw error
+ }
+}
+```
+
+`chartFromJson()` accepts JSON text and returns an ordinary `ChartDefinition`.
+It parses, validates, and evaluates the fixed operations synchronously. A
+`ChartJsonError` contains structured issues with a stable code and JSON Pointer
+path. Its human-readable message is diagnostic text, not a compatibility key.
+
+Host data replaces bundled values with the same name:
+
+```ts
+const definition = chartFromJson(jsonText, {
+ data: { rows: preparedRows },
+})
+```
+
+This boundary keeps filtering, joins, database work, and application objects
+in host code. The host may also add callbacks and interactions to the returned
+definition:
+
+```ts
+const parsed = chartFromJson(jsonText, { data: { rows: preparedRows } })
+const definition = {
+ ...parsed,
+ focus: 'nearest-x' as const,
+ pointer: true,
+ keyboard: true,
+}
+```
+
+Navigation, mutation handlers, controlled state, portal targets, and custom
+renderers are not part of Chart JSON.
+
+## Give the contract to a model
+
+Send `chartJsonSchema` with the analytical question and field meanings. Ask
+for one JSON value without Markdown or commentary, and require the schema's
+exact `chartsVersion`.
+
+```ts
+const modelInput = {
+ question: 'Show daily revenue over time.',
+ fields: {
+ recordedAt: 'ISO 8601 UTC timestamp',
+ revenue: 'US dollars',
+ },
+ schema: chartJsonSchema,
+}
+
+const definition = chartFromJson(modelResponseText, {
+ data: { rows: revenueRows },
+})
+```
+
+Structural validation cannot determine whether a chart answers the analytical
+question. The receiving application remains responsible for reviewing the
+chosen fields, encodings, accessible description, and host behavior.
+
+## Versions
+
+`chartsVersion` follows the `@tanstack/charts` package version. By default,
+the reader accepts supported earlier dialect versions and rejects future
+versions. Before 1.0, author and reader versions must both use major `0`;
+after 1.0, their majors must match.
+
+Use exact matching when the producer and reader must run the same release:
+
+```ts
+const definition = chartFromJson(jsonText, { exactVersion: true })
+```
+
+The published schema is also available as `chartJsonSchema`; its `$id` is the
+immutable release URL.
+
+## Public API
+
+Runtime values are `chartFromJson`, `chartJsonSchema`, `chartJsonVersion`, and
+`ChartJsonError`. Public types are `ChartJson`, `ChartJsonDefinition`,
+`ChartFromJsonOptions`, `ChartJsonMetadata`, `ChartJsonIssue`,
+and `ChartJsonIssueCode`.
+
+## Workbench
+
+Run the editable React workbench:
+
+```sh
+pnpm --filter @charts-poc/conformance-example dev
+```
+
+Open `http://localhost:5194/json/`. The workbench keeps the last valid preview
+after rejected edits and demonstrates host data replacement.
diff --git a/packages/charts-core/llms.txt b/packages/charts-core/llms.txt
index cb2b658c..1591e60f 100644
--- a/packages/charts-core/llms.txt
+++ b/packages/charts-core/llms.txt
@@ -61,6 +61,7 @@ Read the canonical pages below. Each concept is documented once; guides and exam
- docs/examples/themes-and-motion.md — Themes and Motion: Apply inherited palettes and optional motion while keeping chart definitions separate from card layout and controls.
- docs/reference/index.md — API Reference: Reference for TanStack Charts definitions, marks, scales, runtime, rendering, interaction, and framework adapters.
- docs/reference/chart-definitions.md — Chart Definition API: Reference static and responsive chart definitions, build context, and identity-based updates.
+- docs/reference/json-interchange.md — Chart JSON: Author, validate, and render the fixed TanStack Charts JSON interchange format.
- docs/reference/chart-spec.md — Chart Spec: Reference for marks, axes, color, gradients, clipping, margins, guides, and themes in a TanStack Charts spec.
- docs/reference/view-composition.md — View Composition: Compose complete chart definitions with deterministic grid, layer, fill, and inset layouts.
- docs/reference/transforms.md — Data Transforms: Typed, eager transforms for reusable application data.
diff --git a/packages/charts-core/package.json b/packages/charts-core/package.json
index 48ce3b47..8ac4deb7 100644
--- a/packages/charts-core/package.json
+++ b/packages/charts-core/package.json
@@ -19,7 +19,8 @@
"skills",
"LICENSE",
"README.md",
- "llms.txt"
+ "llms.txt",
+ "schemas"
],
"dependencies": {
"@types/d3-force": "^3.0.10",
@@ -149,6 +150,7 @@
"./interaction/cursor": "./src/interaction-cursor.ts",
"./interaction/handle": "./src/interaction-handle.ts",
"./interaction/zoom": "./src/interaction-zoom.ts",
+ "./json": "./src/json/index.ts",
"./legend": "./src/legend.ts",
"./line": "./src/line.ts",
"./link": "./src/link.ts",
@@ -385,6 +387,10 @@
"types": "./dist/interaction-zoom.d.ts",
"import": "./dist/interaction-zoom.js"
},
+ "./json": {
+ "types": "./dist/json/index.d.ts",
+ "import": "./dist/json/index.js"
+ },
"./legend": {
"types": "./dist/legend.d.ts",
"import": "./dist/legend.js"
diff --git a/packages/charts-core/schemas/chart.json b/packages/charts-core/schemas/chart.json
new file mode 100644
index 00000000..64818a3a
--- /dev/null
+++ b/packages/charts-core/schemas/chart.json
@@ -0,0 +1,984 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://unpkg.com/@tanstack/charts@0.14.0/schemas/chart.json",
+ "title": "TanStack Charts JSON",
+ "type": "object",
+ "properties": {
+ "$schema": {
+ "const": "https://unpkg.com/@tanstack/charts@0.14.0/schemas/chart.json"
+ },
+ "chartsVersion": {
+ "const": "0.14.0"
+ },
+ "spec": {
+ "oneOf": [
+ {
+ "title": "Cartesian chart",
+ "type": "object",
+ "properties": {
+ "marks": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "$ref": "#/$defs/result:mark:cartesian"
+ }
+ },
+ "x": {
+ "type": "object",
+ "properties": {
+ "scale": {
+ "$ref": "#/$defs/result:scale"
+ },
+ "nice": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "number"
+ }
+ ]
+ },
+ "reverse": {
+ "type": "boolean"
+ },
+ "grid": {
+ "type": "boolean"
+ },
+ "axis": {
+ "const": false
+ }
+ },
+ "required": ["scale"],
+ "additionalProperties": false
+ },
+ "y": {
+ "type": "object",
+ "properties": {
+ "scale": {
+ "$ref": "#/$defs/result:scale"
+ },
+ "nice": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "number"
+ }
+ ]
+ },
+ "reverse": {
+ "type": "boolean"
+ },
+ "grid": {
+ "type": "boolean"
+ },
+ "axis": {
+ "const": false
+ }
+ },
+ "required": ["scale"],
+ "additionalProperties": false
+ },
+ "guides": {
+ "type": "boolean"
+ },
+ "color": {
+ "type": "object",
+ "properties": {
+ "domain": {
+ "type": "array",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ }
+ ]
+ }
+ },
+ "range": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "legend": {
+ "$ref": "#/$defs/result:legend"
+ }
+ },
+ "required": [],
+ "additionalProperties": false
+ },
+ "clip": {
+ "type": "boolean"
+ },
+ "margin": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "top": {
+ "type": "number"
+ },
+ "right": {
+ "type": "number"
+ },
+ "bottom": {
+ "type": "number"
+ },
+ "left": {
+ "type": "number"
+ }
+ },
+ "required": [],
+ "additionalProperties": false
+ }
+ ]
+ }
+ },
+ "required": ["marks", "x", "y"],
+ "additionalProperties": false
+ },
+ {
+ "title": "Circular chart",
+ "type": "object",
+ "properties": {
+ "marks": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 1,
+ "items": {
+ "$ref": "#/$defs/result:mark:circular"
+ }
+ },
+ "color": {
+ "type": "object",
+ "properties": {
+ "domain": {
+ "type": "array",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ }
+ ]
+ }
+ },
+ "range": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "legend": {
+ "$ref": "#/$defs/result:legend"
+ }
+ },
+ "required": [],
+ "additionalProperties": false
+ },
+ "clip": {
+ "type": "boolean"
+ },
+ "margin": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "top": {
+ "type": "number"
+ },
+ "right": {
+ "type": "number"
+ },
+ "bottom": {
+ "type": "number"
+ },
+ "left": {
+ "type": "number"
+ }
+ },
+ "required": [],
+ "additionalProperties": false
+ }
+ ]
+ }
+ },
+ "required": ["marks"],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "data": {
+ "type": "object",
+ "propertyNames": {
+ "minLength": 1,
+ "not": {
+ "pattern": "^\\$"
+ }
+ },
+ "additionalProperties": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/json"
+ }
+ }
+ },
+ "metadata": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "required": [],
+ "additionalProperties": false
+ }
+ },
+ "required": ["chartsVersion", "spec"],
+ "additionalProperties": false,
+ "$defs": {
+ "json": {
+ "anyOf": [
+ {
+ "type": "null"
+ },
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "number"
+ },
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/json"
+ }
+ },
+ {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/$defs/json"
+ }
+ }
+ ]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "$data": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "required": ["$data"],
+ "additionalProperties": false
+ },
+ "argument:nonempty-string": {
+ "type": "string",
+ "minLength": 1
+ },
+ "argument:path": {
+ "anyOf": [
+ {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^[^.]+(?:\\.[^.]+)*$"
+ },
+ {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ ]
+ },
+ "argument:string": {
+ "type": "string"
+ },
+ "argument:nonnegative-number": {
+ "type": "number",
+ "minimum": 0
+ },
+ "argument:placement": {
+ "enum": ["top", "bottom"]
+ },
+ "argument:scale-band": {
+ "$ref": "#/$defs/operation:tanstack.scale.band"
+ },
+ "argument:data": {
+ "$ref": "#/$defs/data"
+ },
+ "argument:channel": {
+ "anyOf": [
+ {
+ "type": "string",
+ "minLength": 1
+ },
+ {
+ "$ref": "#/$defs/result:accessor"
+ }
+ ]
+ },
+ "argument:numeric-channel": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "string",
+ "minLength": 1
+ },
+ {
+ "$ref": "#/$defs/result:accessor"
+ }
+ ]
+ },
+ "argument:opacity": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "argument:layout": {
+ "$ref": "#/$defs/result:layout"
+ },
+ "argument:finite-number": {
+ "type": "number"
+ },
+ "argument:boolean": {
+ "type": "boolean"
+ },
+ "argument:text-anchor": {
+ "enum": ["start", "middle", "end"]
+ },
+ "argument:ratio": {
+ "type": "number",
+ "minimum": 0,
+ "exclusiveMaximum": 1
+ },
+ "operation:tanstack.accessor.iso-date": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.accessor.iso-date"
+ },
+ "field": {
+ "$ref": "#/$defs/argument:nonempty-string"
+ },
+ "path": {
+ "$ref": "#/$defs/argument:path"
+ }
+ },
+ "required": ["$call"],
+ "additionalProperties": false,
+ "description": "Parses an ISO date from a field, path, or value.",
+ "allOf": [
+ {
+ "not": {
+ "required": ["field", "path"]
+ }
+ }
+ ]
+ },
+ "operation:tanstack.legend.color": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.legend.color"
+ },
+ "label": {
+ "$ref": "#/$defs/argument:string"
+ },
+ "itemWidth": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ },
+ "width": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ },
+ "placement": {
+ "$ref": "#/$defs/argument:placement"
+ }
+ },
+ "required": ["$call"],
+ "additionalProperties": false,
+ "description": "Creates a categorical color legend."
+ },
+ "operation:tanstack.layout.group": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.layout.group"
+ },
+ "scale": {
+ "$ref": "#/$defs/argument:scale-band"
+ },
+ "padding": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ }
+ },
+ "required": ["$call"],
+ "additionalProperties": false,
+ "description": "Creates a grouped interval layout."
+ },
+ "operation:tanstack.mark.area-y": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.mark.area-y"
+ },
+ "data": {
+ "$ref": "#/$defs/argument:data"
+ },
+ "id": {
+ "$ref": "#/$defs/argument:nonempty-string"
+ },
+ "x": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "y": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "z": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "color": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "key": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "y1": {
+ "$ref": "#/$defs/argument:numeric-channel"
+ },
+ "y2": {
+ "$ref": "#/$defs/argument:numeric-channel"
+ },
+ "fill": {
+ "$ref": "#/$defs/argument:string"
+ },
+ "fillOpacity": {
+ "$ref": "#/$defs/argument:opacity"
+ },
+ "stroke": {
+ "$ref": "#/$defs/argument:string"
+ },
+ "strokeWidth": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ }
+ },
+ "required": ["$call", "data"],
+ "additionalProperties": false,
+ "description": "Creates an area-y mark."
+ },
+ "operation:tanstack.mark.bar-x": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.mark.bar-x"
+ },
+ "data": {
+ "$ref": "#/$defs/argument:data"
+ },
+ "id": {
+ "$ref": "#/$defs/argument:nonempty-string"
+ },
+ "x": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "y": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "z": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "color": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "key": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "x1": {
+ "$ref": "#/$defs/argument:numeric-channel"
+ },
+ "x2": {
+ "$ref": "#/$defs/argument:numeric-channel"
+ },
+ "fill": {
+ "$ref": "#/$defs/argument:string"
+ },
+ "fillOpacity": {
+ "$ref": "#/$defs/argument:opacity"
+ },
+ "stroke": {
+ "$ref": "#/$defs/argument:string"
+ },
+ "strokeWidth": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ },
+ "layout": {
+ "$ref": "#/$defs/argument:layout"
+ },
+ "inset": {
+ "$ref": "#/$defs/argument:finite-number"
+ },
+ "maxThickness": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ },
+ "radius": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ }
+ },
+ "required": ["$call", "data"],
+ "additionalProperties": false,
+ "description": "Creates a bar-x mark."
+ },
+ "operation:tanstack.mark.bar-y": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.mark.bar-y"
+ },
+ "data": {
+ "$ref": "#/$defs/argument:data"
+ },
+ "id": {
+ "$ref": "#/$defs/argument:nonempty-string"
+ },
+ "x": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "y": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "z": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "color": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "key": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "y1": {
+ "$ref": "#/$defs/argument:numeric-channel"
+ },
+ "y2": {
+ "$ref": "#/$defs/argument:numeric-channel"
+ },
+ "fill": {
+ "$ref": "#/$defs/argument:string"
+ },
+ "fillOpacity": {
+ "$ref": "#/$defs/argument:opacity"
+ },
+ "stroke": {
+ "$ref": "#/$defs/argument:string"
+ },
+ "strokeWidth": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ },
+ "layout": {
+ "$ref": "#/$defs/argument:layout"
+ },
+ "inset": {
+ "$ref": "#/$defs/argument:finite-number"
+ },
+ "maxThickness": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ },
+ "radius": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ }
+ },
+ "required": ["$call", "data"],
+ "additionalProperties": false,
+ "description": "Creates a bar-y mark."
+ },
+ "operation:tanstack.mark.dot": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.mark.dot"
+ },
+ "data": {
+ "$ref": "#/$defs/argument:data"
+ },
+ "id": {
+ "$ref": "#/$defs/argument:nonempty-string"
+ },
+ "x": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "y": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "z": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "color": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "key": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "r": {
+ "$ref": "#/$defs/argument:numeric-channel"
+ },
+ "fill": {
+ "$ref": "#/$defs/argument:string"
+ },
+ "fillOpacity": {
+ "$ref": "#/$defs/argument:opacity"
+ },
+ "stroke": {
+ "$ref": "#/$defs/argument:string"
+ },
+ "strokeWidth": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ },
+ "strokeOpacity": {
+ "$ref": "#/$defs/argument:opacity"
+ }
+ },
+ "required": ["$call", "data"],
+ "additionalProperties": false,
+ "description": "Creates a dot mark."
+ },
+ "operation:tanstack.mark.line-y": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.mark.line-y"
+ },
+ "data": {
+ "$ref": "#/$defs/argument:data"
+ },
+ "id": {
+ "$ref": "#/$defs/argument:nonempty-string"
+ },
+ "x": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "y": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "z": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "color": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "key": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "stroke": {
+ "$ref": "#/$defs/argument:string"
+ },
+ "strokeOpacity": {
+ "$ref": "#/$defs/argument:opacity"
+ },
+ "strokeWidth": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ },
+ "strokeDasharray": {
+ "$ref": "#/$defs/argument:string"
+ },
+ "points": {
+ "$ref": "#/$defs/argument:boolean"
+ }
+ },
+ "required": ["$call", "data"],
+ "additionalProperties": false,
+ "description": "Creates a line-y mark."
+ },
+ "operation:tanstack.mark.rule-x": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.mark.rule-x"
+ },
+ "data": {
+ "$ref": "#/$defs/argument:data"
+ },
+ "id": {
+ "$ref": "#/$defs/argument:nonempty-string"
+ },
+ "x": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "color": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "stroke": {
+ "$ref": "#/$defs/argument:string"
+ },
+ "strokeOpacity": {
+ "$ref": "#/$defs/argument:opacity"
+ },
+ "strokeWidth": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ },
+ "strokeDasharray": {
+ "$ref": "#/$defs/argument:string"
+ }
+ },
+ "required": ["$call", "data"],
+ "additionalProperties": false,
+ "description": "Creates a vertical reference rule spanning the plot."
+ },
+ "operation:tanstack.mark.rule-y": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.mark.rule-y"
+ },
+ "data": {
+ "$ref": "#/$defs/argument:data"
+ },
+ "id": {
+ "$ref": "#/$defs/argument:nonempty-string"
+ },
+ "y": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "color": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "stroke": {
+ "$ref": "#/$defs/argument:string"
+ },
+ "strokeOpacity": {
+ "$ref": "#/$defs/argument:opacity"
+ },
+ "strokeWidth": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ },
+ "strokeDasharray": {
+ "$ref": "#/$defs/argument:string"
+ }
+ },
+ "required": ["$call", "data"],
+ "additionalProperties": false,
+ "description": "Creates a horizontal reference rule spanning the plot."
+ },
+ "operation:tanstack.mark.text": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.mark.text"
+ },
+ "data": {
+ "$ref": "#/$defs/argument:data"
+ },
+ "id": {
+ "$ref": "#/$defs/argument:nonempty-string"
+ },
+ "x": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "y": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "z": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "color": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "key": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "text": {
+ "$ref": "#/$defs/argument:channel"
+ },
+ "fill": {
+ "$ref": "#/$defs/argument:string"
+ },
+ "fontSize": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ },
+ "fontWeight": {
+ "$ref": "#/$defs/argument:nonnegative-number"
+ },
+ "anchor": {
+ "$ref": "#/$defs/argument:text-anchor"
+ },
+ "rotate": {
+ "$ref": "#/$defs/argument:finite-number"
+ },
+ "dx": {
+ "$ref": "#/$defs/argument:finite-number"
+ },
+ "dy": {
+ "$ref": "#/$defs/argument:finite-number"
+ }
+ },
+ "required": ["$call", "data"],
+ "additionalProperties": false,
+ "description": "Creates positioned text labels; x, y, and text name row fields, while presentation arguments are fixed values."
+ },
+ "operation:tanstack.mark.pie": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.mark.pie"
+ },
+ "data": {
+ "$ref": "#/$defs/argument:data"
+ },
+ "value": {
+ "$ref": "#/$defs/argument:nonempty-string"
+ },
+ "category": {
+ "$ref": "#/$defs/argument:nonempty-string"
+ },
+ "id": {
+ "$ref": "#/$defs/argument:nonempty-string"
+ },
+ "innerRadiusRatio": {
+ "$ref": "#/$defs/argument:ratio"
+ }
+ },
+ "required": ["$call", "data", "value", "category"],
+ "additionalProperties": false,
+ "description": "Creates one pie or donut from pre-aggregated rows; value names a nonnegative numeric field, category names a string or finite-number color field, and innerRadiusRatio defaults to 0 and must be in [0, 1)."
+ },
+ "operation:tanstack.scale.band": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.scale.band"
+ }
+ },
+ "required": ["$call"],
+ "additionalProperties": false,
+ "description": "Creates an inferable band scale."
+ },
+ "operation:tanstack.scale.linear": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.scale.linear"
+ }
+ },
+ "required": ["$call"],
+ "additionalProperties": false,
+ "description": "Creates an inferable linear scale."
+ },
+ "operation:tanstack.scale.point": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.scale.point"
+ }
+ },
+ "required": ["$call"],
+ "additionalProperties": false,
+ "description": "Creates an inferable point scale."
+ },
+ "operation:tanstack.scale.utc": {
+ "type": "object",
+ "properties": {
+ "$call": {
+ "const": "tanstack.scale.utc"
+ }
+ },
+ "required": ["$call"],
+ "additionalProperties": false,
+ "description": "Creates an inferable UTC scale."
+ },
+ "result:accessor": {
+ "oneOf": [
+ {
+ "$ref": "#/$defs/operation:tanstack.accessor.iso-date"
+ }
+ ]
+ },
+ "result:layout": {
+ "oneOf": [
+ {
+ "$ref": "#/$defs/operation:tanstack.layout.group"
+ }
+ ]
+ },
+ "result:legend": {
+ "oneOf": [
+ {
+ "$ref": "#/$defs/operation:tanstack.legend.color"
+ }
+ ]
+ },
+ "result:scale": {
+ "oneOf": [
+ {
+ "$ref": "#/$defs/operation:tanstack.scale.band"
+ },
+ {
+ "$ref": "#/$defs/operation:tanstack.scale.linear"
+ },
+ {
+ "$ref": "#/$defs/operation:tanstack.scale.point"
+ },
+ {
+ "$ref": "#/$defs/operation:tanstack.scale.utc"
+ }
+ ]
+ },
+ "result:mark:cartesian": {
+ "oneOf": [
+ {
+ "$ref": "#/$defs/operation:tanstack.mark.area-y"
+ },
+ {
+ "$ref": "#/$defs/operation:tanstack.mark.bar-x"
+ },
+ {
+ "$ref": "#/$defs/operation:tanstack.mark.bar-y"
+ },
+ {
+ "$ref": "#/$defs/operation:tanstack.mark.dot"
+ },
+ {
+ "$ref": "#/$defs/operation:tanstack.mark.line-y"
+ },
+ {
+ "$ref": "#/$defs/operation:tanstack.mark.rule-x"
+ },
+ {
+ "$ref": "#/$defs/operation:tanstack.mark.rule-y"
+ },
+ {
+ "$ref": "#/$defs/operation:tanstack.mark.text"
+ }
+ ]
+ },
+ "result:mark:circular": {
+ "oneOf": [
+ {
+ "$ref": "#/$defs/operation:tanstack.mark.pie"
+ }
+ ]
+ }
+ }
+}
diff --git a/packages/charts-core/schemas/example.json b/packages/charts-core/schemas/example.json
new file mode 100644
index 00000000..9a650920
--- /dev/null
+++ b/packages/charts-core/schemas/example.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://unpkg.com/@tanstack/charts@0.14.0/schemas/chart.json",
+ "chartsVersion": "0.14.0",
+ "spec": {
+ "marks": [
+ {
+ "$call": "tanstack.mark.bar-y",
+ "data": { "$data": "rows" },
+ "x": "category",
+ "y": "value"
+ }
+ ],
+ "x": { "scale": { "$call": "tanstack.scale.band" } },
+ "y": {
+ "scale": { "$call": "tanstack.scale.linear" },
+ "nice": true,
+ "grid": true
+ }
+ },
+ "data": {
+ "rows": [
+ { "category": "Alpha", "value": 8 },
+ { "category": "Beta", "value": 13 },
+ { "category": "Gamma", "value": 5 }
+ ]
+ },
+ "metadata": {
+ "title": "Category totals",
+ "description": "Values for Alpha, Beta, and Gamma."
+ }
+}
diff --git a/packages/charts-core/src/exports.test.ts b/packages/charts-core/src/exports.test.ts
index 6fbdbb2c..e423a6d8 100644
--- a/packages/charts-core/src/exports.test.ts
+++ b/packages/charts-core/src/exports.test.ts
@@ -108,6 +108,37 @@ describe('public package exports', () => {
expect(Object.keys(definitionModule)).toEqual(['stagger'])
})
+ it('keeps Chart JSON on one opt-in subpath', async () => {
+ const jsonSubpaths = ['./json']
+ expect(
+ Object.keys(packageJson.exports).filter((subpath) =>
+ subpath.startsWith('./json'),
+ ),
+ ).toEqual(jsonSubpaths)
+ expect(
+ Object.keys(packageJson.publishConfig.exports).filter((subpath) =>
+ subpath.startsWith('./json'),
+ ),
+ ).toEqual(jsonSubpaths)
+
+ const [root, universal, json] = await Promise.all([
+ import('@tanstack/charts'),
+ import('@tanstack/charts/universal'),
+ import('@tanstack/charts/json'),
+ ])
+
+ for (const name of ['chartFromJson', 'chartJsonSchema']) {
+ expect(root).not.toHaveProperty(name)
+ expect(universal).not.toHaveProperty(name)
+ }
+ expect(Object.keys(json).sort()).toEqual([
+ 'ChartJsonError',
+ 'chartFromJson',
+ 'chartJsonSchema',
+ 'chartJsonVersion',
+ ])
+ })
+
it('keeps focus guide marks on their exact subpath', async () => {
const [root, universal, guide] = await Promise.all([
import('@tanstack/charts'),
diff --git a/packages/charts-core/src/json/chart-from-json.test.ts b/packages/charts-core/src/json/chart-from-json.test.ts
new file mode 100644
index 00000000..9adaf749
--- /dev/null
+++ b/packages/charts-core/src/json/chart-from-json.test.ts
@@ -0,0 +1,813 @@
+import { describe, expect, expectTypeOf, it } from 'vitest'
+import { lineY } from '../line'
+import { pie, polar, radialArc } from '../polar'
+import { ruleX, ruleY } from '../rule'
+import { createChartScene, defineChart } from '../scene'
+import { scaleBand } from '../scales-band'
+import { scaleLinear } from '../scales-linear'
+import { text } from '../text'
+import type {
+ ChartDefinition,
+ ChartValue,
+ DomChartDefinition,
+ SceneNode,
+} from '../types'
+import { ChartJsonError } from './error'
+import { chartFromJson } from './chart-from-json'
+import { chartJsonOperations } from './operation-contracts'
+import { chartJsonSchema } from './schema'
+import { validateChartJsonVersion } from './version-compatibility'
+import { chartJsonVersion } from './version'
+
+const rows = [
+ { category: 'Alpha', value: 3 },
+ { category: 'Beta', value: 7 },
+]
+
+function lineSource(overrides: Record = {}): string {
+ return JSON.stringify({
+ chartsVersion: chartJsonVersion,
+ spec: {
+ marks: [
+ {
+ $call: 'tanstack.mark.line-y',
+ data: { $data: 'rows' },
+ x: 'category',
+ y: 'value',
+ },
+ ],
+ x: { scale: { $call: 'tanstack.scale.band' } },
+ y: { scale: { $call: 'tanstack.scale.linear' } },
+ },
+ ...overrides,
+ })
+}
+
+function pieSource(
+ mark: Record = {},
+ spec: Record = {},
+ data: readonly unknown[] = rows,
+): string {
+ return JSON.stringify({
+ chartsVersion: chartJsonVersion,
+ spec: {
+ marks: [
+ {
+ $call: 'tanstack.mark.pie',
+ data: { $data: 'rows' },
+ value: 'value',
+ category: 'category',
+ ...mark,
+ },
+ ],
+ color: {
+ domain: ['Alpha', 'Beta'],
+ range: ['#2563eb', '#f97316'],
+ },
+ ...spec,
+ },
+ data: { rows: data },
+ })
+}
+
+function issueFrom(source: string): ChartJsonError {
+ try {
+ chartFromJson(source)
+ } catch (error) {
+ expect(error).toBeInstanceOf(ChartJsonError)
+ return error as ChartJsonError
+ }
+ throw new Error('Expected Chart JSON to fail')
+}
+
+describe('chartFromJson', () => {
+ it('translates bundled JSON to an ordinary host-neutral definition', () => {
+ const metadata = {
+ title: 'Category totals',
+ description: 'Totals for Alpha and Beta.',
+ }
+ const definition = chartFromJson(lineSource({ data: { rows }, metadata }))
+ const portable = createChartScene(definition, {
+ width: 480,
+ height: 260,
+ })
+ const direct = createChartScene(
+ defineChart({
+ marks: [lineY(rows, { x: 'category', y: 'value' })],
+ x: { scale: scaleBand },
+ y: { scale: scaleLinear },
+ }),
+ { width: 480, height: 260 },
+ )
+
+ expect(portable.scales.x.domain).toEqual(direct.scales.x.domain)
+ expect(portable.scales.y.domain).toEqual(direct.scales.y.domain)
+ expect(portable.nodes).toEqual(direct.nodes)
+ expect(definition.metadata).toEqual(metadata)
+ expectTypeOf(definition).toMatchTypeOf<
+ ChartDefinition
+ >()
+ const domDefinition: DomChartDefinition = definition
+ void domDefinition
+ })
+
+ it('translates full-span reference rules like the direct mark API', () => {
+ const definition = chartFromJson(
+ JSON.stringify({
+ chartsVersion: chartJsonVersion,
+ spec: {
+ marks: [
+ {
+ $call: 'tanstack.mark.rule-x',
+ data: { $data: 'rows' },
+ id: 'events',
+ x: 'category',
+ color: 'category',
+ strokeOpacity: 0.8,
+ strokeWidth: 2,
+ strokeDasharray: '4 2',
+ },
+ {
+ $call: 'tanstack.mark.rule-y',
+ data: { $data: 'rows' },
+ id: 'targets',
+ y: 'value',
+ stroke: '#b91c1c',
+ strokeOpacity: 0.4,
+ strokeWidth: 3,
+ strokeDasharray: '2 3',
+ },
+ ],
+ x: { scale: { $call: 'tanstack.scale.band' } },
+ y: { scale: { $call: 'tanstack.scale.linear' } },
+ color: {
+ domain: ['Alpha', 'Beta'],
+ range: ['#2563eb', '#f97316'],
+ },
+ },
+ data: { rows },
+ }),
+ )
+ const portable = createChartScene(definition, {
+ width: 480,
+ height: 260,
+ })
+ const direct = createChartScene(
+ defineChart({
+ marks: [
+ ruleX(rows, {
+ id: 'events',
+ x: 'category',
+ color: 'category',
+ strokeOpacity: 0.8,
+ strokeWidth: 2,
+ strokeDasharray: '4 2',
+ }),
+ ruleY(rows, {
+ id: 'targets',
+ y: 'value',
+ stroke: '#b91c1c',
+ strokeOpacity: 0.4,
+ strokeWidth: 3,
+ strokeDasharray: '2 3',
+ }),
+ ],
+ x: { scale: scaleBand },
+ y: { scale: scaleLinear },
+ color: {
+ domain: ['Alpha', 'Beta'],
+ range: ['#2563eb', '#f97316'],
+ },
+ }),
+ { width: 480, height: 260 },
+ )
+
+ expect(portable.nodes).toEqual(direct.nodes)
+ expect(portable.scales.x.domain).toEqual(direct.scales.x.domain)
+ expect(portable.scales.y.domain).toEqual(direct.scales.y.domain)
+ })
+
+ it('preserves native scalar-datum fallback for reference rules', () => {
+ const portable = createChartScene(
+ chartFromJson(
+ JSON.stringify({
+ chartsVersion: chartJsonVersion,
+ spec: {
+ marks: [
+ {
+ $call: 'tanstack.mark.rule-x',
+ data: { $data: 'events' },
+ },
+ {
+ $call: 'tanstack.mark.rule-y',
+ data: { $data: 'targets' },
+ },
+ ],
+ x: { scale: { $call: 'tanstack.scale.band' } },
+ y: { scale: { $call: 'tanstack.scale.linear' } },
+ },
+ data: { events: ['Alpha'], targets: [5] },
+ }),
+ ),
+ { width: 320, height: 180 },
+ )
+ const direct = createChartScene(
+ defineChart({
+ marks: [ruleX(['Alpha']), ruleY([5])],
+ x: { scale: scaleBand },
+ y: { scale: scaleLinear },
+ }),
+ { width: 320, height: 180 },
+ )
+
+ expect(portable.nodes).toEqual(direct.nodes)
+ expect(portable.scales.x.domain).toEqual(direct.scales.x.domain)
+ expect(portable.scales.y.domain).toEqual(direct.scales.y.domain)
+ expect(
+ flatten(portable.nodes).filter(
+ (node) =>
+ node.kind === 'group' && node.className?.includes('ts-chart__rule'),
+ ),
+ ).toHaveLength(2)
+ })
+
+ it('publishes closed rule annotation contracts in the schema', () => {
+ const definitions = (chartJsonSchema as any).$defs
+
+ expect(definitions['operation:tanstack.mark.rule-x']).toMatchObject({
+ required: ['$call', 'data'],
+ additionalProperties: false,
+ })
+ expect(
+ Object.keys(
+ definitions['operation:tanstack.mark.rule-x'].properties,
+ ).sort(),
+ ).toEqual([
+ '$call',
+ 'color',
+ 'data',
+ 'id',
+ 'stroke',
+ 'strokeDasharray',
+ 'strokeOpacity',
+ 'strokeWidth',
+ 'x',
+ ])
+ expect(definitions['operation:tanstack.mark.rule-y']).toMatchObject({
+ required: ['$call', 'data'],
+ additionalProperties: false,
+ })
+ expect(
+ Object.keys(
+ definitions['operation:tanstack.mark.rule-y'].properties,
+ ).sort(),
+ ).toEqual([
+ '$call',
+ 'color',
+ 'data',
+ 'id',
+ 'stroke',
+ 'strokeDasharray',
+ 'strokeOpacity',
+ 'strokeWidth',
+ 'y',
+ ])
+ })
+
+ it('lets a detached iterable replace bundled rows', () => {
+ function* replacement() {
+ yield { category: 'Replacement', value: 11 }
+ }
+ const definition = chartFromJson(lineSource({ data: { rows } }), {
+ data: { rows: replacement() },
+ })
+ const scene = createChartScene(definition, {
+ width: 320,
+ height: 180,
+ })
+
+ expect(scene.scales.x.domain).toEqual(['Replacement'])
+ })
+
+ it('materializes a shared host iterator for every referencing mark', () => {
+ const authored = JSON.parse(lineSource())
+ authored.spec.marks.push({ ...authored.spec.marks[0], id: 'second-line' })
+ function* replacement() {
+ yield { category: 'Alpha', value: 3 }
+ yield { category: 'Beta', value: 7 }
+ }
+
+ const scene = createChartScene(
+ chartFromJson(JSON.stringify(authored), {
+ data: { rows: replacement() },
+ }),
+ { width: 320, height: 180 },
+ )
+ const lineGroups = flatten(scene.nodes).filter(
+ (node) =>
+ node.kind === 'group' && node.className?.includes('ts-chart__line'),
+ )
+
+ expect(lineGroups).toHaveLength(2)
+ })
+
+ it.each([
+ ['pie', undefined],
+ ['donut', 0.55],
+ ] as const)('renders a %s like the direct polar API', (_, ratio) => {
+ const id = ratio === undefined ? 'revenue-pie' : 'revenue-donut'
+ const definition = chartFromJson(
+ pieSource({
+ id,
+ ...(ratio === undefined ? {} : { innerRadiusRatio: ratio }),
+ }),
+ )
+ const portable = createChartScene(definition, {
+ width: 480,
+ height: 260,
+ })
+ const slices = pie(rows, { value: 'value' })
+ const direct = createChartScene(
+ defineChart({
+ marks: [
+ polar({
+ id,
+ marks: [
+ radialArc(slices, {
+ z: 'category',
+ color: 'category',
+ ...(ratio === undefined
+ ? {}
+ : { innerRadius: ({ radius }) => radius * ratio }),
+ }),
+ ],
+ }),
+ ],
+ color: {
+ domain: ['Alpha', 'Beta'],
+ range: ['#2563eb', '#f97316'],
+ },
+ }),
+ { width: 480, height: 260 },
+ )
+
+ expect(portable.nodes).toEqual(direct.nodes)
+ expect(definition.marks[0]?.initialize({ markIndex: 0 }).id).toBe(id)
+ expect(definition).not.toHaveProperty('x')
+ expect(definition).not.toHaveProperty('y')
+ })
+
+ it('renders positioned text like the direct API', () => {
+ const labelRows = [
+ { category: 'Alpha', value: 3, label: 'Three' },
+ { category: 'Beta', value: 7, label: 'Seven' },
+ ]
+ const source = JSON.stringify({
+ chartsVersion: chartJsonVersion,
+ spec: {
+ marks: [
+ {
+ $call: 'tanstack.mark.text',
+ data: { $data: 'rows' },
+ id: 'value-labels',
+ x: 'category',
+ y: 'value',
+ text: 'label',
+ key: 'category',
+ fill: '#111827',
+ fontSize: 12,
+ fontWeight: 600,
+ anchor: 'end',
+ rotate: -12,
+ dx: 4,
+ dy: -6,
+ },
+ ],
+ x: { scale: { $call: 'tanstack.scale.band' } },
+ y: { scale: { $call: 'tanstack.scale.linear' } },
+ },
+ data: { rows: labelRows },
+ })
+ const portable = createChartScene(chartFromJson(source), {
+ width: 480,
+ height: 260,
+ })
+ const direct = createChartScene(
+ defineChart({
+ marks: [
+ text(labelRows, {
+ id: 'value-labels',
+ x: 'category',
+ y: 'value',
+ text: 'label',
+ key: 'category',
+ fill: '#111827',
+ fontSize: 12,
+ fontWeight: 600,
+ anchor: 'end',
+ rotate: -12,
+ dx: 4,
+ dy: -6,
+ }),
+ ],
+ x: { scale: scaleBand },
+ y: { scale: scaleLinear },
+ }),
+ { width: 480, height: 260 },
+ )
+
+ expect(portable.nodes).toEqual(direct.nodes)
+ expect(portable.points).toEqual(direct.points)
+ expect(flatten(portable.nodes)).toContainEqual(
+ expect.objectContaining({
+ kind: 'label',
+ text: 'Three',
+ anchor: 'end',
+ rotate: -12,
+ fontSize: 12,
+ fontWeight: 600,
+ }),
+ )
+ })
+
+ it('rejects an invalid text anchor at its argument path', () => {
+ const authored = JSON.parse(lineSource({ data: { rows } }))
+ authored.spec.marks = [
+ {
+ $call: 'tanstack.mark.text',
+ data: { $data: 'rows' },
+ x: 'category',
+ y: 'value',
+ text: 'value',
+ anchor: 'left',
+ },
+ ]
+
+ const error = issueFrom(JSON.stringify(authored))
+
+ expect(error.issues).toContainEqual(
+ expect.objectContaining({
+ code: 'invalid-arguments',
+ path: '/spec/marks/0/anchor',
+ }),
+ )
+ })
+
+ it('supports exactly the fixed operation set and nested roles', () => {
+ expect(chartJsonOperations.map(({ id }) => id)).toEqual([
+ 'tanstack.accessor.iso-date',
+ 'tanstack.legend.color',
+ 'tanstack.layout.group',
+ 'tanstack.mark.area-y',
+ 'tanstack.mark.bar-x',
+ 'tanstack.mark.bar-y',
+ 'tanstack.mark.dot',
+ 'tanstack.mark.line-y',
+ 'tanstack.mark.rule-x',
+ 'tanstack.mark.rule-y',
+ 'tanstack.mark.text',
+ 'tanstack.mark.pie',
+ 'tanstack.scale.band',
+ 'tanstack.scale.linear',
+ 'tanstack.scale.point',
+ 'tanstack.scale.utc',
+ ])
+
+ const definition = chartFromJson(
+ JSON.stringify({
+ chartsVersion: chartJsonVersion,
+ spec: {
+ marks: [
+ {
+ $call: 'tanstack.mark.area-y',
+ data: { $data: 'rows' },
+ x: {
+ $call: 'tanstack.accessor.iso-date',
+ field: 'recordedAt',
+ },
+ y: 'value',
+ },
+ {
+ $call: 'tanstack.mark.bar-x',
+ data: { $data: 'rows' },
+ x: 'value',
+ y: 'category',
+ layout: {
+ $call: 'tanstack.layout.group',
+ scale: { $call: 'tanstack.scale.band' },
+ padding: 2,
+ },
+ },
+ {
+ $call: 'tanstack.mark.bar-y',
+ data: { $data: 'rows' },
+ x: 'category',
+ y: 'value',
+ },
+ {
+ $call: 'tanstack.mark.dot',
+ data: { $data: 'rows' },
+ x: 'category',
+ y: 'value',
+ r: 3,
+ },
+ {
+ $call: 'tanstack.mark.line-y',
+ data: { $data: 'rows' },
+ x: 'category',
+ y: 'value',
+ points: true,
+ },
+ ],
+ x: { scale: { $call: 'tanstack.scale.point' } },
+ y: { scale: { $call: 'tanstack.scale.utc' } },
+ color: {
+ domain: ['Alpha', 'Beta'],
+ range: ['#2563eb', '#f97316'],
+ legend: {
+ $call: 'tanstack.legend.color',
+ label: 'Category',
+ placement: 'bottom',
+ },
+ },
+ },
+ data: {
+ rows: [
+ {
+ category: 'Alpha',
+ value: 3,
+ recordedAt: '2026-08-19T12:00:00Z',
+ },
+ ],
+ },
+ }),
+ )
+
+ expect(definition.marks).toHaveLength(5)
+ })
+
+ it('keeps operation descriptions and required data in the schema', () => {
+ const schema = chartJsonSchema as any
+ const definitions = schema.$defs as Record
+ const operationKeys = Object.keys(definitions).filter((key) =>
+ key.startsWith('operation:'),
+ )
+
+ expect(operationKeys).toHaveLength(16)
+ expect(definitions['operation:tanstack.mark.bar-y'].required).toEqual([
+ '$call',
+ 'data',
+ ])
+ expect(
+ definitions['operation:tanstack.mark.bar-y'].description,
+ ).toBeTruthy()
+ expect(definitions['operation:tanstack.mark.pie'].required).toEqual([
+ '$call',
+ 'data',
+ 'value',
+ 'category',
+ ])
+ expect(definitions['operation:tanstack.mark.text'].required).toEqual([
+ '$call',
+ 'data',
+ ])
+ expect(definitions['argument:text-anchor']).toEqual({
+ enum: ['start', 'middle', 'end'],
+ })
+ expect(definitions['argument:ratio']).toEqual({
+ type: 'number',
+ minimum: 0,
+ exclusiveMaximum: 1,
+ })
+ expect(definitions['argument:data']).toEqual({ $ref: '#/$defs/data' })
+ expect(schema.properties.data.additionalProperties.type).toBe('array')
+
+ const [cartesian, circular] = schema.properties.spec.oneOf
+ expect(cartesian.required).toEqual(['marks', 'x', 'y'])
+ expect(cartesian.properties.marks.items).toEqual({
+ $ref: '#/$defs/result:mark:cartesian',
+ })
+ expect(circular.required).toEqual(['marks'])
+ expect(circular.properties.marks).toMatchObject({
+ minItems: 1,
+ maxItems: 1,
+ items: { $ref: '#/$defs/result:mark:circular' },
+ })
+ expect(circular.properties).not.toHaveProperty('x')
+ expect(circular.properties).not.toHaveProperty('y')
+ expect(circular.properties).not.toHaveProperty('guides')
+ })
+
+ it('keeps the circular dialect singular and separate from Cartesian charts', () => {
+ const mixed = JSON.parse(pieSource())
+ mixed.spec.marks.push({
+ $call: 'tanstack.mark.line-y',
+ data: { $data: 'rows' },
+ x: 'category',
+ y: 'value',
+ })
+ expect(issueFrom(JSON.stringify(mixed)).issues[0]).toMatchObject({
+ code: 'invalid-arguments',
+ path: '/spec/marks',
+ })
+
+ const repeated = JSON.parse(pieSource())
+ repeated.spec.marks.push({ ...repeated.spec.marks[0] })
+ expect(issueFrom(JSON.stringify(repeated)).issues[0]).toMatchObject({
+ code: 'invalid-arguments',
+ path: '/spec/marks',
+ })
+
+ for (const [field, value] of [
+ ['x', { scale: { $call: 'tanstack.scale.band' } }],
+ ['y', { scale: { $call: 'tanstack.scale.linear' } }],
+ ['guides', false],
+ ] as const) {
+ const forbidden = JSON.parse(pieSource())
+ forbidden.spec[field] = value
+ expect(issueFrom(JSON.stringify(forbidden)).issues[0]).toMatchObject({
+ code: 'invalid-arguments',
+ path: `/spec/${field}`,
+ })
+ }
+ })
+
+ it('validates pie arguments and source fields at the call boundary', () => {
+ for (const ratio of [-0.01, 1]) {
+ expect(
+ issueFrom(pieSource({ innerRadiusRatio: ratio })).issues[0],
+ ).toMatchObject({
+ code: 'invalid-arguments',
+ path: '/spec/marks/0/innerRadiusRatio',
+ })
+ }
+
+ expect(
+ issueFrom(pieSource({}, {}, [{ category: 'Alpha', value: -1 }]))
+ .issues[0],
+ ).toMatchObject({
+ code: 'call-error',
+ path: '/spec/marks/0',
+ callId: 'tanstack.mark.pie',
+ })
+ expect(
+ issueFrom(pieSource({}, {}, [{ category: false, value: 1 }])).issues[0],
+ ).toMatchObject({
+ code: 'call-error',
+ path: '/spec/marks/0',
+ callId: 'tanstack.mark.pie',
+ })
+
+ const missingCategory = JSON.parse(pieSource())
+ delete missingCategory.spec.marks[0].category
+ expect(issueFrom(JSON.stringify(missingCategory)).issues[0]).toMatchObject({
+ code: 'invalid-arguments',
+ path: '/spec/marks/0/category',
+ callId: 'tanstack.mark.pie',
+ })
+ })
+
+ it('rejects obsolete and open-ended grammar', () => {
+ const obsolete = JSON.parse(lineSource({ data: { rows } }))
+ obsolete.kind = 'chart-document'
+ expect(issueFrom(JSON.stringify(obsolete)).issues[0]).toMatchObject({
+ code: 'invalid-envelope',
+ path: '/kind',
+ })
+
+ const argsWrapper = JSON.parse(lineSource({ data: { rows } }))
+ argsWrapper.spec.marks[0] = {
+ $call: 'tanstack.mark.line-y',
+ args: { data: { $data: 'rows' }, x: 'category', y: 'value' },
+ }
+ expect(issueFrom(JSON.stringify(argsWrapper)).issues[0]).toMatchObject({
+ code: 'invalid-arguments',
+ path: '/spec/marks/0/args',
+ callId: 'tanstack.mark.line-y',
+ })
+
+ const dataInConfig = JSON.parse(lineSource({ data: { rows } }))
+ dataInConfig.spec.marks[0].x = { $data: 'rows' }
+ expect(issueFrom(JSON.stringify(dataInConfig)).issues[0]).toMatchObject({
+ code: 'invalid-node',
+ path: '/spec/marks/0/x',
+ })
+
+ const inlineRows = JSON.parse(lineSource())
+ inlineRows.spec.marks[0].data = rows
+ expect(issueFrom(JSON.stringify(inlineRows)).issues[0]).toMatchObject({
+ code: 'invalid-node',
+ path: '/spec/marks/0/data',
+ })
+ })
+
+ it('reports closed envelope, spec, placement, data, and call errors', () => {
+ expect(issueFrom('{').issues[0]).toMatchObject({ code: 'invalid-json' })
+ expect(
+ issueFrom(`{"chartsVersion":"${chartJsonVersion}","spec":1e400}`)
+ .issues[0],
+ ).toMatchObject({ code: 'invalid-json', path: '/spec' })
+ expect(
+ issueFrom(JSON.stringify({ chartsVersion: chartJsonVersion })).issues,
+ ).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ code: 'invalid-envelope', path: '/spec' }),
+ ]),
+ )
+
+ const empty = JSON.parse(lineSource())
+ empty.spec.marks = []
+ expect(issueFrom(JSON.stringify(empty)).issues[0]).toMatchObject({
+ code: 'invalid-arguments',
+ path: '/spec/marks',
+ })
+
+ const noAxis = JSON.parse(lineSource())
+ delete noAxis.spec.y
+ expect(issueFrom(JSON.stringify(noAxis)).issues[0]).toMatchObject({
+ code: 'invalid-arguments',
+ path: '/spec/y',
+ })
+
+ const badPlacement = JSON.parse(lineSource({ data: { rows } }))
+ badPlacement.spec.x.scale = { $call: 'tanstack.legend.color' }
+ expect(issueFrom(JSON.stringify(badPlacement)).issues[0]).toMatchObject({
+ code: 'invalid-result',
+ path: '/spec/x/scale',
+ callId: 'tanstack.legend.color',
+ })
+
+ const unknown = JSON.parse(lineSource({ data: { rows } }))
+ unknown.spec.marks[0].$call = 'missing.mark'
+ expect(issueFrom(JSON.stringify(unknown)).issues[0]).toMatchObject({
+ code: 'unknown-call',
+ callId: 'missing.mark',
+ })
+
+ const malformed = JSON.parse(lineSource({ data: { rows } }))
+ malformed.spec.marks[0].$call = 42
+ expect(issueFrom(JSON.stringify(malformed)).issues[0]).toMatchObject({
+ code: 'invalid-node',
+ path: '/spec/marks/0/$call',
+ })
+
+ const nonMark = JSON.parse(lineSource({ data: { rows } }))
+ nonMark.spec.marks[0] = { $call: 'tanstack.scale.linear' }
+ expect(issueFrom(JSON.stringify(nonMark)).issues[0]).toMatchObject({
+ code: 'invalid-result',
+ path: '/spec/marks/0',
+ callId: 'tanstack.scale.linear',
+ })
+
+ expect(
+ issueFrom(lineSource({ data: { rows: { category: 'A' } } })).issues[0],
+ ).toMatchObject({ code: 'invalid-data', path: '/data/rows' })
+ expect(issueFrom(lineSource()).issues[0]).toMatchObject({
+ code: 'missing-data',
+ path: '/spec/marks/0/data',
+ })
+ })
+
+ it('supports backward compatibility by default and exact matching on demand', () => {
+ const withBuild = JSON.parse(lineSource({ data: { rows } }))
+ withBuild.chartsVersion = `${chartJsonVersion}+producer`
+ expect(() => chartFromJson(JSON.stringify(withBuild))).not.toThrow()
+ expect(() =>
+ chartFromJson(JSON.stringify(withBuild), { exactVersion: true }),
+ ).toThrowError(ChartJsonError)
+
+ const future = JSON.parse(lineSource({ data: { rows } }))
+ future.chartsVersion = '999.0.0'
+ expect(issueFrom(JSON.stringify(future)).issues[0]).toMatchObject({
+ code: 'incompatible-version',
+ path: '/chartsVersion',
+ })
+ })
+
+ it('applies the supported SemVer range across reader releases', () => {
+ const validate = (author: string, reader: string) =>
+ validateChartJsonVersion(author, false, reader)
+
+ expect(validate('0.16.0', '0.17.3')).toEqual([])
+ expect(validate('0.17.2', '0.17.3')).toEqual([])
+ expect(validate('0.17.3-beta.1', '0.17.3')).toEqual([])
+ expect(validate('0.14.9', '0.17.3')[0]).toMatchObject({
+ code: 'incompatible-version',
+ path: '/chartsVersion',
+ })
+ expect(validate('0.17.4', '0.17.3')[0]).toMatchObject({
+ code: 'incompatible-version',
+ path: '/chartsVersion',
+ })
+ expect(validate('0.17.0', '1.2.0')[0]).toMatchObject({
+ code: 'incompatible-version',
+ path: '/chartsVersion',
+ })
+ })
+})
+
+function flatten(nodes: readonly SceneNode[]): SceneNode[] {
+ return nodes.flatMap((node) =>
+ node.kind === 'group' ? [node, ...flatten(node.children)] : [node],
+ )
+}
diff --git a/packages/charts-core/src/json/chart-from-json.ts b/packages/charts-core/src/json/chart-from-json.ts
new file mode 100644
index 00000000..b49c6aec
--- /dev/null
+++ b/packages/charts-core/src/json/chart-from-json.ts
@@ -0,0 +1,754 @@
+import { ChartJsonError } from './error'
+import {
+ chartJsonOperationsById,
+ type ChartJsonArgumentRule,
+ type ChartJsonOperation,
+ type ChartJsonOperationResult,
+} from './operation-contracts'
+import type {
+ ChartFromJsonOptions,
+ ChartJson,
+ ChartJsonDefinition,
+ ChartJsonIssue,
+ ChartJsonMetadata,
+} from './types'
+import { validateChartJsonVersion } from './version-compatibility'
+import { chartJsonSchemaUrl } from './version'
+
+const envelopeFields = new Set([
+ '$schema',
+ 'chartsVersion',
+ 'spec',
+ 'data',
+ 'metadata',
+])
+const specFields = new Set([
+ 'marks',
+ 'x',
+ 'y',
+ 'guides',
+ 'color',
+ 'clip',
+ 'margin',
+])
+
+export function chartFromJson(
+ source: string,
+ options: ChartFromJsonOptions = {},
+): ChartJsonDefinition {
+ const normalizedOptions = readOptions(options)
+ const envelope = readEnvelope(source, normalizedOptions.exactVersion)
+ const data = mergeData(envelope.data, normalizedOptions.data)
+ const spec = resolveSpec(envelope.spec, data)
+ const metadata = envelope.metadata
+ ? Object.freeze({ ...envelope.metadata })
+ : undefined
+ return {
+ ...spec,
+ ...(metadata ? { metadata } : {}),
+ }
+}
+
+function readOptions(options: ChartFromJsonOptions): ChartFromJsonOptions {
+ if (!isPlainObject(options))
+ fail('invalid-envelope', '/options', 'options must be a plain object')
+ exactProperties(
+ options as Readonly>,
+ ['data', 'exactVersion'],
+ '/options',
+ 'invalid-envelope',
+ )
+ if (options.data !== undefined && !isPlainObject(options.data))
+ fail('invalid-data', '/options/data', 'data must be a plain object')
+ if (
+ options.exactVersion !== undefined &&
+ typeof options.exactVersion !== 'boolean'
+ )
+ fail(
+ 'invalid-envelope',
+ '/options/exactVersion',
+ 'exactVersion must be a boolean',
+ )
+ return options
+}
+
+function readEnvelope(source: string, exactVersion = false): ChartJson {
+ if (typeof source !== 'string')
+ fail('invalid-json', '/', 'source must be JSON text')
+ let value: unknown
+ try {
+ value = JSON.parse(source)
+ } catch (error) {
+ fail(
+ 'invalid-json',
+ '/',
+ `invalid JSON${error instanceof Error ? `: ${error.message}` : ''}`,
+ )
+ }
+ requireFiniteJsonNumbers(value)
+ if (!isPlainObject(value))
+ fail('invalid-envelope', '/', 'expected a Chart JSON object')
+ const issues: ChartJsonIssue[] = []
+ for (const key of Object.keys(value)) {
+ if (!envelopeFields.has(key))
+ issues.push(
+ issue(
+ 'invalid-envelope',
+ pointer('', key),
+ `unknown property ${JSON.stringify(key)}`,
+ ),
+ )
+ }
+ issues.push(...validateChartJsonVersion(value.chartsVersion, exactVersion))
+ if (!isPlainObject(value.spec))
+ issues.push(
+ issue('invalid-envelope', '/spec', 'spec must be a plain object'),
+ )
+ if (value.data !== undefined && !isPlainObject(value.data))
+ issues.push(
+ issue('invalid-envelope', '/data', 'data must be a plain object'),
+ )
+ if (isPlainObject(value.data)) {
+ for (const [key, rows] of Object.entries(value.data)) {
+ if (!key || key.startsWith('$'))
+ issues.push(
+ issue('invalid-data', pointer('/data', key), 'invalid data name'),
+ )
+ if (!Array.isArray(rows))
+ issues.push(
+ issue(
+ 'invalid-data',
+ pointer('/data', key),
+ 'bundled data must be an array of JSON rows',
+ ),
+ )
+ }
+ }
+ const metadata = readMetadata(value.metadata, issues)
+ if (value.$schema !== undefined) {
+ if (typeof value.$schema !== 'string')
+ issues.push(
+ issue('invalid-envelope', '/$schema', '$schema must be a string'),
+ )
+ else if (typeof value.chartsVersion === 'string') {
+ const expected = chartJsonSchemaUrl(value.chartsVersion)
+ if (value.$schema !== expected)
+ issues.push(
+ issue(
+ 'invalid-envelope',
+ '/$schema',
+ `expected ${JSON.stringify(expected)}`,
+ ),
+ )
+ }
+ }
+ if (issues.length) throw new ChartJsonError(issues)
+ return {
+ ...(typeof value.$schema === 'string' ? { $schema: value.$schema } : {}),
+ chartsVersion: value.chartsVersion as string,
+ spec: value.spec as Record,
+ ...(isPlainObject(value.data)
+ ? { data: value.data as NonNullable }
+ : {}),
+ ...(metadata ? { metadata } : {}),
+ }
+}
+
+function readMetadata(
+ value: unknown,
+ issues: ChartJsonIssue[],
+): ChartJsonMetadata | undefined {
+ if (value === undefined) return undefined
+ if (!isPlainObject(value)) {
+ issues.push(
+ issue('invalid-envelope', '/metadata', 'metadata must be a plain object'),
+ )
+ return undefined
+ }
+ for (const key of Object.keys(value)) {
+ if (key !== 'title' && key !== 'description')
+ issues.push(
+ issue(
+ 'invalid-envelope',
+ pointer('/metadata', key),
+ `unknown property ${JSON.stringify(key)}`,
+ ),
+ )
+ }
+ for (const key of ['title', 'description'] as const) {
+ if (value[key] !== undefined && typeof value[key] !== 'string')
+ issues.push(
+ issue(
+ 'invalid-envelope',
+ pointer('/metadata', key),
+ `${key} must be a string`,
+ ),
+ )
+ }
+ return {
+ ...(typeof value.title === 'string' ? { title: value.title } : {}),
+ ...(typeof value.description === 'string'
+ ? { description: value.description }
+ : {}),
+ }
+}
+
+function mergeData(
+ bundled: Readonly> | undefined,
+ supplied: Readonly>> | undefined,
+): Readonly> {
+ const output: Record = Object.create(null)
+ for (const [key, value] of Object.entries(bundled ?? {})) {
+ if (!key || key.startsWith('$'))
+ fail('invalid-data', pointer('/data', key), 'invalid data name')
+ output[key] = value
+ }
+ for (const [key, value] of Object.entries(supplied ?? {})) {
+ const path = pointer('/options/data', key)
+ if (!key || key.startsWith('$'))
+ fail('invalid-data', path, 'invalid data name')
+ if (!matchesArgument(value, 'data'))
+ fail('invalid-data', path, 'data must be iterable')
+ try {
+ output[key] = Array.isArray(value) ? value : Array.from(value)
+ } catch (error) {
+ throw new ChartJsonError(
+ [issue('invalid-data', path, 'data iterable could not be read')],
+ { cause: error },
+ )
+ }
+ }
+ return Object.freeze(output)
+}
+
+function resolveSpec(
+ source: Readonly>,
+ data: Readonly>,
+): ChartJsonDefinition {
+ for (const key of Object.keys(source)) {
+ if (!specFields.has(key))
+ fail(
+ 'invalid-arguments',
+ pointer('/spec', key),
+ `unknown property ${JSON.stringify(key)}`,
+ )
+ }
+ if (!Array.isArray(source.marks))
+ fail('invalid-arguments', '/spec/marks', 'marks must be an array')
+ if (source.marks.length === 0)
+ fail('invalid-arguments', '/spec/marks', 'marks must not be empty')
+ const markCalls = source.marks.map((mark, index) =>
+ readCallOperation(mark, pointer('/spec/marks', index), 'mark', undefined),
+ )
+ const coordinates = new Set(
+ markCalls.map(({ id, operation }, index) => {
+ if (!operation.coordinate)
+ fail(
+ 'invalid-result',
+ pointer('/spec/marks', index),
+ 'mark has no coordinate kind',
+ id,
+ )
+ return operation.coordinate
+ }),
+ )
+ if (coordinates.size !== 1)
+ fail(
+ 'invalid-arguments',
+ '/spec/marks',
+ 'Cartesian and circular marks cannot be mixed',
+ )
+ const circular = coordinates.has('circular')
+ if (circular && source.marks.length !== 1)
+ fail(
+ 'invalid-arguments',
+ '/spec/marks',
+ 'a circular chart must contain exactly one mark',
+ )
+ if (circular) {
+ for (const key of ['x', 'y', 'guides'] as const) {
+ if (source[key] !== undefined)
+ fail(
+ 'invalid-arguments',
+ pointer('/spec', key),
+ `${key} is not allowed in a circular chart`,
+ )
+ }
+ } else {
+ if (source.x === undefined)
+ fail('invalid-arguments', '/spec/x', 'x axis is required')
+ if (source.y === undefined)
+ fail('invalid-arguments', '/spec/y', 'y axis is required')
+ }
+ const marks = source.marks.map((mark, index) =>
+ resolveCall(mark, pointer('/spec/marks', index), data, 'mark', undefined),
+ ) as ChartJsonDefinition['marks']
+ const output: Record & Pick = {
+ marks,
+ }
+ if (!circular) {
+ output.x = resolveAxis(source.x, '/spec/x', data)
+ output.y = resolveAxis(source.y, '/spec/y', data)
+ }
+ if (source.guides !== undefined)
+ output.guides = requireBoolean(source.guides, '/spec/guides')
+ if (source.color !== undefined)
+ output.color = resolveColor(source.color, '/spec/color', data)
+ if (source.clip !== undefined)
+ output.clip = requireBoolean(source.clip, '/spec/clip')
+ if (source.margin !== undefined)
+ output.margin = resolveMargin(source.margin, '/spec/margin')
+ return output as ChartJsonDefinition
+}
+
+function resolveAxis(
+ source: unknown,
+ path: string,
+ data: Readonly>,
+): unknown {
+ if (!isPlainObject(source))
+ fail('invalid-arguments', path, 'axis must be a plain object')
+ exactProperties(
+ source,
+ ['scale', 'nice', 'reverse', 'grid', 'axis'],
+ path,
+ 'invalid-arguments',
+ )
+ if (!Object.hasOwn(source, 'scale'))
+ fail('invalid-arguments', `${path}/scale`, 'scale is required')
+ const output: Record = {
+ scale: resolveCall(source.scale, `${path}/scale`, data, 'scale', undefined),
+ }
+ if (source.nice !== undefined)
+ output.nice = requireBooleanOrNumber(source.nice, `${path}/nice`)
+ if (source.reverse !== undefined)
+ output.reverse = requireBoolean(source.reverse, `${path}/reverse`)
+ if (source.grid !== undefined)
+ output.grid = requireBoolean(source.grid, `${path}/grid`)
+ if (source.axis !== undefined) {
+ if (source.axis !== false)
+ fail('invalid-arguments', `${path}/axis`, 'axis must be false')
+ output.axis = source.axis
+ }
+ return output
+}
+
+function resolveColor(
+ source: unknown,
+ path: string,
+ data: Readonly>,
+): unknown {
+ if (!isPlainObject(source))
+ fail('invalid-arguments', path, 'color must be a plain object')
+ exactProperties(
+ source,
+ ['domain', 'range', 'legend'],
+ path,
+ 'invalid-arguments',
+ )
+ const output: Record = {}
+ if (source.domain !== undefined) {
+ const value = source.domain
+ if (
+ !Array.isArray(value) ||
+ value.some(
+ (entry) =>
+ typeof entry !== 'string' &&
+ !(typeof entry === 'number' && Number.isFinite(entry)),
+ )
+ )
+ fail(
+ 'invalid-arguments',
+ `${path}/domain`,
+ 'domain must contain only strings or finite numbers',
+ )
+ output.domain = value
+ }
+ if (source.range !== undefined) {
+ const value = source.range
+ if (
+ !Array.isArray(value) ||
+ value.some((entry) => typeof entry !== 'string')
+ )
+ fail(
+ 'invalid-arguments',
+ `${path}/range`,
+ 'range must be an array of strings',
+ )
+ output.range = value
+ }
+ if (source.legend !== undefined)
+ output.legend = resolveCall(
+ source.legend,
+ `${path}/legend`,
+ data,
+ 'legend',
+ undefined,
+ )
+ return output
+}
+
+function resolveMargin(source: unknown, path: string): unknown {
+ const resolved = source
+ if (typeof resolved === 'number') {
+ if (Number.isFinite(resolved)) return resolved
+ fail('invalid-arguments', path, 'margin must be finite')
+ }
+ if (!isPlainObject(resolved))
+ fail('invalid-arguments', path, 'margin must be a number or object')
+ exactProperties(
+ resolved,
+ ['top', 'right', 'bottom', 'left'],
+ path,
+ 'invalid-arguments',
+ )
+ const output: Record = {}
+ for (const key of ['top', 'right', 'bottom', 'left'] as const) {
+ if (resolved[key] !== undefined)
+ output[key] = resolveScalar(
+ resolved[key],
+ pointer(path, key),
+ 'finite-number',
+ )
+ }
+ return output
+}
+
+function resolveCall(
+ source: unknown,
+ path: string,
+ data: Readonly>,
+ expected: ChartJsonOperationResult,
+ allowedIds: readonly string[] | undefined,
+): unknown {
+ const { call, id, operation } = readCallOperation(
+ source,
+ path,
+ expected,
+ allowedIds,
+ )
+ const allowed = new Set(['$call', ...Object.keys(operation.fields)])
+ for (const key of Object.keys(call)) {
+ if (!allowed.has(key))
+ fail(
+ 'invalid-arguments',
+ pointer(path, key),
+ `unknown argument ${JSON.stringify(key)}`,
+ id,
+ )
+ }
+ for (const field of operation.required ?? []) {
+ if (!Object.hasOwn(call, field))
+ fail(
+ 'invalid-arguments',
+ pointer(path, field),
+ 'missing required argument',
+ id,
+ )
+ }
+ for (const group of operation.mutuallyExclusive ?? []) {
+ if (group.every((field) => Object.hasOwn(call, field)))
+ fail(
+ 'invalid-arguments',
+ path,
+ `${group.join(' and ')} are mutually exclusive`,
+ id,
+ )
+ }
+ const arguments_: Record = {}
+ for (const [name, rule] of Object.entries(operation.fields)) {
+ if (Object.hasOwn(call, name))
+ arguments_[name] = resolveArgument(
+ call[name],
+ pointer(path, name),
+ data,
+ rule,
+ )
+ }
+ let result: unknown
+ try {
+ result = operation.create(arguments_)
+ } catch (error) {
+ if (error instanceof ChartJsonError) throw error
+ throw new ChartJsonError(
+ [
+ issue(
+ 'call-error',
+ path,
+ `call ${JSON.stringify(id)} failed${error instanceof Error ? `: ${error.message}` : ''}`,
+ id,
+ ),
+ ],
+ { cause: error },
+ )
+ }
+ if (!matchesResult(result, operation))
+ fail(
+ 'invalid-result',
+ path,
+ `call ${JSON.stringify(id)} did not return ${operation.result}`,
+ id,
+ )
+ return result
+}
+
+function readCallOperation(
+ source: unknown,
+ path: string,
+ expected: ChartJsonOperationResult,
+ allowedIds: readonly string[] | undefined,
+): {
+ call: Record
+ id: string
+ operation: ChartJsonOperation
+} {
+ if (!isPlainObject(source) || !Object.hasOwn(source, '$call'))
+ fail('invalid-node', path, 'expected a $call object')
+ const id = source.$call
+ if (typeof id !== 'string' || !id)
+ fail('invalid-node', `${path}/$call`, '$call must be a nonempty string')
+ const operation = chartJsonOperationsById.get(id)
+ if (!operation)
+ fail('unknown-call', path, `unknown call ${JSON.stringify(id)}`, id)
+ if (operation.result !== expected || (allowedIds && !allowedIds.includes(id)))
+ fail(
+ 'invalid-result',
+ path,
+ `call ${JSON.stringify(id)} cannot be used as ${expected}`,
+ id,
+ )
+ return { call: source, id, operation }
+}
+
+function resolveArgument(
+ source: unknown,
+ path: string,
+ data: Readonly>,
+ rule: ChartJsonArgumentRule,
+): unknown {
+ let value: unknown
+ if (rule === 'layout')
+ value = resolveCall(source, path, data, 'layout', undefined)
+ else if (rule === 'scale-band')
+ value = resolveCall(source, path, data, 'scale', ['tanstack.scale.band'])
+ else if (
+ (rule === 'channel' || rule === 'numeric-channel') &&
+ isPlainObject(source) &&
+ Object.hasOwn(source, '$call')
+ )
+ value = resolveCall(source, path, data, 'accessor', undefined)
+ else if (rule === 'data') value = resolveData(source, path, data)
+ else {
+ rejectReservedNode(source, path)
+ value = source
+ }
+ if (!matchesArgument(value, rule))
+ fail('invalid-arguments', path, `value does not satisfy ${rule}`)
+ return value
+}
+
+function resolveData(
+ source: unknown,
+ path: string,
+ data: Readonly>,
+): unknown {
+ if (!isPlainObject(source) || !Object.hasOwn(source, '$data'))
+ fail('invalid-node', path, 'expected an exact $data object')
+ exactProperties(source, ['$data'], path, 'invalid-node')
+ if (typeof source.$data !== 'string' || !source.$data)
+ fail('invalid-node', `${path}/$data`, '$data must be a nonempty string')
+ if (!Object.hasOwn(data, source.$data))
+ fail(
+ 'missing-data',
+ path,
+ `data ${JSON.stringify(source.$data)} is missing`,
+ )
+ return data[source.$data]
+}
+
+function resolveScalar(
+ source: unknown,
+ path: string,
+ rule: 'boolean' | 'finite-number',
+): unknown {
+ if (!matchesArgument(source, rule))
+ fail('invalid-arguments', path, `value does not satisfy ${rule}`)
+ return source
+}
+
+function requireBooleanOrNumber(value: unknown, path: string): unknown {
+ if (
+ typeof value !== 'boolean' &&
+ !(typeof value === 'number' && Number.isFinite(value))
+ )
+ fail('invalid-arguments', path, 'expected a boolean or finite number')
+ return value
+}
+
+function requireBoolean(value: unknown, path: string): boolean {
+ if (typeof value !== 'boolean')
+ fail('invalid-arguments', path, 'expected a boolean')
+ return value
+}
+
+function rejectReservedNode(value: unknown, path: string): void {
+ if (!isPlainObject(value)) return
+ const reserved = Object.keys(value).find((key) => key.startsWith('$'))
+ if (reserved) fail('invalid-node', path, `reserved node is not allowed here`)
+}
+
+function matchesArgument(value: unknown, rule: ChartJsonArgumentRule): boolean {
+ try {
+ switch (rule) {
+ case 'boolean':
+ return typeof value === 'boolean'
+ case 'channel':
+ return (
+ (typeof value === 'string' && value.length > 0) ||
+ typeof value === 'function'
+ )
+ case 'data': {
+ if (
+ value == null ||
+ typeof value === 'string' ||
+ typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] !==
+ 'function'
+ )
+ return false
+ return true
+ }
+ case 'finite-number':
+ return typeof value === 'number' && Number.isFinite(value)
+ case 'layout':
+ return isPlainObject(value) && value.type === 'group'
+ case 'nonempty-string':
+ return typeof value === 'string' && value.length > 0
+ case 'nonnegative-number':
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
+ case 'numeric-channel':
+ return (
+ (typeof value === 'number' && Number.isFinite(value)) ||
+ (typeof value === 'string' && value.length > 0) ||
+ typeof value === 'function'
+ )
+ case 'opacity':
+ return (
+ typeof value === 'number' &&
+ Number.isFinite(value) &&
+ value >= 0 &&
+ value <= 1
+ )
+ case 'path':
+ return (
+ (typeof value === 'string' &&
+ value.length > 0 &&
+ value.split('.').every(Boolean)) ||
+ (Array.isArray(value) &&
+ value.length > 0 &&
+ value.every(
+ (segment) => typeof segment === 'string' && segment.length > 0,
+ ))
+ )
+ case 'placement':
+ return value === 'top' || value === 'bottom'
+ case 'ratio':
+ return (
+ typeof value === 'number' &&
+ Number.isFinite(value) &&
+ value >= 0 &&
+ value < 1
+ )
+ case 'scale-band':
+ return typeof value === 'function'
+ case 'string':
+ return typeof value === 'string'
+ case 'text-anchor':
+ return value === 'start' || value === 'middle' || value === 'end'
+ }
+ } catch {
+ return false
+ }
+}
+
+function matchesResult(value: unknown, operation: ChartJsonOperation): boolean {
+ try {
+ switch (operation.result) {
+ case 'accessor':
+ case 'scale':
+ return typeof value === 'function'
+ case 'mark':
+ return isPlainObject(value) && typeof value.initialize === 'function'
+ case 'layout':
+ case 'legend':
+ return isPlainObject(value)
+ }
+ } catch {
+ return false
+ }
+}
+
+function exactProperties(
+ value: Readonly>,
+ allowed: readonly string[],
+ path: string,
+ code: ChartJsonIssue['code'],
+): void {
+ for (const key of Object.keys(value)) {
+ if (!allowed.includes(key))
+ fail(code, pointer(path, key), `unknown property ${JSON.stringify(key)}`)
+ }
+}
+
+function isPlainObject(value: unknown): value is Record {
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
+ return false
+ const prototype = Object.getPrototypeOf(value)
+ return prototype === Object.prototype || prototype === null
+}
+
+function requireFiniteJsonNumbers(root: unknown): void {
+ const pending: { value: unknown; path: string }[] = [
+ { value: root, path: '' },
+ ]
+ while (pending.length) {
+ const current = pending.pop()!
+ if (typeof current.value === 'number' && !Number.isFinite(current.value))
+ fail('invalid-json', current.path || '/', 'numbers must be finite')
+ if (Array.isArray(current.value)) {
+ current.value.forEach((value, index) =>
+ pending.push({ value, path: pointer(current.path, index) }),
+ )
+ } else if (isPlainObject(current.value)) {
+ for (const [key, value] of Object.entries(current.value))
+ pending.push({ value, path: pointer(current.path, key) })
+ }
+ }
+}
+
+function pointer(path: string, segment: string | number): string {
+ const escaped = String(segment).replaceAll('~', '~0').replaceAll('/', '~1')
+ return `${path}/${escaped}`
+}
+
+function issue(
+ code: ChartJsonIssue['code'],
+ path: string,
+ detail: string,
+ callId?: string,
+): ChartJsonIssue {
+ return {
+ code,
+ path,
+ message: `${path}: ${detail}`,
+ ...(callId ? { callId } : {}),
+ }
+}
+
+function fail(
+ code: ChartJsonIssue['code'],
+ path: string,
+ detail: string,
+ callId?: string,
+): never {
+ throw new ChartJsonError([issue(code, path, detail, callId)])
+}
diff --git a/packages/charts-core/src/json/error.ts b/packages/charts-core/src/json/error.ts
new file mode 100644
index 00000000..e658198a
--- /dev/null
+++ b/packages/charts-core/src/json/error.ts
@@ -0,0 +1,16 @@
+import type { ChartJsonIssue } from './types'
+
+export class ChartJsonError extends TypeError {
+ readonly issues: readonly ChartJsonIssue[]
+
+ constructor(issues: readonly ChartJsonIssue[], options?: ErrorOptions) {
+ super(
+ issues.length === 1
+ ? issues[0]!.message
+ : `Chart JSON contains ${issues.length} errors`,
+ options,
+ )
+ this.name = 'ChartJsonError'
+ this.issues = issues
+ }
+}
diff --git a/packages/charts-core/src/json/index.ts b/packages/charts-core/src/json/index.ts
new file mode 100644
index 00000000..f26f375b
--- /dev/null
+++ b/packages/charts-core/src/json/index.ts
@@ -0,0 +1,12 @@
+export { chartFromJson } from './chart-from-json'
+export { ChartJsonError } from './error'
+export { chartJsonSchema } from './schema'
+export { chartJsonVersion } from './version'
+export type {
+ ChartFromJsonOptions,
+ ChartJson,
+ ChartJsonDefinition,
+ ChartJsonIssue,
+ ChartJsonIssueCode,
+ ChartJsonMetadata,
+} from './types'
diff --git a/packages/charts-core/src/json/operation-contracts.ts b/packages/charts-core/src/json/operation-contracts.ts
new file mode 100644
index 00000000..5995b3b0
--- /dev/null
+++ b/packages/charts-core/src/json/operation-contracts.ts
@@ -0,0 +1,384 @@
+import { areaY } from '../area'
+import { barX, barY } from '../bar'
+import { dot } from '../dot'
+import { group } from '../group'
+import { colorLegend } from '../legend-static'
+import { lineY } from '../line'
+import { pie } from '../polar-pie'
+import { polar, radialArc } from '../polar'
+import { ruleX, ruleY } from '../rule'
+import { scaleBand } from '../scales-band'
+import { scaleLinear } from '../scales-linear'
+import { scalePoint } from '../scales-point'
+import { text } from '../text'
+import { scaleUtc } from 'd3-scale'
+
+const isoDateExpression = new RegExp(
+ '^(\\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\\d|3[01]))(?:T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d))?$',
+)
+
+export type ChartJsonOperationResult =
+ 'accessor' | 'layout' | 'legend' | 'mark' | 'scale'
+
+export type ChartJsonArgumentRule =
+ | 'boolean'
+ | 'channel'
+ | 'data'
+ | 'finite-number'
+ | 'layout'
+ | 'nonempty-string'
+ | 'nonnegative-number'
+ | 'numeric-channel'
+ | 'opacity'
+ | 'path'
+ | 'placement'
+ | 'ratio'
+ | 'scale-band'
+ | 'string'
+ | 'text-anchor'
+
+export interface ChartJsonOperation {
+ readonly id: string
+ readonly summary: string
+ readonly result: ChartJsonOperationResult
+ readonly coordinate?: 'cartesian' | 'circular'
+ readonly fields: Readonly>
+ readonly required?: readonly string[]
+ readonly mutuallyExclusive?: readonly (readonly string[])[]
+ readonly create: (arguments_: Readonly>) => unknown
+}
+
+const channelFields = {
+ id: 'nonempty-string',
+ x: 'channel',
+ y: 'channel',
+ z: 'channel',
+ color: 'channel',
+ key: 'channel',
+} as const
+
+const paintFields = {
+ fill: 'string',
+ fillOpacity: 'opacity',
+ stroke: 'string',
+ strokeWidth: 'nonnegative-number',
+} as const
+
+const operations: readonly ChartJsonOperation[] = [
+ {
+ id: 'tanstack.accessor.iso-date',
+ summary: 'Parses an ISO date from a field, path, or value.',
+ result: 'accessor',
+ fields: { field: 'nonempty-string', path: 'path' },
+ mutuallyExclusive: [['field', 'path']],
+ create({ field, path }) {
+ if (field !== undefined && path !== undefined)
+ throw new TypeError('field and path are mutually exclusive')
+ const segments =
+ field !== undefined
+ ? [field as string]
+ : path === undefined
+ ? []
+ : pathSegments(path)
+ return (datum: unknown) => {
+ let value = datum
+ for (const segment of segments) {
+ if (
+ value === null ||
+ (typeof value !== 'object' && typeof value !== 'function') ||
+ !Object.hasOwn(value, segment)
+ )
+ return undefined
+ value = (value as Record)[segment]
+ }
+ return parseChartJsonIsoDate(value)
+ }
+ },
+ },
+ {
+ id: 'tanstack.legend.color',
+ summary: 'Creates a categorical color legend.',
+ result: 'legend',
+ fields: {
+ label: 'string',
+ itemWidth: 'nonnegative-number',
+ width: 'nonnegative-number',
+ placement: 'placement',
+ },
+ create: colorLegend as ChartJsonOperation['create'],
+ },
+ {
+ id: 'tanstack.layout.group',
+ summary: 'Creates a grouped interval layout.',
+ result: 'layout',
+ fields: { scale: 'scale-band', padding: 'nonnegative-number' },
+ create: group as ChartJsonOperation['create'],
+ },
+ ...(
+ [
+ {
+ id: 'tanstack.mark.area-y',
+ summary: 'Creates an area-y mark.',
+ fields: {
+ data: 'data',
+ ...channelFields,
+ y1: 'numeric-channel',
+ y2: 'numeric-channel',
+ ...paintFields,
+ },
+ create: areaY,
+ },
+ {
+ id: 'tanstack.mark.bar-x',
+ summary: 'Creates a bar-x mark.',
+ fields: {
+ data: 'data',
+ ...channelFields,
+ x1: 'numeric-channel',
+ x2: 'numeric-channel',
+ ...paintFields,
+ layout: 'layout',
+ inset: 'finite-number',
+ maxThickness: 'nonnegative-number',
+ radius: 'nonnegative-number',
+ },
+ create: barX,
+ },
+ {
+ id: 'tanstack.mark.bar-y',
+ summary: 'Creates a bar-y mark.',
+ fields: {
+ data: 'data',
+ ...channelFields,
+ y1: 'numeric-channel',
+ y2: 'numeric-channel',
+ ...paintFields,
+ layout: 'layout',
+ inset: 'finite-number',
+ maxThickness: 'nonnegative-number',
+ radius: 'nonnegative-number',
+ },
+ create: barY,
+ },
+ {
+ id: 'tanstack.mark.dot',
+ summary: 'Creates a dot mark.',
+ fields: {
+ data: 'data',
+ ...channelFields,
+ r: 'numeric-channel',
+ ...paintFields,
+ strokeOpacity: 'opacity',
+ },
+ create: dot,
+ },
+ {
+ id: 'tanstack.mark.line-y',
+ summary: 'Creates a line-y mark.',
+ fields: {
+ data: 'data',
+ ...channelFields,
+ stroke: 'string',
+ strokeOpacity: 'opacity',
+ strokeWidth: 'nonnegative-number',
+ strokeDasharray: 'string',
+ points: 'boolean',
+ },
+ create: lineY,
+ },
+ {
+ id: 'tanstack.mark.rule-x',
+ summary: 'Creates a vertical reference rule spanning the plot.',
+ fields: {
+ data: 'data',
+ id: 'nonempty-string',
+ x: 'channel',
+ color: 'channel',
+ stroke: 'string',
+ strokeOpacity: 'opacity',
+ strokeWidth: 'nonnegative-number',
+ strokeDasharray: 'string',
+ },
+ create: ruleX,
+ },
+ {
+ id: 'tanstack.mark.rule-y',
+ summary: 'Creates a horizontal reference rule spanning the plot.',
+ fields: {
+ data: 'data',
+ id: 'nonempty-string',
+ y: 'channel',
+ color: 'channel',
+ stroke: 'string',
+ strokeOpacity: 'opacity',
+ strokeWidth: 'nonnegative-number',
+ strokeDasharray: 'string',
+ },
+ create: ruleY,
+ },
+ {
+ id: 'tanstack.mark.text',
+ summary:
+ 'Creates positioned text labels; x, y, and text name row fields, while presentation arguments are fixed values.',
+ fields: {
+ data: 'data',
+ ...channelFields,
+ text: 'channel',
+ fill: 'string',
+ fontSize: 'nonnegative-number',
+ fontWeight: 'nonnegative-number',
+ anchor: 'text-anchor',
+ rotate: 'finite-number',
+ dx: 'finite-number',
+ dy: 'finite-number',
+ },
+ create: text,
+ },
+ ] as const
+ ).map(({ create, ...operation }): ChartJsonOperation => ({
+ ...operation,
+ result: 'mark',
+ coordinate: 'cartesian',
+ required: ['data'],
+ create(arguments_) {
+ const { data, ...options } = arguments_
+ return Reflect.apply(create, undefined, [data, options])
+ },
+ })),
+ {
+ id: 'tanstack.mark.pie',
+ summary:
+ 'Creates one pie or donut from pre-aggregated rows; value names a nonnegative numeric field, category names a string or finite-number color field, and innerRadiusRatio defaults to 0 and must be in [0, 1).',
+ result: 'mark',
+ coordinate: 'circular',
+ fields: {
+ data: 'data',
+ value: 'nonempty-string',
+ category: 'nonempty-string',
+ id: 'nonempty-string',
+ innerRadiusRatio: 'ratio',
+ },
+ required: ['data', 'value', 'category'],
+ create({ data, value, category, id, innerRadiusRatio }) {
+ const valueField = value as string
+ const categoryField = category as string
+ const rows: object[] = []
+ const values = new WeakMap