@thomasfosterau/charts 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +590 -1
- package/dist/core-BVbfW7N6.mjs +936 -0
- package/dist/core-BVbfW7N6.mjs.map +1 -0
- package/dist/core.d.mts +2 -0
- package/dist/core.mjs +1 -0
- package/dist/index-B32E5tbG.d.mts +405 -0
- package/dist/index-M0KOYMo4.d.mts +200 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +1 -0
- package/dist/prosemirror.d.mts +106 -0
- package/dist/prosemirror.mjs +245 -0
- package/dist/prosemirror.mjs.map +1 -0
- package/dist/spec-FF2Tds2a.mjs +351 -0
- package/dist/spec-FF2Tds2a.mjs.map +1 -0
- package/dist/svelte/Chart.svelte +73 -0
- package/dist/svelte/Chart.svelte.d.ts +37 -0
- package/dist/svelte/ChartAxes.svelte +39 -0
- package/dist/svelte/ChartAxes.svelte.d.ts +11 -0
- package/dist/svelte/ChartMarks.svelte +74 -0
- package/dist/svelte/ChartMarks.svelte.d.ts +9 -0
- package/dist/svelte/index.d.ts +9 -0
- package/dist/svelte/index.js +9 -0
- package/dist/vega.d.mts +97 -0
- package/dist/vega.mjs +82 -0
- package/dist/vega.mjs.map +1 -0
- package/package.json +93 -1
- package/schema/chart-spec.v1.json +393 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Foster
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,592 @@
|
|
|
1
1
|
# @thomasfosterau/charts
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A portable, renderer-agnostic charting system built around a durable chart specification: the spec and its JSON Schema, a pure TypeScript geometry core, and interpreters for Svelte, ProseMirror and Vega-Lite — plus a Swift package, [`ChartSpecKit`](https://github.com/thomasfosterau/components/tree/trunk/packages/charts/swift/ChartSpecKit), that decodes the same JSON.
|
|
4
|
+
|
|
5
|
+
The Svelte interpreter of this spec is [`@thomasfosterau/charts/svelte`](#thomasfosterauchartssvelte). It is unrelated to `Chart` from [`@thomasfosterau/components/charts`](https://github.com/thomasfosterau/components/blob/trunk/packages/components/README.md), which takes its own series props rather than this spec.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add @thomasfosterau/charts
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The spec, core and Vega-Lite entries need nothing else: `d3-scale`, `d3-shape` and `effect` (`^4.0.0`, so it shares your app's copy) are regular dependencies and install with the package. Each framework entry needs its framework, declared as an **optional** peer, so install only the one you import:
|
|
14
|
+
|
|
15
|
+
| Entry | Install alongside | Peer range |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| `@thomasfosterau/charts`, `/core`, `/vega`, `/schema.json` | nothing | — |
|
|
18
|
+
| `@thomasfosterau/charts/svelte` | `svelte` | `^5.40.0` |
|
|
19
|
+
| `@thomasfosterau/charts/prosemirror` | `prosemirror-model`, `prosemirror-view` | `^1.0.0` each |
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pnpm add @thomasfosterau/charts svelte # Svelte
|
|
23
|
+
pnpm add @thomasfosterau/charts prosemirror-model prosemirror-view # ProseMirror
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The package is ESM-only (`"type": "module"`) and `"sideEffects": false`.
|
|
27
|
+
|
|
28
|
+
## Architecture
|
|
29
|
+
|
|
30
|
+
The system has three layers with different lifespans, languages, and owners. **The persisted chart spec is the durable asset and the only cross-platform contract.** Everything else is a per-platform interpreter of that spec.
|
|
31
|
+
|
|
32
|
+
| Layer | What | Where |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| **1 — Spec** | Durable, declarative, language-neutral JSON chart description. Grammar-of-graphics level: mark + encodings + declared types + data reference. Never geometry, pixels, or renderer names. | [`@thomasfosterau/charts`](#entry-points) + [`schema/chart-spec.v1.json`](https://github.com/thomasfosterau/components/blob/trunk/packages/charts/schema/chart-spec.v1.json) |
|
|
35
|
+
| **2 — Core** | Pure, synchronous TypeScript. Turns `(spec, box, data)` into scales, dimensions, and geometry. No DOM, no reactivity, no rendering. | [`@thomasfosterau/charts/core`](#entry-points) |
|
|
36
|
+
| **3 — Interpreters** | Per-platform renderers: ProseMirror NodeView, Vega-Lite, Swift, Svelte/SvelteKit, Kotlin/Compose, media targets. Each reads the spec and renders it idiomatically. | `/prosemirror`, `/svelte`, `/vega`, [`swift/ChartSpecKit`](https://github.com/thomasfosterau/components/tree/trunk/packages/charts/swift/ChartSpecKit) |
|
|
37
|
+
|
|
38
|
+
Only the spec (Layer 1) and its emitted JSON Schema may cross a language boundary. Swift and Kotlin interpreters decode the same spec JSON; they never consume the TypeScript core.
|
|
39
|
+
|
|
40
|
+
### Invariants
|
|
41
|
+
|
|
42
|
+
- The spec is grammar-level: no geometry, pixels, or renderer names in portable parts.
|
|
43
|
+
- Every encoding carries an explicit type (`quantitative` | `ordinal` | `nominal` | `temporal`).
|
|
44
|
+
- `specVersion` is present and respected by every interpreter.
|
|
45
|
+
- Interaction stays out of the portable spec beyond trivial hints (e.g. `tooltip`).
|
|
46
|
+
- The portable subset targets the intersection of native charting envelopes (Swift Charts, Compose); web-only richness lives in `extensions` and degrades gracefully.
|
|
47
|
+
- Effect Schema is an implementation detail of the spec entry: no published `.d.mts` names `effect`, and the build fails if one does.
|
|
48
|
+
|
|
49
|
+
See [§ Capability matrix](#capability-matrix) for which interpreters honour which spec fields.
|
|
50
|
+
|
|
51
|
+
## Entry points
|
|
52
|
+
|
|
53
|
+
Each layer is its own entry, so a consumer loads only the interpreter it uses. The frameworks are **optional** peers: install the one your entry needs (see [§ Install](#install)). Every symbol is documented in [§ API reference](#api-reference).
|
|
54
|
+
|
|
55
|
+
| Entry | What | Peer to install |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `@thomasfosterau/charts` | The portable spec: types, `parseSpec` / `validateSpec` / `encodeSpec`, `SpecValidationError`, data guards | — |
|
|
58
|
+
| `@thomasfosterau/charts/core` | Pure TS geometry core: `computeContext(spec, box, data)` → scales, layout, geometry, ticks | — |
|
|
59
|
+
| `@thomasfosterau/charts/prosemirror` | ProseMirror integration: chart node schema + edit-time NodeView rendering SVG via the core, and a DOM-free SVG string renderer | `prosemirror-model`, `prosemirror-view` (1.x) |
|
|
60
|
+
| `@thomasfosterau/charts/svelte` | Svelte 5 `Chart` component: SSR-compatible SVG rendering via the core, shipped as `.svelte` source | `svelte` (5.40+) |
|
|
61
|
+
| `@thomasfosterau/charts/vega` | Spec → Vega-Lite emitter: escape hatch / SSR fallback, validated against the real compiler | — (emits a plain object; bring your own `vega-embed`) |
|
|
62
|
+
| `@thomasfosterau/charts/schema.json` | The JSON Schema artifact (draft 2020-12), for codegen and non-TS validators | — |
|
|
63
|
+
|
|
64
|
+
## Example spec
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"specVersion": "1",
|
|
69
|
+
"mark": "line",
|
|
70
|
+
"encoding": {
|
|
71
|
+
"x": { "field": "date", "type": "temporal" },
|
|
72
|
+
"y": { "field": "price", "type": "quantitative" }
|
|
73
|
+
},
|
|
74
|
+
"data": {
|
|
75
|
+
"values": [
|
|
76
|
+
{ "date": "2024-01-01", "price": 150 },
|
|
77
|
+
{ "date": "2024-01-02", "price": 155.5 }
|
|
78
|
+
]
|
|
79
|
+
},
|
|
80
|
+
"config": { "title": "Stock price", "tooltip": true }
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Example: a planting-calendar Gantt (spec 1.1)
|
|
85
|
+
|
|
86
|
+
A range bar is a `bar` with an end channel: one row per task (`y`), a bar from its start (`x`) to its end (`x2`).
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"specVersion": "1.1",
|
|
91
|
+
"mark": "bar",
|
|
92
|
+
"encoding": {
|
|
93
|
+
"y": { "field": "task", "type": "nominal" },
|
|
94
|
+
"x": { "field": "start", "type": "temporal" },
|
|
95
|
+
"x2": { "field": "end", "type": "temporal" },
|
|
96
|
+
"color": { "field": "bed", "type": "nominal" }
|
|
97
|
+
},
|
|
98
|
+
"data": {
|
|
99
|
+
"values": [
|
|
100
|
+
{ "task": "Sow tomatoes", "start": "2026-09-01", "end": "2026-10-15", "bed": "Glasshouse" },
|
|
101
|
+
{ "task": "Transplant", "start": "2026-10-15", "end": "2026-11-01", "bed": "North bed" },
|
|
102
|
+
{ "task": "Harvest", "start": "2026-12-20", "end": "2027-03-01", "bed": "North bed" }
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
"config": { "title": "Planting calendar" }
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Example: a plant taxonomy (spec 1.1)
|
|
110
|
+
|
|
111
|
+
A `tree` draws one node per row, joined to the row its `parent` names (`null` for a root). Nodes are laid out tidily: leaves in depth-first order, each parent centred over its children.
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"specVersion": "1.1",
|
|
116
|
+
"mark": "tree",
|
|
117
|
+
"encoding": {
|
|
118
|
+
"id": { "field": "id", "type": "nominal" },
|
|
119
|
+
"parent": { "field": "parent", "type": "nominal" },
|
|
120
|
+
"label": { "field": "name", "type": "nominal" },
|
|
121
|
+
"color": { "field": "rank", "type": "nominal" }
|
|
122
|
+
},
|
|
123
|
+
"data": {
|
|
124
|
+
"values": [
|
|
125
|
+
{ "id": "solanaceae", "parent": null, "name": "Solanaceae", "rank": "Family" },
|
|
126
|
+
{ "id": "solanum", "parent": "solanaceae", "name": "Solanum", "rank": "Genus" },
|
|
127
|
+
{ "id": "tomato", "parent": "solanum", "name": "S. lycopersicum", "rank": "Species" },
|
|
128
|
+
{ "id": "capsicum", "parent": "solanaceae", "name": "Capsicum", "rank": "Genus" }
|
|
129
|
+
]
|
|
130
|
+
},
|
|
131
|
+
"tree": { "orientation": "vertical", "linkStyle": "orthogonal" },
|
|
132
|
+
"config": { "title": "Nightshades" }
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Every interpreter also renders the hierarchy as a visually hidden nested list beside the SVG, so assistive technology reads the tree's structure rather than a flat picture.
|
|
137
|
+
|
|
138
|
+
## API reference
|
|
139
|
+
|
|
140
|
+
Every exported symbol, grouped by the entry that exports it. Signatures are the published TypeScript ones; all object types are `readonly`.
|
|
141
|
+
|
|
142
|
+
- [`@thomasfosterau/charts`](#thomasfosteraucharts) — the spec
|
|
143
|
+
- [`@thomasfosterau/charts/core`](#thomasfosterauchartscore) — geometry
|
|
144
|
+
- [`@thomasfosterau/charts/svelte`](#thomasfosterauchartssvelte)
|
|
145
|
+
- [`@thomasfosterau/charts/prosemirror`](#thomasfosterauchartsprosemirror)
|
|
146
|
+
- [`@thomasfosterau/charts/vega`](#thomasfosterauchartsvega)
|
|
147
|
+
- [`@thomasfosterau/charts/schema.json`](#thomasfosterauchartsschemajson)
|
|
148
|
+
|
|
149
|
+
### `@thomasfosterau/charts`
|
|
150
|
+
|
|
151
|
+
The portable spec (Layer 1): plain types, the parse / validate / encode functions, and the data guards. Parsing is strict: unknown keys are rejected, exactly where the [JSON Schema](#thomasfosterauchartsschemajson) sets `additionalProperties: false`.
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
import {
|
|
155
|
+
encodeSpec,
|
|
156
|
+
isDataRef,
|
|
157
|
+
parseSpec,
|
|
158
|
+
SpecValidationError,
|
|
159
|
+
validateSpec,
|
|
160
|
+
} from '@thomasfosterau/charts';
|
|
161
|
+
|
|
162
|
+
// `json` is an unknown value, e.g. a stored document attribute.
|
|
163
|
+
|
|
164
|
+
// Throwing form, for trusted input.
|
|
165
|
+
try {
|
|
166
|
+
const spec = parseSpec(json);
|
|
167
|
+
console.log(spec.mark, isDataRef(spec.data) ? spec.data.ref.kind : 'inline');
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof SpecValidationError) console.error(error.message);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Result form, for user-supplied input.
|
|
173
|
+
const result = validateSpec(json);
|
|
174
|
+
if (result.ok) {
|
|
175
|
+
const persisted = encodeSpec(result.value); // plain JSON, ready to store
|
|
176
|
+
} else {
|
|
177
|
+
console.error(result.errors.join('\n'));
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
#### Functions
|
|
182
|
+
|
|
183
|
+
| Function | Description |
|
|
184
|
+
| --- | --- |
|
|
185
|
+
| `parseSpec(input: unknown): ChartSpec` | Parses an unknown JSON value into a `ChartSpec`. Throws `SpecValidationError` when the value is not a valid spec; all problems are collected into the one message. |
|
|
186
|
+
| `validateSpec(input: unknown): ValidationResult` | The same check, returning a result instead of throwing. On failure, `errors` currently holds one string that describes every problem found, each with its path. |
|
|
187
|
+
| `encodeSpec(spec: ChartSpec): unknown` | Encodes a spec back to a plain JSON-safe value for persisting (for example into ProseMirror node attrs). Throws if `spec` does not satisfy the schema. |
|
|
188
|
+
| `isInlineData(data: Data): data is InlineData` | `true` when the spec's data is inline `values`. |
|
|
189
|
+
| `isDataRef(data: Data): data is DataRef` | `true` when the spec's data is an external `ref`. |
|
|
190
|
+
|
|
191
|
+
#### Classes and constants
|
|
192
|
+
|
|
193
|
+
| Export | Description |
|
|
194
|
+
| --- | --- |
|
|
195
|
+
| `SpecValidationError` | `Error` subclass (`name: 'SpecValidationError'`) thrown by `parseSpec`. |
|
|
196
|
+
| `SPEC_VERSION` | `'1.1'`: the version a new spec should declare. `parseSpec` accepts `'1'` and `'1.1'` and rejects any other `specVersion` (see [§ Spec versioning](#spec-versioning)). |
|
|
197
|
+
|
|
198
|
+
#### Types
|
|
199
|
+
|
|
200
|
+
| Type | Shape and role |
|
|
201
|
+
| --- | --- |
|
|
202
|
+
| `ChartSpec` | The portable spec: `{ specVersion: SpecVersion; mark: Mark; encoding: Encoding; data: Data; scales?: Scales; config?: Config; tree?: TreeOptions; extensions?: Extensions }`. |
|
|
203
|
+
| `SpecVersion` | `'1' \| '1.1'`. |
|
|
204
|
+
| `Mark` | `'bar' \| 'line' \| 'point' \| 'area' \| 'tree'` — the v1 four are the intersection of the native charting vocabularies; `tree` (spec 1.1) is a hierarchy drawn by its own layout. |
|
|
205
|
+
| `FieldType` | `'quantitative' \| 'ordinal' \| 'nominal' \| 'temporal'` — the declared type of an encoded field. Temporal values are ISO 8601 strings or epoch milliseconds. |
|
|
206
|
+
| `Channel` | `{ field: string; type: FieldType }` — maps a data field to a visual channel. Interpreters read values by the declared `type`, never by sniffing. |
|
|
207
|
+
| `Encoding` | `{ x?, y?, x2?, y2?, color?, size?, opacity?: Channel }`. Every v1 mark needs `x` and `y` to draw anything; `x2` / `y2` (spec 1.1) make a `bar` a range bar. `id` / `parent` / `label` (spec 1.1) belong to the `tree` mark, which takes no positional channels. |
|
|
208
|
+
| `TreeOptions` | `{ orientation?: 'vertical' \| 'horizontal'; linkStyle?: 'orthogonal' \| 'curved' }` (spec 1.1) — layout of the `tree` mark: roots at the top (default) or the left, right-angled (default) or curved links. |
|
|
209
|
+
| `DatumValue` | `string \| number \| boolean \| null` — one value in a row. |
|
|
210
|
+
| `Datum` | `{ [field: string]: DatumValue }` — one flat data row. |
|
|
211
|
+
| `InlineData` | `{ values: ReadonlyArray<Datum> }` — rows embedded in the spec. |
|
|
212
|
+
| `DataRefKind` | `'query' \| 'node' \| 'url'` — what an external reference points at: a host query, another content node, or a URL returning JSON rows. |
|
|
213
|
+
| `DataRef` | `{ ref: { kind: DataRefKind; id: string } }` — data stored outside the spec, resolved by the host. |
|
|
214
|
+
| `Data` | `InlineData \| DataRef` — exactly one of the two. |
|
|
215
|
+
| `ScaleHint` | `{ domain?: ReadonlyArray<number \| string>; nice?: boolean; zero?: boolean }` — optional hints for one positional scale. |
|
|
216
|
+
| `Scales` | `{ x?: ScaleHint; y?: ScaleHint }`. |
|
|
217
|
+
| `Config` | `{ title?: string; tooltip?: boolean }` — portable presentation hints. |
|
|
218
|
+
| `Extensions` | `{ [key: string]: unknown }` — web-only hints; native interpreters ignore the whole object. |
|
|
219
|
+
| `ValidationResult` | `{ ok: true; value: ChartSpec } \| { ok: false; errors: ReadonlyArray<string> }`. |
|
|
220
|
+
|
|
221
|
+
### `@thomasfosterau/charts/core`
|
|
222
|
+
|
|
223
|
+
The pure geometry core (Layer 2): `(spec, box, rows)` in, dimensions, scales, geometry and ticks out, all in pixel coordinates. Synchronous, with no DOM and no fetching. Most consumers call only `computeContext`; the scale, tick and geometry functions are there for interpreters that need finer control.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import { parseSpec } from '@thomasfosterau/charts';
|
|
227
|
+
import { computeContext } from '@thomasfosterau/charts/core';
|
|
228
|
+
|
|
229
|
+
const spec = parseSpec({
|
|
230
|
+
specVersion: '1',
|
|
231
|
+
mark: 'bar',
|
|
232
|
+
encoding: {
|
|
233
|
+
x: { field: 'fruit', type: 'nominal' },
|
|
234
|
+
y: { field: 'count', type: 'quantitative' },
|
|
235
|
+
},
|
|
236
|
+
data: {
|
|
237
|
+
values: [
|
|
238
|
+
{ fruit: 'apple', count: 3 },
|
|
239
|
+
{ fruit: 'pear', count: 5 },
|
|
240
|
+
],
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const context = computeContext(spec, { width: 640, height: 360 }, undefined, {
|
|
245
|
+
margins: { left: 64 },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
context.dimensions.plot; // { width: 556, height: 300 }
|
|
249
|
+
context.ticks.x?.map((tick) => tick.label); // ['apple', 'pear']
|
|
250
|
+
if (context.geometry.mark === 'bar') {
|
|
251
|
+
// `draw` stands in for your renderer.
|
|
252
|
+
for (const bar of context.geometry.bars) draw(bar.x, bar.y, bar.width, bar.height, bar.color);
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Geometry is in **plot** coordinates: translate by `dimensions.margins.left` / `.top` to place it in the outer box.
|
|
257
|
+
|
|
258
|
+
#### Functions
|
|
259
|
+
|
|
260
|
+
| Function | Description |
|
|
261
|
+
| --- | --- |
|
|
262
|
+
| `resolveChart(spec: unknown, box: Box, data?: DataRows, options?: ComputeContextOptions): ResolvedChart` | `validateSpec` then `computeContext`, with every failure returned as a message instead of thrown: `Invalid chart spec: …`, a data `ref` with no `data` passed, or the error `computeContext` threw. The bundled Svelte `Chart` component renders its `error` message as its fallback. |
|
|
263
|
+
| `computeContext(spec: ChartSpec, box: Box, data?: DataRows, options?: ComputeContextOptions): ChartContext` | Turns a parsed spec, the measured outer box and resolved rows into a `ChartContext`. `data`, when passed, is used instead of the spec's inline values; it is required when the spec's data is a `ref` (otherwise throws `Error`). Throws `TypeError` when a value contradicts its channel's declared type. x maps onto `[0, plot.width]` and y onto `[plot.height, 0]`, so larger values sit higher. Without both `x` and `y` encodings the geometry is empty. |
|
|
264
|
+
| `computeDimensions(outer: Box, margins?: Partial<Margins>): Dimensions` | Fills missing margins from `DEFAULT_MARGINS` and subtracts them from the box; the plot width and height are clamped at zero. |
|
|
265
|
+
| `makeAccessor(channel: Channel): (datum: Datum) => FieldValue` | A typed reader for one channel: `quantitative` → finite number, `temporal` → `Date` (ISO 8601 string or epoch ms), `ordinal` / `nominal` → string (numbers and booleans stringified). Missing and `null` values read as `null`; a value contradicting the declared type throws `TypeError`. |
|
|
266
|
+
| `resolvePositionalScale(channel: Channel, mark: Mark, pixelRange: readonly [number, number], data: DataRows, hint?: ScaleHint): PositionalScale` | Builds an x or y scale from the channel's type: `quantitative` → linear, `temporal` → UTC time, `ordinal` / `nominal` → band (padding 0.1) for `bar`, point (padding 0.5) otherwise. A continuous `hint.domain` with at least two entries supplies its first and last as the domain (otherwise the data extent is used); a categorical one is used whole. `hint.zero` (default `true` on a `bar` mark, else `false`) extends a linear domain to include zero, and `hint.nice` rounds linear and UTC domains. A single-value domain is padded (±1, or ±1 day). |
|
|
267
|
+
| `resolveColorScale(channel: Channel, data: DataRows): ResolvedScale` | `ordinal` / `nominal` → an `ordinal` scale onto `CATEGORICAL_PALETTE` in first-seen order (colours repeat past ten categories). `quantitative` / `temporal` → a `linear` scale normalised to `[0, 1]`, for the interpreter to map onto its own gradient. |
|
|
268
|
+
| `resolveSizeScale(channel: Channel, data: DataRows): ResolvedScale` | A square-root scale from the data extent onto `DEFAULT_RADIUS_RANGE`, so symbol area is proportional to the value. Only numeric (quantitative) values map; its `kind` reads `'linear'`. |
|
|
269
|
+
| `computeTicks(scale: PositionalScale, count?: number): Tick[]` | Axis ticks: d3 tick generation and formatting for linear and UTC scales (`count` defaults to `DEFAULT_TICK_COUNT`; UTC labels are time-zone independent); one tick per category, centred on its band, for band and point scales. |
|
|
270
|
+
| `buildLineGeometry(args): LineGeometry` | One `Series` per colour category (one unsplit series without a colour encoding), each with its points and an SVG path. Rows missing either position are skipped. |
|
|
271
|
+
| `buildAreaGeometry(args): AreaGeometry` | As `buildLineGeometry`, with each path closed down to the zero baseline, or to the bottom of the plot when y is not linear. |
|
|
272
|
+
| `buildBarGeometry(args): BarGeometry` | One `BarRect` per row, grown from the zero baseline (negative values hang below it). Band x-scales give the bar width; continuous x-scales get 80% of an equal share of the plot width, at least 1 px. |
|
|
273
|
+
| `buildTreeGeometry(args: { data; plot: Box; id: Channel; parent: Channel; label?; color?; colorScale?; options?: TreeOptions }): TreeGeometry` | Lays out a `tree`: leaves take equal breadth slots in depth-first order, each parent centred over its first and last child, depths spread evenly along the other axis, so siblings never overlap. Throws `Error` naming the row (1-based) on a missing or duplicate id, a parent that is not an id, or a cycle; `resolveChart` returns it as the message. |
|
|
274
|
+
| `outlineText(node: { label: string; category: string \| undefined }): string` | The text of one entry in a tree's nested-list alternative: the label, then the colour category in words. |
|
|
275
|
+
| `buildPointGeometry(args & { size?: Channel \| undefined; sizeScale?: ResolvedScale \| undefined }): PointGeometry` | One `PointCircle` per row; the radius comes from `sizeScale`, else `DEFAULT_POINT_RADIUS`. |
|
|
276
|
+
|
|
277
|
+
The four builders take `args: { data: DataRows; x: Channel; y: Channel; xScale: PositionalScale; yScale: PositionalScale; color?: Channel | undefined; colorScale?: ResolvedScale | undefined }`; series splitting and colours need both `color` and `colorScale`.
|
|
278
|
+
|
|
279
|
+
#### Constants
|
|
280
|
+
|
|
281
|
+
| Constant | Value | Role |
|
|
282
|
+
| --- | --- | --- |
|
|
283
|
+
| `DEFAULT_MARGINS` | `{ top: 20, right: 20, bottom: 40, left: 48 }` | Margins used where `ComputeContextOptions.margins` gives none; room for tick labels. |
|
|
284
|
+
| `CATEGORICAL_PALETTE` | Tableau 10: `'#4e79a7'`, `'#f28e2c'`, `'#e15759'`, `'#76b7b4'`, `'#59a14f'`, `'#edc949'`, `'#af7aa1'`, `'#ff9da7'`, `'#9c755f'`, `'#bab0ab'` | Colours for categorical colour encodings. The bundled interpreters draw uncoloured marks in its first entry. |
|
|
285
|
+
| `DEFAULT_RADIUS_RANGE` | `[3, 12]` | Radius range in pixels for the `size` channel on point marks. |
|
|
286
|
+
| `DEFAULT_POINT_RADIUS` | `4` | Point radius when there is no size encoding or the value is missing. |
|
|
287
|
+
| `DEFAULT_TICK_COUNT` | `5` | Target tick count for continuous scales in `computeTicks`. |
|
|
288
|
+
| `VISUALLY_HIDDEN_STYLE` | an inline `style` string | The visually-hidden style every interpreter puts on a tree's nested-list alternative (inline, since the interpreters ship no CSS). |
|
|
289
|
+
|
|
290
|
+
#### Types
|
|
291
|
+
|
|
292
|
+
| Type | Shape and role |
|
|
293
|
+
| --- | --- |
|
|
294
|
+
| `Box` | `{ width: number; height: number }` — a pixel box. |
|
|
295
|
+
| `Margins` | `{ top; right; bottom; left: number }` — pixels between the outer box and the plot. |
|
|
296
|
+
| `Dimensions` | `{ outer: Box; margins: Margins; plot: Box }` — `plot` is `outer` minus `margins`. |
|
|
297
|
+
| `ComputeContextOptions` | `{ margins?: Partial<Margins> }`. |
|
|
298
|
+
| `DataRows` | `ReadonlyArray<Datum>` — resolved rows, what the host passes after resolving a data ref. |
|
|
299
|
+
| `FieldValue` | `number \| Date \| string \| null` — a typed value read by `makeAccessor`; `null` means missing, and geometry builders skip the row. |
|
|
300
|
+
| `DomainValue` | `number \| string \| Date` — a value in a scale's domain. |
|
|
301
|
+
| `ScaleKind` | `'linear' \| 'utc' \| 'band' \| 'point' \| 'ordinal'`. |
|
|
302
|
+
| `ResolvedScale` | `{ kind: ScaleKind; domain: ReadonlyArray<DomainValue>; range: ReadonlyArray<number> \| ReadonlyArray<string>; apply(value: DomainValue): number \| string \| undefined; bandwidth: number }` — a callable scale plus a serialisable description (`kind`, `domain`, `range`) from which the mapping can be rebuilt. `apply` returns `undefined` for an unmappable value; `bandwidth` is 0 except on band scales. |
|
|
303
|
+
| `PositionalScale` | A discriminated union on `kind` (`'linear' \| 'utc' \| 'band' \| 'point'`) of `{ kind; resolved: ResolvedScale; d3 }`, where `d3` is the matching `d3-scale` scale. The input to `computeTicks` and the geometry builders. |
|
|
304
|
+
| `ChartScales` | `{ x?; y?; color?; size?: ResolvedScale }` — the scales `computeContext` resolved, one per encoded channel (opacity is not resolved). |
|
|
305
|
+
| `Tick` | `{ value: DomainValue; position: number; label: string }` — `position` is in plot pixels along the axis. |
|
|
306
|
+
| `ChartTicks` | `{ x?: ReadonlyArray<Tick>; y?: ReadonlyArray<Tick> }`. |
|
|
307
|
+
| `ChartContext` | `{ dimensions: Dimensions; scales: ChartScales; geometry: Geometry; ticks: ChartTicks }` — everything an interpreter needs to draw. |
|
|
308
|
+
| `ResolvedChart` | `{ kind: 'ready'; spec: ChartSpec; context: ChartContext } \| { kind: 'error'; message: string }` — the result of `resolveChart`. |
|
|
309
|
+
| `Geometry` | `LineGeometry \| BarGeometry \| PointGeometry \| AreaGeometry \| TreeGeometry`, discriminated by `mark`. |
|
|
310
|
+
| `TreeGeometry` | `{ mark: 'tree'; nodes: ReadonlyArray<TreeNodeBox>; links: ReadonlyArray<TreeLink>; outline: ReadonlyArray<TreeOutlineNode> }` — no scales or ticks; the interpreters draw no axes for it. |
|
|
311
|
+
| `TreeNodeBox` | `{ id; label: string; depth; x; y; width; height: number; color: string \| undefined; datum: Datum }` — a node's box in plot pixels, depth-first. |
|
|
312
|
+
| `TreeLink` | `{ from; to: string; path: string }` — parent and child ids, and the SVG `d` of the link. |
|
|
313
|
+
| `TreeOutlineNode` | `{ id; label: string; category: string \| undefined; children: ReadonlyArray<TreeOutlineNode> }` — the hierarchy for the nested-list text alternative. |
|
|
314
|
+
| `LineGeometry` | `{ mark: 'line'; series: ReadonlyArray<Series> }`. |
|
|
315
|
+
| `AreaGeometry` | `{ mark: 'area'; series: ReadonlyArray<Series> }` — paths closed to the baseline. |
|
|
316
|
+
| `Series` | `{ key: string \| null; color: string \| undefined; points: ReadonlyArray<SeriesPoint>; path: string }` — `key` is the colour category (`null` for the unsplit series), `path` the SVG `d` attribute (`''` with no points). |
|
|
317
|
+
| `SeriesPoint` | `{ x: number; y: number; datum: Datum }`. |
|
|
318
|
+
| `BarGeometry` | `{ mark: 'bar'; bars: ReadonlyArray<BarRect> }`. |
|
|
319
|
+
| `BarRect` | `{ x; y; width; height: number; color: string \| undefined; datum: Datum }`. |
|
|
320
|
+
| `PointGeometry` | `{ mark: 'point'; points: ReadonlyArray<PointCircle> }`. |
|
|
321
|
+
| `PointCircle` | `{ cx; cy; r: number; color: string \| undefined; datum: Datum }`. |
|
|
322
|
+
|
|
323
|
+
`color` is `undefined` wherever there is no colour encoding; renderers pick their own default.
|
|
324
|
+
|
|
325
|
+
### `@thomasfosterau/charts/svelte`
|
|
326
|
+
|
|
327
|
+
Peer: `svelte` (5.40+). A Svelte 5 `Chart` component. It is published as `.svelte` source (`svelte-package`), so your app's Svelte compiles it; it renders the same under `svelte/server` and on the client.
|
|
328
|
+
|
|
329
|
+
```svelte
|
|
330
|
+
<script lang="ts">
|
|
331
|
+
import type { ChartSpec } from '@thomasfosterau/charts';
|
|
332
|
+
import type { DataRows } from '@thomasfosterau/charts/core';
|
|
333
|
+
import { Chart } from '@thomasfosterau/charts/svelte';
|
|
334
|
+
|
|
335
|
+
let { spec, rows }: { spec: ChartSpec; rows: DataRows } = $props();
|
|
336
|
+
</script>
|
|
337
|
+
|
|
338
|
+
<Chart {spec} data={rows} width={800} height={400} class="sales-chart">
|
|
339
|
+
{#snippet fallback(message)}
|
|
340
|
+
<p role="alert">{message}</p>
|
|
341
|
+
{/snippet}
|
|
342
|
+
</Chart>
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
| Prop | Type | Default | Description |
|
|
346
|
+
| --- | --- | --- | --- |
|
|
347
|
+
| `spec` | `ChartSpec` | required | The spec; validated again at render time, since specs come from documents. |
|
|
348
|
+
| `data` | `DataRows` | — | Resolved rows. Required when the spec's data is a `ref`; replaces inline values when given. |
|
|
349
|
+
| `width` | `number` | `640` | Outer width in pixels (sets the `viewBox`, and with it the aspect ratio). |
|
|
350
|
+
| `height` | `number` | `360` | Outer height in pixels. |
|
|
351
|
+
| `margins` | `Partial<Margins>` | `DEFAULT_MARGINS` | Margin overrides. |
|
|
352
|
+
| `fallback` | `Snippet<[message: string]>` | alert `<div>` | What to render on an error; receives the message. |
|
|
353
|
+
| …rest | `SVGAttributes<SVGSVGElement>` | — | Spread onto the `<svg>`. `class` also lands on the default fallback `<div>`; an `aria-label` replaces the one taken from `config.title`. |
|
|
354
|
+
|
|
355
|
+
It renders `<svg role="img" viewBox="0 0 {width} {height}">`, named by `aria-label` from `config.title`, with the title drawn above the plot when set. On an error (invalid spec, a `ref` with no `data`, or a throw from the core) it renders `fallback`, or by default `<div role="alert" data-chart-error>` with the message.
|
|
356
|
+
|
|
357
|
+
### `@thomasfosterau/charts/prosemirror`
|
|
358
|
+
|
|
359
|
+
Peers: `prosemirror-model`, `prosemirror-view` (the example's `prosemirror-state` is your editor's dependency, not a peer of this package). A chart node that stores the spec in its attrs, an edit-time NodeView that draws it, and the DOM-free SVG string renderer the NodeView uses.
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
import {
|
|
363
|
+
CHART_NODE_NAME,
|
|
364
|
+
chartNodeViewFactory,
|
|
365
|
+
createChartNodeSpec,
|
|
366
|
+
type DataResolver,
|
|
367
|
+
} from '@thomasfosterau/charts/prosemirror';
|
|
368
|
+
import { Schema } from 'prosemirror-model';
|
|
369
|
+
import { EditorState } from 'prosemirror-state';
|
|
370
|
+
import { EditorView } from 'prosemirror-view';
|
|
371
|
+
|
|
372
|
+
const schema = new Schema({
|
|
373
|
+
nodes: {
|
|
374
|
+
doc: { content: 'block+' },
|
|
375
|
+
paragraph: {
|
|
376
|
+
group: 'block',
|
|
377
|
+
content: 'text*',
|
|
378
|
+
toDOM: () => ['p', 0],
|
|
379
|
+
parseDOM: [{ tag: 'p' }],
|
|
380
|
+
},
|
|
381
|
+
text: {},
|
|
382
|
+
[CHART_NODE_NAME]: createChartNodeSpec(),
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// `fetchRows` (your data API) and `spec` (the chart to insert) are the host app's.
|
|
387
|
+
const resolveData: DataResolver = async (chart) =>
|
|
388
|
+
'ref' in chart.data ? fetchRows(chart.data.ref.id) : [];
|
|
389
|
+
|
|
390
|
+
new EditorView(document.querySelector('#editor'), {
|
|
391
|
+
state: EditorState.create({
|
|
392
|
+
schema,
|
|
393
|
+
doc: schema.node('doc', null, [schema.node(CHART_NODE_NAME, { spec })]),
|
|
394
|
+
}),
|
|
395
|
+
nodeViews: { [CHART_NODE_NAME]: chartNodeViewFactory({ resolveData }) },
|
|
396
|
+
});
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
#### Functions and classes
|
|
400
|
+
|
|
401
|
+
| Export | Description |
|
|
402
|
+
| --- | --- |
|
|
403
|
+
| `createChartNodeSpec(options?: ChartNodeSpecOptions): NodeSpec` | The chart node: an atom (selectable, draggable) whose only attr is `spec` (default `null`), so `toJSON` / `fromJSON` carry the spec as is. `toDOM` writes `<div class="chart-node" data-chart-spec="…">` with the encoded spec JSON (empty for a `null` spec); `parseDOM` reads it back and validates it, and an invalid spec does not match the rule, leaving other rules to try. |
|
|
404
|
+
| `chartNodeViewFactory(options?: ChartNodeViewOptions): (node: Node, view: EditorView, getPos: () => number \| undefined) => ChartNodeView` | A constructor for the editor's `nodeViews` prop. |
|
|
405
|
+
| `ChartNodeView` | `new ChartNodeView(node: Node, view: EditorView, options?: ChartNodeViewOptions)`. A `NodeView` whose `dom` is a `<div class="chart-node-view">` holding the rendered SVG. It re-validates the spec on every render and otherwise shows a `<div class="chart-node-view__placeholder">` with a message (no chart configured, invalid spec, data stored externally when there is no `resolveData`, loading, failed to load, or the core's error). It measures its own width and keeps `defaultBox`'s aspect ratio. `update` re-renders for a node of the same type (and returns `false` for any other), `ignoreMutation` returns `true`, and `destroy` drops any pending async resolution. |
|
|
406
|
+
| `renderTreeOutline(context: ChartContext): string` | A tree's visually hidden nested `<ul>` text alternative as an HTML string (`''` for any other mark). The NodeView appends it after the SVG, whose `role="img"` would otherwise hide the structure. |
|
|
407
|
+
| `renderContextToSvg(context: ChartContext, options?: RenderSvgOptions): string` | Renders a `ChartContext` to an SVG string with no DOM, for the NodeView's `innerHTML`, server rendering or tests. The root is `<svg role="img" viewBox="…">` with no `aria-label` (the title is drawn as `<text>`); the title, class and tick labels are XML-escaped. |
|
|
408
|
+
|
|
409
|
+
#### Constants
|
|
410
|
+
|
|
411
|
+
| Constant | Value | Role |
|
|
412
|
+
| --- | --- | --- |
|
|
413
|
+
| `CHART_NODE_NAME` | `'chart'` | The conventional node name; use it as the key in your schema's `nodes` and in `nodeViews`. |
|
|
414
|
+
| `CHART_SPEC_ATTRIBUTE` | `'data-chart-spec'` | The DOM attribute that carries the serialised spec in `toDOM` / `parseDOM`. |
|
|
415
|
+
|
|
416
|
+
#### Types
|
|
417
|
+
|
|
418
|
+
| Type | Shape and role |
|
|
419
|
+
| --- | --- |
|
|
420
|
+
| `ChartNodeSpecOptions` | `{ group?: string; className?: string }` — node group (default `'block'`) and the class of the serialised `<div>` (default `'chart-node'`). |
|
|
421
|
+
| `ChartNodeAttrs` | `{ spec: ChartSpec \| null }` — a chart node's attrs; `null` is an empty placeholder chart. |
|
|
422
|
+
| `ChartNodeViewOptions` | `{ defaultBox?: Box; resolveData?: DataResolver; className?: string }` — `defaultBox` (default 640 × 360) is used before the element can be measured and sets the aspect ratio after; without `resolveData`, charts whose data is a `ref` show a placeholder; `className` replaces `'chart-node-view'` on the wrapper. |
|
|
423
|
+
| `DataResolver` | `(spec: ChartSpec) => DataRows \| Promise<DataRows>` — resolves a spec's data ref at edit time. A stale promise never overwrites a newer render. |
|
|
424
|
+
| `RenderSvgOptions` | `{ title?: string \| undefined; className?: string \| undefined }` — title drawn above the plot, and the class of the root `<svg>`. |
|
|
425
|
+
|
|
426
|
+
### `@thomasfosterau/charts/vega`
|
|
427
|
+
|
|
428
|
+
No peer: emits a plain Vega-Lite object, so bring your own `vega-embed` (or the Vega-Lite compiler) to render it. The test suite compiles every emitted spec with the real Vega-Lite compiler.
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
import { emitVegaLite, VegaLiteEmitError } from '@thomasfosterau/charts/vega';
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
// `rows` are the pre-resolved rows a query or node ref needs.
|
|
435
|
+
const vegaLite = emitVegaLite(spec, { rows, height: 300 });
|
|
436
|
+
await vegaEmbed('#chart', vegaLite);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
if (error instanceof VegaLiteEmitError) console.error(error.message);
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
| Export | Description |
|
|
443
|
+
| --- | --- |
|
|
444
|
+
| `emitVegaLite(spec: ChartSpec, options?: EmitVegaLiteOptions): VegaLiteSpec` | Translates the mark and typed encodings one-to-one. `config.title` becomes `title`, `config.tooltip` becomes `mark.tooltip`, and `scales.x` / `scales.y` become the x and y `scale` (`domain`, `nice`, `zero`); `extensions` are ignored. Data comes from `options.rows` if given, else inline `values`, else a `url` ref's `id` as `url`. |
|
|
445
|
+
| `VegaLiteEmitError` | `Error` subclass (`name: 'VegaLiteEmitError'`) with a `reason`: `'unresolved-data'` when a `query` or `node` ref arrives without `options.rows`, `'unsupported-mark'` for a `tree` (Vega-Lite has no tree layout). |
|
|
446
|
+
| `VegaLiteEmitErrorReason` | `'unsupported-mark' \| 'unresolved-data'`. |
|
|
447
|
+
| `VEGA_LITE_SCHEMA` | `'https://vega.github.io/schema/vega-lite/v6.json'` — the `$schema` of every emitted spec. |
|
|
448
|
+
| `EmitVegaLiteOptions` | `{ rows?: ReadonlyArray<Datum>; width?: number \| 'container'; height?: number }` — pre-resolved rows, width (default `'container'`, responsive) and height in pixels. |
|
|
449
|
+
| `VegaLiteSpec` | `{ $schema: string; title?: string; mark: { type: Mark; tooltip?: boolean }; data: VegaLiteData; encoding: VegaLiteEncoding; width?: number \| 'container'; height?: number }` — the single-view subset the emitter produces, typed structurally rather than imported from `vega-lite`. |
|
|
450
|
+
| `VegaLiteData` | `{ values: ReadonlyArray<Record<string, unknown>> } \| { url: string }`. |
|
|
451
|
+
| `VegaLiteEncoding` | `{ x?, y?, color?, size?, opacity?: VegaLiteFieldDef; x2?, y2?: VegaLiteSecondaryFieldDef }`. |
|
|
452
|
+
| `VegaLiteSecondaryFieldDef` | `{ field }`: a range end, sharing its primary channel's type and scale. |
|
|
453
|
+
| `VegaLiteFieldDef` | `{ field: string; type: FieldType; scale?: { domain?: ReadonlyArray<number \| string>; nice?: boolean; zero?: boolean } }`. |
|
|
454
|
+
|
|
455
|
+
### `@thomasfosterau/charts/schema.json`
|
|
456
|
+
|
|
457
|
+
The spec's JSON Schema ([`schema/chart-spec.v1.json`](https://github.com/thomasfosterau/components/blob/trunk/packages/charts/schema/chart-spec.v1.json), draft 2020-12), generated from the same source as `parseSpec` and held equal to it by a test. Use it for codegen or for validating specs outside TypeScript:
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
import schema from '@thomasfosterau/charts/schema.json' with { type: 'json' };
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
## Capability matrix
|
|
464
|
+
|
|
465
|
+
For every spec field and mark type, this records which interpreters honour it and how it degrades when unsupported. This is the artifact that keeps multiple interpreters honest and prevents the spec from drifting into web-only assumptions.
|
|
466
|
+
|
|
467
|
+
**Status legend**
|
|
468
|
+
|
|
469
|
+
- ✅ — implemented
|
|
470
|
+
- 🔵 — planned, expected to be supported natively
|
|
471
|
+
- 🟡 — planned, supported with degradation (see notes)
|
|
472
|
+
- ⛔ — will not support; degradation behaviour is normative
|
|
473
|
+
- — — not applicable
|
|
474
|
+
|
|
475
|
+
**Interpreter columns**
|
|
476
|
+
|
|
477
|
+
- **TS core** — `@thomasfosterau/charts/core` (`computeContext`). Web interpreters that sit on the core — the ProseMirror NodeView (`/prosemirror`) and the Svelte component (`/svelte`) — inherit this column.
|
|
478
|
+
- **Vega-Lite** — `@thomasfosterau/charts/vega` (`emitVegaLite`), the spec → Vega-Lite emitter (escape hatch / SSR fallback), validated against the real Vega-Lite compiler
|
|
479
|
+
- **Swift Charts** — `swift/ChartSpecKit`: the `ChartSpec` Codable decoder plus a swappable SwiftUI renderer whose default backend is Apple Swift Charts. Compiled and tested in CI (`.github/workflows/chartspeckit.yml`); renderer statuses stay 🔵 until each is verified on device
|
|
480
|
+
- **Compose** — the planned Kotlin/Android interpreter (charting approach not yet chosen; envelope may constrain further)
|
|
481
|
+
- **Media** — planned PDF / raster image / video targets (most likely built on the TS core)
|
|
482
|
+
|
|
483
|
+
### Marks
|
|
484
|
+
|
|
485
|
+
| Mark | TS core | Vega-Lite | Swift Charts | Compose | Media | Degradation when unsupported |
|
|
486
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
487
|
+
| `bar` | ✅ | ✅ `bar` | 🔵 `BarMark` | 🔵 | 🔵 | n/a — part of the portable intersection |
|
|
488
|
+
| `line` | ✅ | ✅ `line` | 🔵 `LineMark` | 🔵 | 🔵 | n/a — part of the portable intersection |
|
|
489
|
+
| `point` | ✅ | ✅ `point`/`circle` | 🔵 `PointMark` | 🔵 | 🔵 | n/a — part of the portable intersection |
|
|
490
|
+
| `area` | ✅ | ✅ `area` | 🔵 `AreaMark` | 🔵 | 🔵 | n/a — part of the portable intersection |
|
|
491
|
+
| `tree` (spec 1.1) | ✅ tidy layout (`buildTreeGeometry`), plus a visually hidden nested list | ⛔ `VegaLiteEmitError` with reason `'unsupported-mark'` (Vega-Lite has no tree layout; use full Vega's `tree` transform or another interpreter) | 🟡 `TreeLayout` drawn on a SwiftUI `Canvas` outside Swift Charts, the hierarchy as accessibility children | 🟡 custom layout outside the charting library | 🔵 | Render the nested-list fallback (the hierarchy as text), never an empty plot |
|
|
492
|
+
|
|
493
|
+
The v1 mark set is deliberately the intersection of the native charting vocabularies. `tree` is the one exception (owner decision on #256): no native charting library has it, so every interpreter lays it out itself from the same algorithm, and the matrix rule 2 below is met by the nested-list degradation. New marks (e.g. `rect`, `rule`, `arc`) require a minor `specVersion` bump (see [§ Spec versioning](#spec-versioning)) and a row here **before** implementation.
|
|
494
|
+
|
|
495
|
+
### Encoding channels
|
|
496
|
+
|
|
497
|
+
| Field | TS core | Vega-Lite | Swift Charts | Compose | Media | Degradation when unsupported |
|
|
498
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
499
|
+
| `encoding.x` | ✅ | ✅ | 🔵 `.value(x:)` | 🔵 | 🔵 | Required for all v1 marks; a spec without `x`/`y` renders an empty plot |
|
|
500
|
+
| `encoding.y` | ✅ | ✅ | 🔵 `.value(y:)` | 🔵 | 🔵 | Same as `x` |
|
|
501
|
+
| `encoding.x2` (spec 1.1) | ✅ range bars: each bar spans `x` to `x2` in `y`'s bands; an end before its start is normalised; the domain covers both ends and is not forced through zero | ✅ `x2` (field only; shares `x`'s type and scale) | 🔵 `BarMark(xStart:xEnd:y:)` | 🟡 floating bars via a custom column layer; where unavailable, a bar from the axis origin to `x2` | 🔵 | Only with `mark: "bar"` and an `x` of the same `quantitative` or `temporal` type (`parseSpec` rejects anything else). The Gantt / timeline basis |
|
|
502
|
+
| `encoding.y2` (spec 1.1) | ✅ vertical range bars, as `x2` | ✅ `y2` | 🔵 `BarMark(x:yStart:yEnd:)` | 🟡 as `x2` | 🔵 | As `x2`, on y |
|
|
503
|
+
| `encoding.id` / `encoding.parent` (spec 1.1) | ✅ `tree` only: `id` names each row (unique), `parent` names its parent's id (`null` for a root; several roots make a forest) | ⛔ (no `tree`) | 🟡 `TreeLayout` | 🟡 | 🔵 | Both required with `mark: "tree"` and rejected on every other mark; a `tree` takes no `x` / `y` / `x2` / `y2`. Row errors (duplicate id, unknown parent, cycle) are reported with the row number |
|
|
504
|
+
| `encoding.label` (spec 1.1) | ✅ `tree` only: the node text | ⛔ | 🟡 | 🟡 | 🔵 | Ignored → the node's id |
|
|
505
|
+
| `encoding.color` | ✅ | ✅ | 🔵 `.foregroundStyle(by:)` | 🔵 | 🔵 | Ignored → single default series colour |
|
|
506
|
+
| `encoding.size` | ✅ point only | ✅ | 🟡 `.symbolSize(by:)` (point only) | 🟡 | 🔵 | Ignored → default mark size |
|
|
507
|
+
| `encoding.opacity` | 🟡 parsed, not resolved | ✅ | 🟡 limited | 🟡 | 🔵 | Ignored → fully opaque marks |
|
|
508
|
+
|
|
509
|
+
### Mark options
|
|
510
|
+
|
|
511
|
+
| Field | TS core | Vega-Lite | Swift Charts | Compose | Media | Degradation when unsupported |
|
|
512
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
513
|
+
| `tree.orientation` (spec 1.1) | ✅ `vertical` (roots on top, default) or `horizontal` (roots on the left) | ⛔ | 🟡 | 🟡 | 🔵 | Ignored → vertical |
|
|
514
|
+
| `tree.linkStyle` (spec 1.1) | ✅ `orthogonal` (default) or `curved` | ⛔ | 🟡 | 🟡 | 🔵 | Ignored → orthogonal |
|
|
515
|
+
|
|
516
|
+
`tree` is rejected on any mark other than `tree`.
|
|
517
|
+
|
|
518
|
+
### Encoding field types
|
|
519
|
+
|
|
520
|
+
| Type | TS core | Vega-Lite | Swift Charts | Compose | Media | Notes |
|
|
521
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
522
|
+
| `quantitative` | ✅ linear scale | ✅ | 🔵 `Double` plottable | 🔵 | 🔵 | Values must be finite JSON numbers |
|
|
523
|
+
| `temporal` | ✅ UTC scale | ✅ | 🔵 `Date` plottable | 🔵 | 🔵 | ISO 8601 strings or epoch ms; decoders reconstruct dates from the declared type, never sniff |
|
|
524
|
+
| `ordinal` | ✅ band/point scale | ✅ | 🟡 treated as categorical | 🟡 | 🔵 | Ordering is data order |
|
|
525
|
+
| `nominal` | ✅ band/point scale | ✅ | 🔵 `String` plottable | 🔵 | 🔵 | |
|
|
526
|
+
|
|
527
|
+
### Data
|
|
528
|
+
|
|
529
|
+
| Field | TS core | Vega-Lite | Swift Charts | Compose | Media | Degradation when unsupported |
|
|
530
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
531
|
+
| `data.values` (inline) | ✅ | ✅ inline `values` | 🔵 decoded rows | 🔵 | 🔵 | n/a |
|
|
532
|
+
| `data.ref` kind `url` | 🟡 host resolves | ✅ `url` | 🔵 native fetch | 🔵 | 🟡 | Interpreters that cannot fetch render an empty chart with the title |
|
|
533
|
+
| `data.ref` kind `query` | 🟡 host resolves | ⛔ host pre-resolves to values | 🟡 host app resolves | 🟡 | 🟡 | Host must resolve before handing to the interpreter |
|
|
534
|
+
| `data.ref` kind `node` | 🟡 host resolves | ⛔ host pre-resolves to values | 🟡 host app resolves | 🟡 | 🟡 | Host must resolve before handing to the interpreter |
|
|
535
|
+
|
|
536
|
+
The TS core never resolves references itself: `computeContext` takes already-resolved rows. Resolution is the host's/interpreter's job in every language.
|
|
537
|
+
|
|
538
|
+
### Scales
|
|
539
|
+
|
|
540
|
+
| Field | TS core | Vega-Lite | Swift Charts | Compose | Media | Degradation when unsupported |
|
|
541
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
542
|
+
| `scales.x.domain` / `scales.y.domain` | ✅ | ✅ `scale.domain` | 🟡 `chartXScale(domain:)` | 🟡 | 🔵 | Ignored → inferred from data extent |
|
|
543
|
+
| `scales.*.nice` | ✅ | ✅ `scale.nice` | ⛔ ignored (Swift Charts nices automatically) | 🟡 | 🔵 | Ignored → interpreter default |
|
|
544
|
+
| `scales.*.zero` | ✅ | ✅ `scale.zero` | ⛔ ignored | 🟡 | 🔵 | Ignored → interpreter default (the TS core defaults `zero` to `true` on a bar chart's quantitative axes and `false` elsewhere; an explicit hint wins) |
|
|
545
|
+
|
|
546
|
+
### Config
|
|
547
|
+
|
|
548
|
+
| Field | TS core | Vega-Lite | Swift Charts | Compose | Media | Degradation when unsupported |
|
|
549
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
550
|
+
| `config.title` | 🟡 not in `ChartContext`; the Svelte and ProseMirror interpreters draw it (rendering is the interpreter's job) | ✅ `title` | 🔵 SwiftUI label | 🔵 | 🔵 | Ignored → untitled chart |
|
|
551
|
+
| `config.tooltip` | — (interaction is per-platform) | ✅ `tooltip` | 🟡 maps to selection/`chartOverlay` if the app implements it | 🟡 | ⛔ static output has no tooltips | Ignored entirely; never an error |
|
|
552
|
+
|
|
553
|
+
### Extensions
|
|
554
|
+
|
|
555
|
+
| Field | TS core | Vega-Lite | Swift Charts | Compose | Media | Degradation when unsupported |
|
|
556
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
557
|
+
| `extensions.*` | 🟡 passed through untouched | ⛔ ignored | ⛔ ignored | ⛔ ignored | 🟡 | **Normative:** native interpreters must ignore the whole object. Web interpreters must render correctly when it is absent. |
|
|
558
|
+
|
|
559
|
+
### Spec versioning
|
|
560
|
+
|
|
561
|
+
| Field | TS core | Vega-Lite | Swift Charts | Compose | Media | Behaviour |
|
|
562
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
563
|
+
| `specVersion` | ✅ accepts `'1'` and `'1.1'` | ✅ | 🔵 enforced by decoder (`.v1`, `.v1_1`) | 🔵 | 🔵 | See the rule below |
|
|
564
|
+
|
|
565
|
+
**The rule (owner decision on #255 / #256):** versions are `major.minor`, and `SPEC_VERSION` is the one a new spec should declare (`'1.1'`).
|
|
566
|
+
|
|
567
|
+
- **A minor version only adds.** Every `'1'` spec is a valid `'1.1'` spec and renders the same. New channels and marks come in a minor version and need a row in this matrix first. `'1.1'` adds `encoding.x2` / `encoding.y2`, the `tree` mark with its `id` / `parent` / `label` channels and `tree` options, and `parseSpec` rejects all of them in a `'1'` spec.
|
|
568
|
+
- **Degradation across minors is defined:** an interpreter given a newer `1.x` spec ignores channels it does not know, and renders a mark it does not know through the data-table fallback, never an empty plot. A new major version is a break, and an interpreter rejects it with a clear error.
|
|
569
|
+
- **What is implemented today:** this release accepts `'1'` and `'1.1'` exactly. Interpreters published before it (spec `'1'` only) close every object, so they reject a `'1.1'` document rather than ignore its range channels. Accepting newer `1.x` minors and the data-table fallback are tracked in #304.
|
|
570
|
+
|
|
571
|
+
### Rules for changing this matrix
|
|
572
|
+
|
|
573
|
+
1. Every new spec field or mark gets a row here **in the same change** that adds it to the schema.
|
|
574
|
+
2. A field may only enter the portable (non-`extensions`) spec if Swift Charts **and** the chosen Compose approach can honour it or degrade from it gracefully.
|
|
575
|
+
3. Degradation behaviour is part of the contract: an interpreter that cannot honour a field must do what this matrix says, not improvise.
|
|
576
|
+
|
|
577
|
+
## Development
|
|
578
|
+
|
|
579
|
+
From the workspace root (see the [root README](https://github.com/thomasfosterau/components/blob/trunk/README.md) for setup):
|
|
580
|
+
|
|
581
|
+
```sh
|
|
582
|
+
vp run --filter ./packages/charts test:unit # Vitest under Node (happy-dom where a suite mounts)
|
|
583
|
+
vp run --filter ./packages/charts check # tsc, plus the DOM-free re-check of spec + core
|
|
584
|
+
vp run --filter ./packages/charts build # vp pack -> dist/, then the Effect-leak check
|
|
585
|
+
vp run --filter ./packages/charts emit-schema # regenerate schema/chart-spec.v1.json
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
The JSON Schema artifact at `schema/chart-spec.v1.json` is committed and kept in sync by a test; if you change the spec schema, rerun `emit-schema` and commit the result. The Swift package is tested by its own workflow (`swift test` in `swift/ChartSpecKit`, on macOS).
|
|
589
|
+
|
|
590
|
+
## License
|
|
591
|
+
|
|
592
|
+
MIT © Thomas Foster
|