inkplot-scratch 0.0.0-stage → 1.0.0-beta.1
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 +60 -2
- package/bin.mjs +312 -0
- package/fonts/Inter-Bold.otf +0 -0
- package/fonts/Inter-Regular.otf +0 -0
- package/fonts/LICENSE.txt +133 -0
- package/package.json +14 -4
- package/skill/SKILL.md +61 -0
- package/skill/inkplot/SKILL.md +309 -0
- package/skill/inkplot/reference/exports.md +104 -0
- package/skill/inkplot/topics/axes.md +125 -0
- package/skill/inkplot/topics/data.md +141 -0
- package/skill/inkplot/topics/furniture.md +137 -0
- package/skill/inkplot/topics/interaction.md +169 -0
- package/skill/inkplot/topics/lines-bars.md +201 -0
- package/skill/inkplot/topics/markers-color.md +274 -0
- package/skill/inkplot/topics/planes.md +139 -0
- package/skill/inkplot/topics/statistics.md +365 -0
- package/skill/inkplot/topics/styling-output.md +75 -0
- package/skill/inkplot/topics/troubleshooting.md +24 -0
- package/src/app.ts +100 -0
- package/src/figures.ts +105 -0
- package/src/interact.ts +39 -0
- package/src/template.html +38 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# inkplot topic: statistics
|
|
2
|
+
|
|
3
|
+
For: histograms, heat maps, density, box plots, trendlines (linear, polynomial, sinusoidal, loess), error bars and bands.
|
|
4
|
+
|
|
5
|
+
Read `SKILL.md` first (rules, template, and the attributes of `Chart`, `CartesianPlane`, `Axis`, `LinearScale`, `DomainSeries`, `FlatDataSource`, `ObjectDataSource`, `LinePath`, `LinearCurve`). This file has the rest of what this topic needs: examples, notes, and the attributes of its elements. Everything here is exported from `'inkplot'`.
|
|
6
|
+
|
|
7
|
+
## Examples
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
A transform sits between the series and its source and replaces the points. Each of these is one structure: source inside transform inside series, then renderers.
|
|
11
|
+
|
|
12
|
+
```ts check title="Histogram"
|
|
13
|
+
import {
|
|
14
|
+
Axis,
|
|
15
|
+
BarPath,
|
|
16
|
+
CartesianPlane,
|
|
17
|
+
Chart,
|
|
18
|
+
Density,
|
|
19
|
+
DomainSeries,
|
|
20
|
+
LinearScale,
|
|
21
|
+
ObjectDataSource,
|
|
22
|
+
lightTheme,
|
|
23
|
+
} from 'inkplot';
|
|
24
|
+
|
|
25
|
+
const rows = Array.from({ length: 300 }, (_, i) => ({ v: 50 + 12 * Math.sin(i * 12.9898) }));
|
|
26
|
+
|
|
27
|
+
export const chart = new Chart(
|
|
28
|
+
{ theme: lightTheme },
|
|
29
|
+
new CartesianPlane(
|
|
30
|
+
{},
|
|
31
|
+
new Axis({ orientation: 'bottom', title: 'Value' }, new LinearScale()),
|
|
32
|
+
new Axis({ orientation: 'left', title: 'Count', include: [0] }, new LinearScale()),
|
|
33
|
+
new DomainSeries(
|
|
34
|
+
{ name: 'Count' },
|
|
35
|
+
new Density({ width: 4 }, new ObjectDataSource({ source: rows, domain: 'v', value: 'v' })),
|
|
36
|
+
new BarPath({ width: 0.95 }),
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
A histogram's value axis is fitted to the bin centres, so the bottom axis starts a little inside the first bin: set `min: 0` on it to start at 0 (or at any value you want). The count axis ends at the tallest bar; add `nice` to it for some headroom.
|
|
43
|
+
|
|
44
|
+
Bars cannot `bind`, so to colour some bins differently (say, those above a threshold) draw two series from filtered rows with the same `Density` `width`, `origin` and `extent`, and a different `fill` on each `BarPath`. JSX spreads (`<Density {...bins}>`) are fine. `BarPath width={0.95}` is the fraction of each bin a bar fills (the rest is the gap); `include: [0]` keeps zero on the count axis (axes otherwise fit the data). `Density` (one dimension, `by` defaults to `['domain']`) outputs one point per bin: domain is the bin centre, value is `count`. `width` is the bin size in data units; `bins: n` fixes the count instead. Empty bins are kept as zeros. The source's `domain` and `value` can name the same column; only `domain` is counted. For each bin's share of the total use `stat: 'fraction'` (0 to 1; the other values are `'count'`, `'sum'`, `'mean'`, `'density'`), and show it as a percent with `<Axis format={(v) => `${Math.round(v * 100)}%`}>`.
|
|
45
|
+
|
|
46
|
+
```ts check title="Heat map from raw points"
|
|
47
|
+
import {
|
|
48
|
+
Axis,
|
|
49
|
+
CartesianPlane,
|
|
50
|
+
Chart,
|
|
51
|
+
ColorAxis,
|
|
52
|
+
ColorKey,
|
|
53
|
+
Density,
|
|
54
|
+
HeatMap,
|
|
55
|
+
LinearScale,
|
|
56
|
+
ObjectDataSource,
|
|
57
|
+
XYSeries,
|
|
58
|
+
lightTheme,
|
|
59
|
+
} from 'inkplot';
|
|
60
|
+
|
|
61
|
+
const pts = Array.from({ length: 2000 }, (_, i) => ({ x: Math.sin(i * 12.9898) * 3, y: Math.cos(i * 78.233) * 2 }));
|
|
62
|
+
|
|
63
|
+
export const chart = new Chart(
|
|
64
|
+
{ theme: lightTheme },
|
|
65
|
+
new CartesianPlane(
|
|
66
|
+
{},
|
|
67
|
+
new Axis({ orientation: 'bottom', title: 'x' }, new LinearScale()),
|
|
68
|
+
new Axis({ orientation: 'left', title: 'y' }, new LinearScale()),
|
|
69
|
+
new XYSeries(
|
|
70
|
+
{ name: 'Points', x: 'domain' },
|
|
71
|
+
new Density(
|
|
72
|
+
{ by: ['domain', 'value'], width: 0.5 },
|
|
73
|
+
new ObjectDataSource({ source: pts, domain: 'x', value: 'y' }),
|
|
74
|
+
),
|
|
75
|
+
new HeatMap({ cellWidth: 0.5, bind: { fill: { axis: 'heat', channel: 'count' } } }),
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
new ColorAxis({ id: 'heat', ramp: 'viridis', title: 'Points' }, new LinearScale()),
|
|
79
|
+
new ColorKey({ dimension: 'heat', side: 'right' }),
|
|
80
|
+
);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Two dimensions: `by: ['domain', 'value']` (domain is x, value is y, or any channel name). The output has `domain` (x of the cell centre), `value` (y), `count`, `sum`, `mean`, `fraction`, `density`, `width` and `height`. `x: 'domain'` is what lets an `XYSeries` use the `Density` output. Colour by `bind: { fill: { axis: 'heat', channel: 'count' } }` (the channel is any output name below: `count`, `sum`, `mean`, `fraction`, `density`); the ramp names (`'viridis'`, `'magma'`, `'turbo'`, `'blues'`, `'greys'`, `'diverging'`) are set on the `ColorAxis` (see `markers-color`). The axes reach the outer edges of the outer cells. `cellWidth` alone draws square cells; set `cellHeight` too for rectangles. They must match the bin `width` (a number for both, or `[w, h]` such as `width: [2, 1]` with `cellWidth: 2, cellHeight: 1`). Cells with no points are left out in two dimensions (`empty: 'omit'` is the default there; one-dimensional bins keep zeros).
|
|
84
|
+
|
|
85
|
+
```ts check title="Box plot from samples, with outliers"
|
|
86
|
+
import {
|
|
87
|
+
Axis,
|
|
88
|
+
BandScale,
|
|
89
|
+
BoxPath,
|
|
90
|
+
BoxStats,
|
|
91
|
+
CartesianPlane,
|
|
92
|
+
Chart,
|
|
93
|
+
DomainSeries,
|
|
94
|
+
DotMarker,
|
|
95
|
+
LinearScale,
|
|
96
|
+
ObjectDataSource,
|
|
97
|
+
lightTheme,
|
|
98
|
+
} from 'inkplot';
|
|
99
|
+
|
|
100
|
+
const samples = Array.from({ length: 120 }, (_, i) => ({
|
|
101
|
+
g: i % 3,
|
|
102
|
+
v: 10 + (i % 3) * 3 + 4 * Math.sin(i * 12.9898) + (i === 7 ? 25 : 0),
|
|
103
|
+
}));
|
|
104
|
+
const source = () => new ObjectDataSource({ source: samples, domain: 'g', value: 'v' });
|
|
105
|
+
|
|
106
|
+
export const chart = new Chart(
|
|
107
|
+
{ theme: lightTheme },
|
|
108
|
+
new CartesianPlane(
|
|
109
|
+
{},
|
|
110
|
+
new Axis({ orientation: 'bottom' }, new BandScale({ categories: ['A', 'B', 'C'] })),
|
|
111
|
+
new Axis({ orientation: 'left', title: 'Measurement' }, new LinearScale()),
|
|
112
|
+
new DomainSeries({ name: 'Spread' }, new BoxStats({}, source()), new BoxPath()),
|
|
113
|
+
new DomainSeries(
|
|
114
|
+
{ name: 'Outliers' },
|
|
115
|
+
new BoxStats({ output: 'outliers' }, source()),
|
|
116
|
+
new DotMarker({ size: 3 }),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
The shared source is a function because one element has one parent (rule 14). `BoxStats` takes raw samples (any order) and groups them by `domain` (0, 1, 2… on a `BandScale`). Its output channels are `min q1 median q3 max mean sd sem n`; the point's value is the median. `BoxPath` reads `min q1 q3 max` by those names, and the median is the point's value. Its attributes `low q1 median q3 high` only matter to rename channels, for example when you already have the numbers: `new ObjectDataSource({ source: rows, domain: 'g', value: 'med', channels: { min: 'lo', q1: 'q1', q3: 'q3', max: 'hi' } })` then `new BoxPath()` with no `BoxStats`.
|
|
124
|
+
|
|
125
|
+
From numbers you already have (no samples), give the median as `value` (it must be the median column) and the other four as channels, then `BoxPath` alone. Always declare `min`, `q1`, `q3` and `max` in `channels` (even when the columns are already called that), or the chart throws `the data has no channel "min"`:
|
|
126
|
+
|
|
127
|
+
```ts check title="Box plot from numbers you already have"
|
|
128
|
+
import {
|
|
129
|
+
Axis,
|
|
130
|
+
BandScale,
|
|
131
|
+
BoxPath,
|
|
132
|
+
CartesianPlane,
|
|
133
|
+
Chart,
|
|
134
|
+
DomainSeries,
|
|
135
|
+
LinearScale,
|
|
136
|
+
ObjectDataSource,
|
|
137
|
+
lightTheme,
|
|
138
|
+
} from 'inkplot';
|
|
139
|
+
|
|
140
|
+
const rows = [
|
|
141
|
+
{ i: 0, min: 12, q1: 20, median: 26, q3: 34, max: 61 },
|
|
142
|
+
{ i: 1, min: 18, q1: 30, median: 41, q3: 55, max: 90 },
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
export const chart = new Chart(
|
|
146
|
+
{ theme: lightTheme },
|
|
147
|
+
new CartesianPlane(
|
|
148
|
+
{},
|
|
149
|
+
new Axis({ orientation: 'bottom' }, new BandScale({ categories: ['API', 'Web'] })),
|
|
150
|
+
new Axis({ orientation: 'left', title: 'Response (ms)' }, new LinearScale()),
|
|
151
|
+
new DomainSeries(
|
|
152
|
+
{ name: 'Latency' },
|
|
153
|
+
new ObjectDataSource({
|
|
154
|
+
source: rows,
|
|
155
|
+
domain: 'i',
|
|
156
|
+
value: 'median',
|
|
157
|
+
channels: { min: 'min', q1: 'q1', q3: 'q3', max: 'max' },
|
|
158
|
+
}),
|
|
159
|
+
new BoxPath(),
|
|
160
|
+
),
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
```ts check title="Scatter with a trendline"
|
|
167
|
+
import {
|
|
168
|
+
Axis,
|
|
169
|
+
CartesianPlane,
|
|
170
|
+
Chart,
|
|
171
|
+
CircleMarker,
|
|
172
|
+
DomainSeries,
|
|
173
|
+
LinearCurve,
|
|
174
|
+
LinearScale,
|
|
175
|
+
LinePath,
|
|
176
|
+
LinearTrend,
|
|
177
|
+
Legend,
|
|
178
|
+
ObjectDataSource,
|
|
179
|
+
XYSeries,
|
|
180
|
+
lightTheme,
|
|
181
|
+
} from 'inkplot';
|
|
182
|
+
|
|
183
|
+
const rows = Array.from({ length: 80 }, (_, i) => ({ x: i / 4, y: 3 + 0.8 * (i / 4) + 2 * Math.sin(i * 12.9898) }));
|
|
184
|
+
|
|
185
|
+
export const chart = new Chart(
|
|
186
|
+
{ theme: lightTheme },
|
|
187
|
+
new CartesianPlane(
|
|
188
|
+
{},
|
|
189
|
+
new Axis({ orientation: 'bottom' }, new LinearScale()),
|
|
190
|
+
new Axis({ orientation: 'left' }, new LinearScale()),
|
|
191
|
+
new XYSeries(
|
|
192
|
+
{ name: 'Data', x: 'x' },
|
|
193
|
+
new ObjectDataSource({ source: rows, domain: 'x', value: 'y', channels: { x: 'x' } }),
|
|
194
|
+
new CircleMarker({ size: 4 }),
|
|
195
|
+
),
|
|
196
|
+
new DomainSeries(
|
|
197
|
+
{ name: 'Trend' },
|
|
198
|
+
new LinearTrend(
|
|
199
|
+
{ extend: 0.05 },
|
|
200
|
+
new ObjectDataSource({ source: rows, domain: 'x', value: 'y', isSorted: true }),
|
|
201
|
+
),
|
|
202
|
+
new LinePath({}, new LinearCurve()),
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
new Legend({ side: 'bottom' }),
|
|
206
|
+
);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
A trendline is its own `DomainSeries` (a line over the same data) beside the scatter; its source needs `domain` and `value` and `isSorted` if sorted. It outputs a smooth line, one point per pixel, with `extend` 0 (the line stops at the data) unless you set it. Choose its colour with `stroke` on the `LinePath`. Other fits: `PolynomialTrend degree`, `SinusoidalTrend period?`, `LoessTrend smoothness` (0 joins the points, 1 is the straight line). `extend: 0.1` runs the line 10% of the span past each end.
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
| To… | Use |
|
|
213
|
+
|---|---|
|
|
214
|
+
| Error bars / a band | A channel of the data (`channels: { sd: 'sd' }`) and `new ErrorBars({ error: 'sd' })` or `new ErrorBand({ error: 'hi', negativeError: 'lo' })` in the series; `mode: 'relative'` for fractions |
|
|
215
|
+
| Chart data as a table | `readTable(node)` (a source, transform, trendline or `series.data`): typed-array columns and `rows()` |
|
|
216
|
+
|
|
217
|
+
## Attributes of this topic's elements
|
|
218
|
+
|
|
219
|
+
An attribute followed by `(required)` must be given. Every element also takes `id`, `meta`, `visible` and `style`.
|
|
220
|
+
|
|
221
|
+
<!-- api:start -->
|
|
222
|
+
### BoxPath
|
|
223
|
+
|
|
224
|
+
Box and whisker from min, q1, median, q3, max channels.
|
|
225
|
+
|
|
226
|
+
**In** a series · **Holds** none
|
|
227
|
+
|
|
228
|
+
| Attribute | Type | |
|
|
229
|
+
|---|---|---|
|
|
230
|
+
| `low` | `string` | The channel of the lower whisker's end. Default 'min'. |
|
|
231
|
+
| `q1` | `string` | The channel of the first quartile (the foot of the box). Default 'q1'. |
|
|
232
|
+
| `median` | `string` | The channel of the median. Default: the series' own value, which is what a data source's `value` names. |
|
|
233
|
+
| `q3` | `string` | The channel of the third quartile (the top of the box). Default 'q3'. |
|
|
234
|
+
| `high` | `string` | The channel of the upper whisker's end. Default 'max'. |
|
|
235
|
+
| `capWidth` | `number` | Width of the cap on each whisker, as a fraction of the box's width; 0 for none. Default 0.5. |
|
|
236
|
+
|
|
237
|
+
### BoxStats
|
|
238
|
+
|
|
239
|
+
Samples in; per group min q1 median q3 max mean sd sem n out (or the outliers).
|
|
240
|
+
|
|
241
|
+
**In** a series · **Holds** 1 data
|
|
242
|
+
|
|
243
|
+
| Attribute | Type | |
|
|
244
|
+
|---|---|---|
|
|
245
|
+
| `output` | `'summary' \| 'outliers'` | 'summary' (the default): a point for each group. 'outliers': a point for each sample beyond the whiskers. |
|
|
246
|
+
| `fence` | `number \| 'minmax'` | How far the whiskers may reach past the box, in interquartile ranges (default 1.5), or 'minmax' for the whole range. |
|
|
247
|
+
| `method` | `'inclusive' \| 'exclusive'` | How a quartile is found between samples. Default 'inclusive'. |
|
|
248
|
+
| `value` | `'median' \| 'mean'` | Which statistic is the point's value: 'median' (the default; what a box is drawn round) or 'mean' (what error bars from `sd` or `sem` are drawn round). |
|
|
249
|
+
|
|
250
|
+
### Density
|
|
251
|
+
|
|
252
|
+
Bins points over any number of dimensions: count, sum, mean, fraction, density per bin (a histogram with BarPath).
|
|
253
|
+
|
|
254
|
+
**In** a series · **Holds** 1 data
|
|
255
|
+
|
|
256
|
+
| Attribute | Type | |
|
|
257
|
+
|---|---|---|
|
|
258
|
+
| `by` | `readonly string[]` | The dimensions to bin: 'domain', 'value' or channel names. Default ['domain']. |
|
|
259
|
+
| `weight` | `string` | What each point counts for: a channel name or 'value'. Default: 1 for each point. |
|
|
260
|
+
| `stat` | `Stat` | The value of a one-dimensional output: 'count', 'sum', 'mean', 'fraction' (the share of the total, 0 to 1) or 'density'. Default 'count', or 'sum' with a `weight`. |
|
|
261
|
+
| `width` | `number \| readonly number[]` | Bin size in each dimension's units: one number for all, or one for each dimension. |
|
|
262
|
+
| `bins` | `number \| readonly number[]` | Number of bins in each dimension, spread evenly over its extent (by default the data's own range). |
|
|
263
|
+
| `origin` | `number \| readonly number[]` | Where a bin edge falls when `width` is used, in each dimension: edges are at `origin` plus whole widths. Default 0. |
|
|
264
|
+
| `extent` | `Extent \| readonly (Extent \| null)[]` | The span to bin over in each dimension, as `{ min, max }` (one for all, or an array with one for each); points outside are left out. Default: the data's extent. |
|
|
265
|
+
| `maxBins` | `number` | Most bins along a dimension when the size is chosen by the rule. Default 200. |
|
|
266
|
+
| `empty` | `'keep' \| 'omit'` | 'keep' bins with no points as zeros, 'omit' leaves them out. Default 'keep' for one dimension, 'omit' for more. |
|
|
267
|
+
|
|
268
|
+
### HeatMap
|
|
269
|
+
|
|
270
|
+
A rectangle at each point, coloured by a channel; with `Density`, a heat map.
|
|
271
|
+
|
|
272
|
+
**In** a series · **Holds** none
|
|
273
|
+
|
|
274
|
+
| Attribute | Type | |
|
|
275
|
+
|---|---|---|
|
|
276
|
+
| `cellWidth` | `number` | The size of a cell along the first axis, in data units. Default: the `widthChannel`. |
|
|
277
|
+
| `cellHeight` | `number` | The size along the second axis. Default: the `heightChannel`, or `cellWidth` when only that is given (square cells). |
|
|
278
|
+
| `widthChannel` | `string` | The channel with each cell's width. Default 'width'. |
|
|
279
|
+
| `heightChannel` | `string` | The channel with each cell's height. Default 'height'. |
|
|
280
|
+
| `stroke` | `string` | Outline colour (a CSS colour). Default: from the theme. |
|
|
281
|
+
| `strokeWidth` | `number` | Outline width in pixels. Default: from the theme. |
|
|
282
|
+
| `fill` | `string` | Fill colour (a CSS colour; 'none' for no fill). Default: from the theme. |
|
|
283
|
+
| `opacity` | `number` | Opacity from 0 to 1. Default 1. |
|
|
284
|
+
| `bind` | `Bindings<Bindable>` | Attributes fed from data instead of fixed. |
|
|
285
|
+
| `maxNodes` | `number` | Most paths this renderer may create. Default 1000. |
|
|
286
|
+
|
|
287
|
+
### LinearTrend
|
|
288
|
+
|
|
289
|
+
The least-squares line through its source, sampled across the domain.
|
|
290
|
+
|
|
291
|
+
**In** a series · **Holds** 1 data
|
|
292
|
+
|
|
293
|
+
| Attribute | Type | |
|
|
294
|
+
|---|---|---|
|
|
295
|
+
| `x` | `string` | A channel of the source to fit against instead of its domain. |
|
|
296
|
+
| `extend` | `number \| readonly [number, number]` | How far past the data the line goes at each end, as a fraction of the data's span: one number, or `[before, after]`. Default 0. |
|
|
297
|
+
| `count` | `number` | Samples, evenly spaced over the whole span, always. |
|
|
298
|
+
|
|
299
|
+
### PolynomialTrend
|
|
300
|
+
|
|
301
|
+
A polynomial fitted to its source (`degree`).
|
|
302
|
+
|
|
303
|
+
**In** a series · **Holds** 1 data
|
|
304
|
+
|
|
305
|
+
| Attribute | Type | |
|
|
306
|
+
|---|---|---|
|
|
307
|
+
| `degree` | `number` | The highest power, 1 to 12. Default 2. |
|
|
308
|
+
| `x` | `string` | A channel of the source to fit against instead of its domain. |
|
|
309
|
+
| `extend` | `number \| readonly [number, number]` | How far past the data the line goes at each end, as a fraction of the data's span: one number, or `[before, after]`. Default 0. |
|
|
310
|
+
| `count` | `number` | Samples, evenly spaced over the whole span, always. |
|
|
311
|
+
|
|
312
|
+
### SinusoidalTrend
|
|
313
|
+
|
|
314
|
+
A constant plus a sine wave fitted to its source, of a given `period` or the best one.
|
|
315
|
+
|
|
316
|
+
**In** a series · **Holds** 1 data
|
|
317
|
+
|
|
318
|
+
| Attribute | Type | |
|
|
319
|
+
|---|---|---|
|
|
320
|
+
| `period` | `number` | The period in the domain's units. |
|
|
321
|
+
| `x` | `string` | A channel of the source to fit against instead of its domain. |
|
|
322
|
+
| `extend` | `number \| readonly [number, number]` | How far past the data the line goes at each end, as a fraction of the data's span: one number, or `[before, after]`. Default 0. |
|
|
323
|
+
| `count` | `number` | Samples, evenly spaced over the whole span, always. |
|
|
324
|
+
|
|
325
|
+
### LoessTrend
|
|
326
|
+
|
|
327
|
+
Local linear regression: `smoothness` 0 joins the points, 1 is the line.
|
|
328
|
+
|
|
329
|
+
**In** a series · **Holds** 1 data
|
|
330
|
+
|
|
331
|
+
| Attribute | Type | |
|
|
332
|
+
|---|---|---|
|
|
333
|
+
| `smoothness` | `number` | 0 to 1: 0 joins the points, 1 is the least-squares line, and between them the line is smoother the larger it is. Default 0.5. |
|
|
334
|
+
| `kernel` | `'tricube' \| 'gaussian'` | How a point's weight falls with its distance. Default 'tricube'. |
|
|
335
|
+
| `x` | `string` | A channel of the source to fit against instead of its domain. |
|
|
336
|
+
| `extend` | `number \| readonly [number, number]` | How far past the data the line goes at each end, as a fraction of the data's span: one number, or `[before, after]`. Default 0. |
|
|
337
|
+
| `count` | `number` | Samples, evenly spaced over the whole span, always. |
|
|
338
|
+
|
|
339
|
+
### ErrorBars
|
|
340
|
+
|
|
341
|
+
Whiskers with caps from an error channel (error, optional negativeError; absolute or relative).
|
|
342
|
+
|
|
343
|
+
**In** a series · **Holds** none
|
|
344
|
+
|
|
345
|
+
| Attribute | Type | |
|
|
346
|
+
|---|---|---|
|
|
347
|
+
| `capWidth` | `number` | Width in pixels of the cap at each end of a whisker; 0 for none. Default 8. |
|
|
348
|
+
| `error` (required) | `string` | The channel with how far above the value each point may be. |
|
|
349
|
+
| `negativeError` | `string` | The channel with how far below. Default: the same as `error`. |
|
|
350
|
+
| `mode` | `'absolute' \| 'relative'` | What the numbers mean: units of the value (default), or a fraction of it. |
|
|
351
|
+
| `along` | `'value' \| 'domain'` | Which way the error runs. Default 'value'. |
|
|
352
|
+
|
|
353
|
+
### ErrorBand
|
|
354
|
+
|
|
355
|
+
The region between value minus and plus an error channel.
|
|
356
|
+
|
|
357
|
+
**In** a series · **Holds** none
|
|
358
|
+
|
|
359
|
+
| Attribute | Type | |
|
|
360
|
+
|---|---|---|
|
|
361
|
+
| `error` (required) | `string` | The channel with how far above the value each point may be. |
|
|
362
|
+
| `negativeError` | `string` | The channel with how far below. Default: the same as `error`. |
|
|
363
|
+
| `mode` | `'absolute' \| 'relative'` | What the numbers mean: units of the value (default), or a fraction of it. |
|
|
364
|
+
| `along` | `'value' \| 'domain'` | Which way the error runs. Default 'value'. |
|
|
365
|
+
<!-- api:end -->
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# inkplot topic: themes, styling, React, export
|
|
2
|
+
|
|
3
|
+
For: dark and custom themes, styling parts, React, a PNG, chart as JSON, your own element types.
|
|
4
|
+
|
|
5
|
+
Read `SKILL.md` first (rules, template, and the attributes of `Chart`, `CartesianPlane`, `Axis`, `LinearScale`, `DomainSeries`, `FlatDataSource`, `ObjectDataSource`, `LinePath`, `LinearCurve`). This file has the rest of what this topic needs: examples, notes, and the attributes of its elements. Everything here is exported from `'inkplot'`.
|
|
6
|
+
|
|
7
|
+
## Dark theme, and styling
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
```ts check title="Theme and style" dark
|
|
11
|
+
import {
|
|
12
|
+
Axis,
|
|
13
|
+
CartesianPlane,
|
|
14
|
+
Chart,
|
|
15
|
+
DomainSeries,
|
|
16
|
+
FlatDataSource,
|
|
17
|
+
LinePath,
|
|
18
|
+
LinearCurve,
|
|
19
|
+
LinearScale,
|
|
20
|
+
darkTheme,
|
|
21
|
+
extendTheme,
|
|
22
|
+
} from 'inkplot';
|
|
23
|
+
|
|
24
|
+
const brand = extendTheme(darkTheme, { palette: ['#ff6b9d', '#6bcbff'], text: { family: 'Georgia, serif' } });
|
|
25
|
+
|
|
26
|
+
export const chart = new Chart(
|
|
27
|
+
{ theme: brand, style: { gridline: { dash: '2 3' } } },
|
|
28
|
+
new CartesianPlane(
|
|
29
|
+
{},
|
|
30
|
+
new Axis({ orientation: 'bottom', style: { tickLabel: { size: 13 } } }, new LinearScale()),
|
|
31
|
+
new Axis({ orientation: 'left' }, new LinearScale()),
|
|
32
|
+
new DomainSeries(
|
|
33
|
+
{ name: 'A' },
|
|
34
|
+
new FlatDataSource({ data: [3, 5, 4, 8], regularDomain: { start: 0, step: 1 } }),
|
|
35
|
+
new LinePath({ strokeWidth: 3 }, new LinearCurve()),
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## In React
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
Import the components from `inkplot/react`: one per built-in class, same names. For your own class, `reactWrapper(MyClass)`. The root renders a `<div>`; give it a height. Functions in props should be memoized.
|
|
45
|
+
|
|
46
|
+
```tsx check title="React" jsx=react
|
|
47
|
+
import { lightTheme } from 'inkplot';
|
|
48
|
+
import { Axis, CartesianPlane, Chart, DomainSeries, FlatDataSource, LinePath, LinearCurve, LinearScale } from 'inkplot/react';
|
|
49
|
+
|
|
50
|
+
export function Trend({ values }: { values: number[] }) {
|
|
51
|
+
return (
|
|
52
|
+
<Chart theme={lightTheme} hostProps={{ style: { height: 300 } }}>
|
|
53
|
+
<CartesianPlane>
|
|
54
|
+
<Axis orientation="bottom"><LinearScale /></Axis>
|
|
55
|
+
<Axis orientation="left"><LinearScale /></Axis>
|
|
56
|
+
<DomainSeries name="Trend">
|
|
57
|
+
<FlatDataSource data={values} regularDomain={{ start: 0, step: 1 }} />
|
|
58
|
+
<LinePath><LinearCurve /></LinePath>
|
|
59
|
+
</DomainSeries>
|
|
60
|
+
</CartesianPlane>
|
|
61
|
+
</Chart>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Notes
|
|
67
|
+
|
|
68
|
+
| To… | Use |
|
|
69
|
+
|---|---|
|
|
70
|
+
| A PNG | Browser: `await toPng(chart, { scale: 2, background: '#fff' })` (a `Blob`). Server: `toSvgString` then a WASM renderer with a font (`@resvg/resvg-wasm`; recipe "Make a PNG on a server") |
|
|
71
|
+
| Chart as JSON | `toJSON(chart, registry, { refs })`, `fromJSON(json, registry, { refs })` |
|
|
72
|
+
| Custom element that listens to the pointer, keys or focus | declare `static takesInput = inputOf` (from `inkplot`) on the class, or the chart never routes input to it |
|
|
73
|
+
| Show gridlines or a frame | `new CartesianPlane({ gridlines: false, frame: true }, …)` |
|
|
74
|
+
| Accessible name | `meta: { title }`, `meta: { description }` on the `Chart`; `new ChartSummary()` for an automatic one |
|
|
75
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# inkplot topic: troubleshooting
|
|
2
|
+
|
|
3
|
+
For: an error message, or a chart that draws nothing or draws wrongly. Find the symptom, apply the fix.
|
|
4
|
+
|
|
5
|
+
| Symptom | Cause and fix |
|
|
6
|
+
|---|---|
|
|
7
|
+
| Error `X: child 0 should be Scale, but is …` | Children in the wrong order. `Axis`: scale. `DomainSeries`: data, then renderers. |
|
|
8
|
+
| Error `X: has no child 0 (it needs …)` | A required child is missing: scale on an `Axis`, data on a series, curve on a `LinePath`. |
|
|
9
|
+
| Error `a series needs one horizontal and one vertical axis` | Missing or mismatched axes. `bottom` + `left` for `CartesianPlane`; `angle` + `radius` for polar and radar. |
|
|
10
|
+
| Error `the data has no channel "x"` | Add it: `channels: { x: 'x' }` on an `ObjectDataSource`. |
|
|
11
|
+
| Error `no element with id "x"` | The `id` is missing or misspelt on the axis or series that is referred to. |
|
|
12
|
+
| Error `LinePath bindings is not implemented yet` | Only markers can `bind`. |
|
|
13
|
+
| Error `add a PointerListener before PanZoom` / `add a BoxZoom before PanZoom` / `add a Brush before PanZoom` | Put the `PointerListener`, `BoxZoom` or `Brush` first. |
|
|
14
|
+
| Error `a Decimator cannot carry channels` | No bound channels through a `Decimator`. |
|
|
15
|
+
| Nothing is drawn (browser) | The container has no height; the chart was not attached (`chart.attach(new DOMPlatform(el))`); no `theme`. |
|
|
16
|
+
| Nothing is drawn (`toSvgString`) | No renderer on the series; empty data; `visible: false`. |
|
|
17
|
+
| Axis starts somewhere odd | Axes fit the data and tidy the ends: set `min`/`max`, or `include: [0]`. |
|
|
18
|
+
| A line jumps backwards | `isSorted` set on unsorted data: remove it. |
|
|
19
|
+
| Series missing from legend or tooltip | It has no `name`. |
|
|
20
|
+
| Tooltip or crosshair missing | `Cursor` must be a child of the **chart**, holding `ChartTooltip`/`Crosshair`. |
|
|
21
|
+
| Dates wrong or in 1970 | Values must be milliseconds since 1970. Time zone defaults to UTC: set `timeZone`. |
|
|
22
|
+
| Chart does not update | You mutated an object: set a new one with `setAttribute`. For an in-place array edit call `source.invalidate()`. |
|
|
23
|
+
| A colour is only roughly right | Bound colours are stepped; `merge: false` on the marker for small data. |
|
|
24
|
+
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page the `--html` output opens: every figure of a module, live, with the usual controls. Bundled with the
|
|
3
|
+
* module itself (see cli.ts), so one file holds the app, the charts and their data.
|
|
4
|
+
*/
|
|
5
|
+
import { DOMPlatform, svgToPng } from 'inkplot';
|
|
6
|
+
import { hintFor, makeInteractive } from './interact.ts';
|
|
7
|
+
import { type Facts, type Figure, checkText, factsOf, figuresOf, svgOf } from './figures.ts';
|
|
8
|
+
|
|
9
|
+
const h = <K extends keyof HTMLElementTagNameMap>(tag: K, props: Partial<HTMLElementTagNameMap[K]> & { class?: string } = {}, ...kids: (Node | string)[]) => {
|
|
10
|
+
const { class: cls, ...rest } = props;
|
|
11
|
+
const el = Object.assign(document.createElement(tag), rest);
|
|
12
|
+
if (cls) el.className = cls;
|
|
13
|
+
el.append(...kids);
|
|
14
|
+
return el;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** An element the page template is known to have. */
|
|
18
|
+
const byId = (id: string): HTMLElement => document.getElementById(id) ?? (() => { throw new Error(`the page has no #${id}`); })();
|
|
19
|
+
|
|
20
|
+
const download = (blob: Blob, name: string) => {
|
|
21
|
+
const url = URL.createObjectURL(blob);
|
|
22
|
+
const a = h('a', { href: url, download: name });
|
|
23
|
+
document.body.append(a);
|
|
24
|
+
a.click();
|
|
25
|
+
a.remove();
|
|
26
|
+
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function mount(module: Record<string, unknown>, title: string, options: { interactive?: boolean } = {}): void {
|
|
30
|
+
document.title = title;
|
|
31
|
+
const root = byId('figures'), note = byId('note');
|
|
32
|
+
byId('title').textContent = title;
|
|
33
|
+
let toastTimer = 0;
|
|
34
|
+
const say = (text: string) => {
|
|
35
|
+
note.textContent = text;
|
|
36
|
+
note.classList.add('on');
|
|
37
|
+
clearTimeout(toastTimer);
|
|
38
|
+
toastTimer = window.setTimeout(() => note.classList.remove('on'), 2500);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
let figures: Figure[];
|
|
42
|
+
try { figures = figuresOf(module); } catch (e) { root.append(h('pre', { class: 'error' }, e instanceof Error ? e.message : String(e))); return; }
|
|
43
|
+
|
|
44
|
+
const svgs = new Map<string, string>(), pngs = new Map<string, Promise<Blob>>(), allFacts: Facts[] = [];
|
|
45
|
+
/** The figure's exported SVG, written when its section is built, before anything can ask for it. */
|
|
46
|
+
const svgText = (f: Figure) => svgs.get(f.id) ?? '';
|
|
47
|
+
const png = (f: Figure, scale = 2) => {
|
|
48
|
+
const key = `${f.id}@${scale}`;
|
|
49
|
+
let made = pngs.get(key);
|
|
50
|
+
if (!made) pngs.set(key, made = svgToPng(svgText(f), { scale, background: '#ffffff' }));
|
|
51
|
+
return made;
|
|
52
|
+
};
|
|
53
|
+
// The clipboard wants a user gesture and, for images, a secure page; a file:// page often has neither, so every copy falls back to a download and says so.
|
|
54
|
+
const copy = async (what: 'SVG' | 'PNG', f: Figure) => {
|
|
55
|
+
try {
|
|
56
|
+
if (what === 'SVG') await navigator.clipboard.writeText(svgText(f));
|
|
57
|
+
else await navigator.clipboard.write([new ClipboardItem({ 'image/png': png(f) })]);
|
|
58
|
+
say(`Copied ${f.title} as ${what}`);
|
|
59
|
+
} catch {
|
|
60
|
+
if (what === 'SVG') download(new Blob([svgText(f)], { type: 'image/svg+xml' }), `${f.id}.svg`);
|
|
61
|
+
else download(await png(f), `${f.id}.png`);
|
|
62
|
+
say(`The browser blocked the clipboard, so ${f.title} was downloaded as ${what} instead`);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
for (const f of figures) {
|
|
67
|
+
// The export is rendered before the chart goes on the page, at the chart's own size: what is copied is the chart as written, not as zoomed.
|
|
68
|
+
svgs.set(f.id, svgOf(f));
|
|
69
|
+
const facts = factsOf(f);
|
|
70
|
+
allFacts.push(facts);
|
|
71
|
+
// After the export render above, so the file and the copy never carry them.
|
|
72
|
+
let added: string[] = [];
|
|
73
|
+
if (options.interactive !== false && f.interactive) try { added = makeInteractive(f.chart); } catch { added = []; }
|
|
74
|
+
const host = h('div', { class: 'plot' });
|
|
75
|
+
// A chart that sets its own size is drawn at exactly that size, so its box is that size (a bigger box would leave blank margin);
|
|
76
|
+
// one that does not fills the page up to the default width.
|
|
77
|
+
host.style.aspectRatio = `${facts.width} / ${facts.height}`;
|
|
78
|
+
if (f.chart.attrs.width !== undefined || f.chart.attrs.height !== undefined) host.style.width = `${facts.width}px`;
|
|
79
|
+
host.style.maxWidth = `${f.chart.attrs.width !== undefined ? facts.width : Math.max(facts.width, 640)}px`;
|
|
80
|
+
const stats = h('pre', { class: 'stats' }, checkText(facts).split('\n').slice(1).join('\n').replace(/^ {2}/gm, ''));
|
|
81
|
+
stats.hidden = true;
|
|
82
|
+
const button = (label: string, run: () => unknown, hint: string) => h('button', { type: 'button', textContent: label, title: hint, onclick: () => void run() });
|
|
83
|
+
const section = h('section', { class: 'figure', id: f.id },
|
|
84
|
+
h('header', {}, h('h2', {}, f.title), h('div', { class: 'buttons' },
|
|
85
|
+
button('Copy SVG', () => copy('SVG', f), 'Copy the chart as SVG markup'),
|
|
86
|
+
button('Copy PNG', () => copy('PNG', f), 'Copy the chart as a 2x PNG image'),
|
|
87
|
+
button('SVG ↓', () => download(new Blob([svgText(f)], { type: 'image/svg+xml' }), `${f.id}.svg`), 'Download as an SVG file'),
|
|
88
|
+
button('PNG ↓', async () => download(await png(f), `${f.id}.png`), 'Download as a 2x PNG file'),
|
|
89
|
+
...(added.length ? [button('Reset view', () => f.chart.resetViews(), 'Back to the whole chart')] : []),
|
|
90
|
+
button('Stats', () => { stats.hidden = !stats.hidden; }, 'Show what the chart holds: series, point counts, axis ranges'))),
|
|
91
|
+
...(f.caption ? [h('p', { class: 'caption' }, f.caption)] : []),
|
|
92
|
+
...(hintFor(added) ? [h('p', { class: 'hint' }, hintFor(added))] : []), host, stats,
|
|
93
|
+
...facts.problems.map((p) => h('p', { class: 'warning' }, `⚠ ${p}`)));
|
|
94
|
+
section.dataset.interactive = added.join(' ');
|
|
95
|
+
root.append(section);
|
|
96
|
+
try { f.chart.attach(new DOMPlatform(host)); } catch (e) { host.replaceWith(h('pre', { class: 'error' }, `This chart could not be drawn live: ${e instanceof Error ? e.message : String(e)}\nThe copy and download buttons still work.`)); }
|
|
97
|
+
}
|
|
98
|
+
byId('all-svg').onclick = () => figures.forEach((f, i) => setTimeout(() => download(new Blob([svgText(f)], { type: 'image/svg+xml' }), `${f.id}.svg`), i * 150));
|
|
99
|
+
byId('all-png').onclick = () => figures.forEach((f, i) => setTimeout(async () => download(await png(f), `${f.id}.png`), i * 150));
|
|
100
|
+
}
|