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

JSON source

+
+ + + +
+
+ +