inkplot 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 +46 -2
- package/dist/adapt.d.ts +10 -0
- package/dist/adapt.js +34 -0
- package/dist/annotation.d.ts +120 -0
- package/dist/annotation.js +267 -0
- package/dist/aria.d.ts +24 -0
- package/dist/aria.js +31 -0
- package/dist/attrs.d.ts +81 -0
- package/dist/attrs.js +84 -0
- package/dist/axis.d.ts +137 -0
- package/dist/axis.js +185 -0
- package/dist/bars.d.ts +80 -0
- package/dist/bars.js +255 -0
- package/dist/boxplot.d.ts +57 -0
- package/dist/boxplot.js +177 -0
- package/dist/boxzoom.d.ts +39 -0
- package/dist/boxzoom.js +138 -0
- package/dist/brush.d.ts +60 -0
- package/dist/brush.js +185 -0
- package/dist/bucket.d.ts +22 -0
- package/dist/bucket.js +62 -0
- package/dist/callout.d.ts +30 -0
- package/dist/callout.js +98 -0
- package/dist/chart.d.ts +126 -0
- package/dist/chart.js +257 -0
- package/dist/crosshair.d.ts +28 -0
- package/dist/crosshair.js +78 -0
- package/dist/cursor.d.ts +76 -0
- package/dist/cursor.js +344 -0
- package/dist/curve.d.ts +50 -0
- package/dist/curve.js +87 -0
- package/dist/curves.d.ts +104 -0
- package/dist/curves.js +375 -0
- package/dist/data.d.ts +209 -0
- package/dist/data.js +276 -0
- package/dist/decimation.d.ts +86 -0
- package/dist/decimation.js +410 -0
- package/dist/density.d.ts +55 -0
- package/dist/density.js +252 -0
- package/dist/dimension.d.ts +103 -0
- package/dist/dimension.js +205 -0
- package/dist/dom-platform.d.ts +53 -0
- package/dist/dom-platform.js +190 -0
- package/dist/element.d.ts +232 -0
- package/dist/element.js +414 -0
- package/dist/errors.d.ts +85 -0
- package/dist/errors.js +237 -0
- package/dist/events.d.ts +144 -0
- package/dist/events.js +19 -0
- package/dist/focus.d.ts +22 -0
- package/dist/focus.js +51 -0
- package/dist/furniture.d.ts +70 -0
- package/dist/furniture.js +166 -0
- package/dist/geometry.d.ts +40 -0
- package/dist/geometry.js +7 -0
- package/dist/heatmap.d.ts +57 -0
- package/dist/heatmap.js +207 -0
- package/dist/hit.d.ts +103 -0
- package/dist/hit.js +428 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.js +73 -0
- package/dist/input.d.ts +42 -0
- package/dist/input.js +166 -0
- package/dist/jsx-dev-runtime.d.ts +9 -0
- package/dist/jsx-dev-runtime.js +13 -0
- package/dist/jsx-runtime.d.ts +59 -0
- package/dist/jsx-runtime.js +81 -0
- package/dist/key.d.ts +118 -0
- package/dist/key.js +199 -0
- package/dist/legend.d.ts +36 -0
- package/dist/legend.js +220 -0
- package/dist/mapped.d.ts +80 -0
- package/dist/mapped.js +106 -0
- package/dist/marker.d.ts +143 -0
- package/dist/marker.js +213 -0
- package/dist/panzoom.d.ts +119 -0
- package/dist/panzoom.js +467 -0
- package/dist/plane.d.ts +173 -0
- package/dist/plane.js +370 -0
- package/dist/platform.d.ts +113 -0
- package/dist/platform.js +80 -0
- package/dist/png.d.ts +44 -0
- package/dist/png.js +71 -0
- package/dist/pointer.d.ts +54 -0
- package/dist/pointer.js +132 -0
- package/dist/quantile.d.ts +3 -0
- package/dist/quantile.js +11 -0
- package/dist/radial.d.ts +116 -0
- package/dist/radial.js +336 -0
- package/dist/react.d.ts +441 -0
- package/dist/react.js +252 -0
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +51 -0
- package/dist/renderer.d.ts +191 -0
- package/dist/renderer.js +164 -0
- package/dist/scale.d.ts +37 -0
- package/dist/scale.js +19 -0
- package/dist/scales.d.ts +69 -0
- package/dist/scales.js +134 -0
- package/dist/serialize.d.ts +77 -0
- package/dist/serialize.js +321 -0
- package/dist/series.d.ts +304 -0
- package/dist/series.js +513 -0
- package/dist/simple.d.ts +35 -0
- package/dist/simple.js +20 -0
- package/dist/sources.d.ts +116 -0
- package/dist/sources.js +237 -0
- package/dist/stack.d.ts +61 -0
- package/dist/stack.js +172 -0
- package/dist/stats.d.ts +38 -0
- package/dist/stats.js +159 -0
- package/dist/steps.d.ts +50 -0
- package/dist/steps.js +154 -0
- package/dist/stub.d.ts +3 -0
- package/dist/stub.js +5 -0
- package/dist/style-names.d.ts +1 -0
- package/dist/style-names.js +6 -0
- package/dist/summary.d.ts +44 -0
- package/dist/summary.js +58 -0
- package/dist/surface.d.ts +94 -0
- package/dist/surface.js +145 -0
- package/dist/table.d.ts +39 -0
- package/dist/table.js +37 -0
- package/dist/text.d.ts +23 -0
- package/dist/text.js +44 -0
- package/dist/theme.d.ts +174 -0
- package/dist/theme.js +73 -0
- package/dist/themes.d.ts +9 -0
- package/dist/themes.js +56 -0
- package/dist/time-intl.d.ts +194 -0
- package/dist/time-intl.js +144 -0
- package/dist/time-temporal.d.ts +236 -0
- package/dist/time-temporal.js +58 -0
- package/dist/time.d.ts +216 -0
- package/dist/time.js +154 -0
- package/dist/trend.d.ts +151 -0
- package/dist/trend.js +500 -0
- package/dist/web-components.d.ts +62 -0
- package/dist/web-components.js +264 -0
- package/package.json +53 -3
package/dist/plane.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planes: a coordinate space that owns axes and series, lays them out and routes pointer input.
|
|
3
|
+
*
|
|
4
|
+
* How a plane, its axes and its series cooperate (the part that is hardest to see from any one file)
|
|
5
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
* Sizes depend on ranges (wider tick labels need more margin) and ranges depend on sizes (decimation reads as many
|
|
7
|
+
* points as there are pixels). The plane breaks the circle by working in passes, in `arrange`:
|
|
8
|
+
* 1. Estimate the plot size (the available rectangle less a typical margin) and fit with it: `Plane.fit` has the
|
|
9
|
+
* series declare their domain, load their data, and contribute value extents, then settles every axis' range.
|
|
10
|
+
* 2. With ranges known, the axes are measured (their tick labels give thickness) and the real plot rectangle is the
|
|
11
|
+
* available area less those, with `insets` overriding.
|
|
12
|
+
* 3. With the real size known, the axes get their final `Ruler`s and each series is `project`ed: data → fractions
|
|
13
|
+
* through the axes → pixels through the plane's `place`.
|
|
14
|
+
* The plane alone knows what the two axes *mean* spatially (`place`, and rulers), the axes alone know values ↔ fractions,
|
|
15
|
+
* and the series alone know data. That separation is why a radar chart only needs a new plane and the same series.
|
|
16
|
+
*
|
|
17
|
+
* A `Ruler` is the one thing an axis draws against: a line in plot pixels that maps a fraction 0..1 to a position and
|
|
18
|
+
* a normal. Straight edges, floating axes and radial spokes are all just different rulers, so Axis has no per-shape code.
|
|
19
|
+
*/
|
|
20
|
+
/* oxlint-disable typescript/no-non-null-assertion -- stride-addressed rows of the placed-point buffers: every index is bounded by the row count */
|
|
21
|
+
import { Axis, cartesianSide, isHorizontal } from './axis.js';
|
|
22
|
+
import { ChartElement, fail, resolveRef } from './element.js';
|
|
23
|
+
import { writeAria } from './aria.js';
|
|
24
|
+
import { EVENT_ARRANGE, EVENT_ATTACH, EVENT_PAINT } from './events.js';
|
|
25
|
+
import { f2 } from './geometry.js';
|
|
26
|
+
import { Series, groupOf, seriesOf } from './series.js';
|
|
27
|
+
/** What every plane does the same way: owns the axes and series among its children and runs the fit. */
|
|
28
|
+
export class Plane extends ChartElement {
|
|
29
|
+
#rect = { x: 0, y: 0, width: 1, height: 1 };
|
|
30
|
+
/** The allocated plot area, in the chart's coordinates. Valid after arrange. */
|
|
31
|
+
get rect() { return this.#rect; }
|
|
32
|
+
/** Subclasses record the plot area they settle on. */
|
|
33
|
+
setRect(rect) { this.#rect = rect; }
|
|
34
|
+
get axes() { return [...this].filter((c) => c instanceof Axis); }
|
|
35
|
+
/** Every series on the plane, in drawing order, those inside a `Stack` included, whether shown or not: the order that gives each its palette colour. */
|
|
36
|
+
get allSeries() { return [...this].flatMap(seriesOf); }
|
|
37
|
+
/** The series that are on show (`visible` is not false): the ones that are drawn, fit the axes and are hit. */
|
|
38
|
+
get series() { return this.allSeries.filter((s) => s.shown); }
|
|
39
|
+
/**
|
|
40
|
+
* Fractions of `axis` at which a line running along it changes direction, ascending, for planes that are not smooth
|
|
41
|
+
* (a radar's spokes). A line traced through these is exact where sampling evenly would cut the corners. Default none.
|
|
42
|
+
*/
|
|
43
|
+
breaks(_axis) { return []; }
|
|
44
|
+
/**
|
|
45
|
+
* About how long `axis` will be drawn, in pixels, given the room estimated for the plot, so that a series' reach in
|
|
46
|
+
* pixels can be turned into room in the axis' range. 0 where that has no meaning (the default, and any axis round a circle).
|
|
47
|
+
*/
|
|
48
|
+
axisLength(_axis, _estimate) { return 0; }
|
|
49
|
+
/** Put every axis of the plane back to the range its data asks for, undoing pan, zoom and box zoom. */
|
|
50
|
+
resetViews() { for (const axis of this.axes)
|
|
51
|
+
axis.setViewRange(null); }
|
|
52
|
+
/** Whether `axis` comes back round to where it started, so that its two ends are the same place (the angle of a full turn). Default no. */
|
|
53
|
+
cyclic(_axis) { return false; }
|
|
54
|
+
/**
|
|
55
|
+
* The axis whose strip beside the plot a position (in plot pixels) is on, or null: the strip is where its line, ticks and labels
|
|
56
|
+
* are. This is what lets a wheel turn or a drag over an axis act on that axis alone. Default: none, so only the plot itself takes input.
|
|
57
|
+
*/
|
|
58
|
+
axisAt(_x, _y) { return null; }
|
|
59
|
+
/** Whether a position in plot pixels is on the plot: inside the rectangle, and for a circular plot inside the circle. Default: inside the rectangle. */
|
|
60
|
+
covers(x, y) { return x >= 0 && y >= 0 && x <= this.#rect.width && y <= this.#rect.height; }
|
|
61
|
+
/**
|
|
62
|
+
* The line along which the first (or second) coordinate has the data value `value`, across the whole plot, as path data in
|
|
63
|
+
* plot pixels: a vertical line for a horizontal first axis, a spoke for an angle, a circle for a radius. It is what a
|
|
64
|
+
* crosshair draws, and is as the plane draws its own gridlines. Empty when the plane has no such axis.
|
|
65
|
+
*/
|
|
66
|
+
guide(role, value) {
|
|
67
|
+
const a = this.defaultAxis('first'), b = this.defaultAxis('second');
|
|
68
|
+
if (!a || !b)
|
|
69
|
+
return '';
|
|
70
|
+
const f = (role === 'first' ? a : b).fractionOf(value);
|
|
71
|
+
if (!Number.isFinite(f))
|
|
72
|
+
return '';
|
|
73
|
+
const ends = new Float64Array(role === 'first' ? [f, 0, f, 1] : [0, f, 1, f]);
|
|
74
|
+
this.place({ data: ends, stride: 2, count: 2 }, a, b);
|
|
75
|
+
return `M${f2(ends[0])} ${f2(ends[1])}L${f2(ends[2])} ${f2(ends[3])}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Give every axis an orientation and a stack slot: its own `orientation` attribute if it has one, else the next of
|
|
79
|
+
* `defaults` in child order. `check` rejects an orientation this kind of plane does not have. The slot counts how
|
|
80
|
+
* many axes share an orientation, so they can be stacked outwards.
|
|
81
|
+
*/
|
|
82
|
+
assignAxes(defaults, check) {
|
|
83
|
+
const used = new Map();
|
|
84
|
+
let next = 0;
|
|
85
|
+
for (const a of this.axes) {
|
|
86
|
+
const o = check(a.attrs.orientation ?? defaults[next++] ?? fail('too many axes without an orientation'));
|
|
87
|
+
const slot = used.get(o) ?? 0;
|
|
88
|
+
used.set(o, slot + 1);
|
|
89
|
+
a.assign(o, slot);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Put a resolved line look on a node of this plane (gridlines, frame). */
|
|
93
|
+
strokeWith(node, look) {
|
|
94
|
+
const s = this.surface;
|
|
95
|
+
s.set(node, 'stroke', look.color);
|
|
96
|
+
s.set(node, 'stroke-width', look.width);
|
|
97
|
+
s.set(node, 'stroke-dasharray', look.dash);
|
|
98
|
+
s.set(node, 'opacity', look.opacity);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The first half of every layout: start the axes, let the series name theirs, agree the domain before any data is
|
|
102
|
+
* read, then fill every series (which gives the value extents) and settle every range. `estimate` is how much room the
|
|
103
|
+
* plot is expected to have, which sizes decimation; the real plot is only known once the axes have been measured.
|
|
104
|
+
*/
|
|
105
|
+
fit(estimate) {
|
|
106
|
+
const axes = this.axes, series = this.series;
|
|
107
|
+
for (const a of axes)
|
|
108
|
+
a.begin(this.axisLength(a, estimate));
|
|
109
|
+
for (const s of series)
|
|
110
|
+
s.resolveAxes(this);
|
|
111
|
+
for (const s of series)
|
|
112
|
+
s.contributeDomain();
|
|
113
|
+
for (const s of series)
|
|
114
|
+
s.resolveDomain();
|
|
115
|
+
// A group (a stack) loads its own members, since what they contribute depends on all of them.
|
|
116
|
+
for (const c of this) {
|
|
117
|
+
if (c instanceof Series) {
|
|
118
|
+
if (c.shown)
|
|
119
|
+
c.load(estimate);
|
|
120
|
+
}
|
|
121
|
+
else
|
|
122
|
+
groupOf(c)?.load(estimate);
|
|
123
|
+
}
|
|
124
|
+
for (const a of axes)
|
|
125
|
+
a.resolve();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Module-level so ids stay unique across charts sharing a page (clip ids live in the document's one namespace).
|
|
129
|
+
let clipCounter = 0;
|
|
130
|
+
/** A fresh id for a plane's clip region, unique within the page. */
|
|
131
|
+
export const nextClipId = () => `clip${++clipCounter}`;
|
|
132
|
+
/** Where axes go when they do not say, in the order they are added: the familiar x-below, y-left, then the mirrors. */
|
|
133
|
+
const DEFAULT_SIDES = ['bottom', 'left', 'top', 'right'];
|
|
134
|
+
/** The least room left round a plot, whatever the axes need. */
|
|
135
|
+
export const MIN_INSET = 8;
|
|
136
|
+
/** A point `offset` pixels outside the middle of an edge of a plot `w` by `h`: where a title there starts. */
|
|
137
|
+
function edgeSpot(side, w, h, offset) {
|
|
138
|
+
switch (side) {
|
|
139
|
+
case 'bottom': return { x: w / 2, y: h + offset, nx: 0, ny: 1, beyond: false };
|
|
140
|
+
case 'top': return { x: w / 2, y: -offset, nx: 0, ny: -1, beyond: false };
|
|
141
|
+
case 'left': return { x: -offset, y: h / 2, nx: -1, ny: 0, beyond: false };
|
|
142
|
+
case 'right': return { x: w + offset, y: h / 2, nx: 1, ny: 0, beyond: false };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The ruler for an axis on `side` of a plot `w` by `h`, with its line `pos` pixels across: down for a horizontal axis,
|
|
147
|
+
* right for a vertical one. `title` says where its title goes, given the middle of the line.
|
|
148
|
+
*/
|
|
149
|
+
function rulerAt(side, w, h, pos, title) {
|
|
150
|
+
const horizontal = isHorizontal(side), sign = side === 'top' || side === 'left' ? -1 : 1;
|
|
151
|
+
if (horizontal) {
|
|
152
|
+
const at = (f) => [f * w, pos, 0, sign];
|
|
153
|
+
return { length: w, tickSpacing: 70, horizontal, line: `M0 ${f2(pos)}H${f2(w)}`, at, fractionAt: (px) => px / w, title: (p) => title(p, at(0.5)) };
|
|
154
|
+
}
|
|
155
|
+
const at = (f) => [pos, (1 - f) * h, sign, 0];
|
|
156
|
+
return { length: h, tickSpacing: 36, horizontal, line: `M${f2(pos)} 0V${f2(h)}`, at, fractionAt: (_px, py) => 1 - py / h, title: (p) => title(p, at(0.5)) };
|
|
157
|
+
}
|
|
158
|
+
/** x/y plane: horizontal and vertical axes on its edges (or floating across it), clipped, with gridlines at the first axes' ticks. */
|
|
159
|
+
export class CartesianPlane extends Plane {
|
|
160
|
+
clipId = nextClipId();
|
|
161
|
+
get clipUrl() { return this.attrs.clip === false ? null : `url(#${this.clipId})`; }
|
|
162
|
+
get curved() { return false; }
|
|
163
|
+
axisLength(axis, estimate) { return isHorizontal(axis.orientation) ? estimate.width : estimate.height; }
|
|
164
|
+
/** The first coordinate is on the horizontal axis, the second on the vertical one. */
|
|
165
|
+
defaultAxis(role) {
|
|
166
|
+
return this.axes.find((a) => (role === 'first') === isHorizontal(a.orientation));
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Fractions to pixels. The 'first' coordinate goes on whichever axis is horizontal, so a series' first/second
|
|
170
|
+
* columns need no knowledge of orientation (swapping axis sides transposes the plot).
|
|
171
|
+
*/
|
|
172
|
+
place(points, first, second) {
|
|
173
|
+
if (isHorizontal(first.orientation) === isHorizontal(second.orientation)) {
|
|
174
|
+
fail('a series needs one horizontal and one vertical axis');
|
|
175
|
+
}
|
|
176
|
+
const { data, count, stride } = points;
|
|
177
|
+
const w = this.rect.width, h = this.rect.height;
|
|
178
|
+
switch (first.orientation) {
|
|
179
|
+
case 'bottom':
|
|
180
|
+
case 'top':
|
|
181
|
+
for (let k = 0, i = 0; k < count; k++, i += stride) {
|
|
182
|
+
data[i] = data[i] * w;
|
|
183
|
+
data[i + 1] = (1 - data[i + 1]) * h;
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
case 'left':
|
|
187
|
+
case 'right':
|
|
188
|
+
for (let k = 0, i = 0; k < count; k++, i += stride) {
|
|
189
|
+
const d = data[i];
|
|
190
|
+
data[i] = data[i + 1] * w;
|
|
191
|
+
data[i + 1] = (1 - d) * h;
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
case 'angle':
|
|
195
|
+
case 'radius':
|
|
196
|
+
cartesianSide(first.orientation);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
unplace(x, y, first, second) {
|
|
200
|
+
const across = x / this.rect.width, up = 1 - y / this.rect.height;
|
|
201
|
+
return isHorizontal(first.orientation) ? [first.denormalize(across), second.denormalize(up)] : [first.denormalize(up), second.denormalize(across)];
|
|
202
|
+
}
|
|
203
|
+
axisAt(x, y) {
|
|
204
|
+
const w = this.rect.width, h = this.rect.height;
|
|
205
|
+
for (const a of this.axes) {
|
|
206
|
+
if (!this.reserves(a) || a.thickness <= 0)
|
|
207
|
+
continue;
|
|
208
|
+
const at = a.ruler.at(0), horizontal = isHorizontal(a.orientation);
|
|
209
|
+
const pos = horizontal ? at[1] : at[0], sign = horizontal ? at[3] : at[2], far = pos + sign * a.thickness;
|
|
210
|
+
const lo = Math.min(pos, far), hi = Math.max(pos, far);
|
|
211
|
+
if (horizontal ? x >= 0 && x <= w && y >= lo && y <= hi : y >= 0 && y <= h && x >= lo && x <= hi)
|
|
212
|
+
return a;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
onEvent(ev) {
|
|
217
|
+
switch (ev.type) {
|
|
218
|
+
case EVENT_ATTACH:
|
|
219
|
+
this.surface.wrapper(); // carries the plot offset
|
|
220
|
+
break;
|
|
221
|
+
case EVENT_ARRANGE:
|
|
222
|
+
this.arrange(ev.rect);
|
|
223
|
+
break;
|
|
224
|
+
case EVENT_PAINT:
|
|
225
|
+
this.paint();
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Where a floating axis crosses, as a fraction of the plot across the crossing axis (0 at the left or bottom, 1 at
|
|
231
|
+
* the right or top), or null for an axis fixed to a side. A position out of range is held at the nearest edge.
|
|
232
|
+
*/
|
|
233
|
+
crossing(axis) {
|
|
234
|
+
const at = axis.attrs.crossesAt;
|
|
235
|
+
if (at === undefined)
|
|
236
|
+
return null;
|
|
237
|
+
const horizontal = isHorizontal(axis.orientation);
|
|
238
|
+
const cross = resolveRef(axis.attrs.crossAxis, axis, Axis) ?? this.axes.find((a) => isHorizontal(a.orientation) !== horizontal)
|
|
239
|
+
?? fail('a floating axis needs an axis on the other orientation to cross');
|
|
240
|
+
return Math.min(1, Math.max(0, cross.fractionOf(at)));
|
|
241
|
+
}
|
|
242
|
+
/** Whether an axis takes room round the plot: one fixed to a side does, and so does one held at the edge it faces. */
|
|
243
|
+
reserves(axis) {
|
|
244
|
+
const across = this.crossing(axis);
|
|
245
|
+
if (across === null)
|
|
246
|
+
return true;
|
|
247
|
+
switch (cartesianSide(axis.orientation)) {
|
|
248
|
+
case 'bottom':
|
|
249
|
+
case 'left': return across <= 1e-9;
|
|
250
|
+
case 'top':
|
|
251
|
+
case 'right': return across >= 1 - 1e-9;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Every axis gets its place in a plot `w` by `h`: a fixed side, stacked outwards by the axes before it, or across the
|
|
256
|
+
* plot. Titles that are not on an axis line go outside everything else at their edge, one after another.
|
|
257
|
+
*/
|
|
258
|
+
setRulers(w, h) {
|
|
259
|
+
const stacked = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
260
|
+
const titleAt = new Map();
|
|
261
|
+
for (const a of this.axes) {
|
|
262
|
+
const o = cartesianSide(a.orientation), across = this.crossing(a), reserves = this.reserves(a);
|
|
263
|
+
// A title belongs on the axis line when the axis is fixed there and the title is asked for on its own side.
|
|
264
|
+
const spot = (position, mid) => reserves && (position ?? o) === o
|
|
265
|
+
? { x: mid[0], y: mid[1], nx: mid[2], ny: mid[3], beyond: true }
|
|
266
|
+
: edgeSpot(position ?? o, w, h, titleAt.get(a) ?? 0);
|
|
267
|
+
if (across !== null && !reserves) {
|
|
268
|
+
a.setRuler(rulerAt(o, w, h, isHorizontal(o) ? (1 - across) * h : across * w, spot));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const edge = o === 'bottom' ? h : o === 'right' ? w : 0, sign = o === 'top' || o === 'left' ? -1 : 1;
|
|
272
|
+
a.setRuler(rulerAt(o, w, h, edge + sign * stacked[o], spot));
|
|
273
|
+
stacked[o] += a.thickness;
|
|
274
|
+
}
|
|
275
|
+
for (const a of this.axes) {
|
|
276
|
+
if (a.titleThickness === 0)
|
|
277
|
+
continue;
|
|
278
|
+
titleAt.set(a, stacked[a.titleSide]);
|
|
279
|
+
stacked[a.titleSide] += a.titleThickness;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** Negotiates ranges, lays out the axes, projects every series. */
|
|
283
|
+
arrange(available) {
|
|
284
|
+
const axes = this.axes, series = this.series;
|
|
285
|
+
this.assignAxes(DEFAULT_SIDES, cartesianSide);
|
|
286
|
+
// 80 and 40: roughly a y-axis's labels and an x-axis's labels. Only a guess for decimation; the real plot is set below.
|
|
287
|
+
const estimate = { width: Math.max(1, available.width - 80), height: Math.max(1, available.height - 40) };
|
|
288
|
+
this.setRulers(estimate.width, estimate.height);
|
|
289
|
+
this.fit(estimate);
|
|
290
|
+
this.setRulers(estimate.width, estimate.height); // ranges are settled now, which is what tells a floating axis from a fixed one
|
|
291
|
+
// Axes ask for space; what is left is the plot. Overhang is a horizontal axis' end labels poking past the plot sides.
|
|
292
|
+
// (The fixed-side thickness is summed per side, so stacked axes add up.)
|
|
293
|
+
const side = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
294
|
+
let overhang = 0;
|
|
295
|
+
for (const a of axes) {
|
|
296
|
+
this.measureChild(a, estimate);
|
|
297
|
+
if (this.reserves(a))
|
|
298
|
+
side[cartesianSide(a.orientation)] += a.thickness;
|
|
299
|
+
side[a.titleSide] += a.titleThickness;
|
|
300
|
+
if (isHorizontal(a.orientation))
|
|
301
|
+
overhang = Math.max(overhang, a.labelOverhang);
|
|
302
|
+
}
|
|
303
|
+
const o = this.attrs.insets ?? {};
|
|
304
|
+
const insets = {
|
|
305
|
+
top: o.top ?? Math.max(MIN_INSET, side.top),
|
|
306
|
+
bottom: o.bottom ?? Math.max(MIN_INSET, side.bottom),
|
|
307
|
+
left: o.left ?? Math.max(MIN_INSET, side.left, Math.ceil(overhang)),
|
|
308
|
+
right: o.right ?? Math.max(MIN_INSET, side.right, Math.ceil(overhang)),
|
|
309
|
+
};
|
|
310
|
+
this.setRect({
|
|
311
|
+
x: available.x + insets.left,
|
|
312
|
+
y: available.y + insets.top,
|
|
313
|
+
width: Math.max(1, available.width - insets.left - insets.right),
|
|
314
|
+
height: Math.max(1, available.height - insets.top - insets.bottom),
|
|
315
|
+
});
|
|
316
|
+
// Now the plot is known, the axes can be placed along it, and the series projected.
|
|
317
|
+
this.setRulers(this.rect.width, this.rect.height);
|
|
318
|
+
for (const s of series)
|
|
319
|
+
s.project(this);
|
|
320
|
+
}
|
|
321
|
+
/** Clip region, gridlines (from the first horizontal and vertical axis) and frame. */
|
|
322
|
+
paint() {
|
|
323
|
+
const s = this.surface, r = this.rect, a = this.attrs;
|
|
324
|
+
s.set(s.wrapper(), 'transform', `translate(${f2(r.x)} ${f2(r.y)})`);
|
|
325
|
+
writeAria(s, s.wrapper(), a.meta, { role: 'group' });
|
|
326
|
+
if (a.clip !== false) {
|
|
327
|
+
const clip = s.node('clip', 'clipPath', { parent: s.node('defs', 'defs') });
|
|
328
|
+
s.set(clip, 'id', this.clipId);
|
|
329
|
+
const rect = s.node('clip-rect', 'rect', { parent: clip });
|
|
330
|
+
s.set(rect, 'width', f2(r.width));
|
|
331
|
+
s.set(rect, 'height', f2(r.height));
|
|
332
|
+
}
|
|
333
|
+
if (a.gridlines !== false) {
|
|
334
|
+
let d = '';
|
|
335
|
+
const horizontalAxis = this.axes.find((x) => isHorizontal(x.orientation));
|
|
336
|
+
const verticalAxis = this.axes.find((x) => !isHorizontal(x.orientation));
|
|
337
|
+
if (horizontalAxis) {
|
|
338
|
+
for (const t of horizontalAxis.getTicks(r.width)) {
|
|
339
|
+
const f = horizontalAxis.fractionOf(t.value);
|
|
340
|
+
if (f >= -1e-9 && f <= 1 + 1e-9)
|
|
341
|
+
d += `M${f2(f * r.width)} 0V${f2(r.height)}`;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (verticalAxis) {
|
|
345
|
+
for (const t of verticalAxis.getTicks(r.height)) {
|
|
346
|
+
const f = verticalAxis.fractionOf(t.value);
|
|
347
|
+
if (f >= -1e-9 && f <= 1 + 1e-9)
|
|
348
|
+
d += `M0 ${f2((1 - f) * r.height)}H${f2(r.width)}`;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const grid = s.node('grid', 'path');
|
|
352
|
+
s.set(grid, 'd', d);
|
|
353
|
+
s.set(grid, 'fill', 'none');
|
|
354
|
+
this.strokeWith(grid, this.styleFor(['gridline', 'line']));
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
s.remove('grid');
|
|
358
|
+
}
|
|
359
|
+
if (a.frame) {
|
|
360
|
+
const f = s.node('frame', 'rect', { above: true });
|
|
361
|
+
s.set(f, 'width', f2(r.width));
|
|
362
|
+
s.set(f, 'height', f2(r.height));
|
|
363
|
+
s.set(f, 'fill', 'none');
|
|
364
|
+
this.strokeWith(f, this.styleFor(['frame', 'line']));
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
s.remove('frame');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform: everything the library needs from the outside world, and a string-backed implementation of it.
|
|
3
|
+
*
|
|
4
|
+
* Why an interface rather than the DOM: the element tree only ever talks to `Surface`, and `Surface` only ever talks to
|
|
5
|
+
* a `Platform`. That keeps the core free of `document`/`window`, so the same chart renders in a browser (DOMPlatform),
|
|
6
|
+
* in node or a serverless function (StringPlatform → SVG text), and in tests. Everything beyond node creation is
|
|
7
|
+
* optional: a platform with nothing to observe simply leaves `schedule`/`onResize`/`onPointer` out.
|
|
8
|
+
*/
|
|
9
|
+
import type { Size } from './geometry.ts';
|
|
10
|
+
export declare const SVG_NS = "http://www.w3.org/2000/svg";
|
|
11
|
+
export type SvgTag = 'svg' | 'g' | 'path' | 'rect' | 'line' | 'circle' | 'text' | 'clipPath' | 'defs' | 'title' | 'desc' | 'linearGradient' | 'stop';
|
|
12
|
+
export interface PlatformNode {
|
|
13
|
+
readonly __platformNode: unique symbol;
|
|
14
|
+
}
|
|
15
|
+
export type Unsubscribe = () => void;
|
|
16
|
+
export interface FontSpec {
|
|
17
|
+
family: string;
|
|
18
|
+
size: number;
|
|
19
|
+
weight?: number | string;
|
|
20
|
+
}
|
|
21
|
+
export type TextMeasurer = (text: string, font: FontSpec) => Size;
|
|
22
|
+
/** Pointer input boiled down to what charts use, so every platform feeds the same events whatever its native ones look like. */
|
|
23
|
+
export interface RawPointer {
|
|
24
|
+
/** `pinch` is a zoom gesture the platform recognised itself (a trackpad pinch in Safari): `scale` is its factor since the last one. */
|
|
25
|
+
kind: 'move' | 'down' | 'up' | 'leave' | 'wheel' | 'pinch';
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
buttons: number;
|
|
29
|
+
deltaY: number;
|
|
30
|
+
/** Sideways wheel travel, in pixels. Default 0. */
|
|
31
|
+
deltaX?: number;
|
|
32
|
+
/** For `pinch`: how much the fingers spread since the last event (above 1 spreads, below 1 closes). */
|
|
33
|
+
scale?: number;
|
|
34
|
+
/** Which finger or device, so that several touches can be told apart. Default 0. */
|
|
35
|
+
id?: number;
|
|
36
|
+
/** Default 'mouse'. */
|
|
37
|
+
pointerType?: 'mouse' | 'touch' | 'pen';
|
|
38
|
+
shift: boolean;
|
|
39
|
+
ctrl: boolean;
|
|
40
|
+
}
|
|
41
|
+
/** A key press or release on a focused node, boiled down like `RawPointer`. */
|
|
42
|
+
export interface RawKey {
|
|
43
|
+
kind: 'down' | 'up';
|
|
44
|
+
key: string;
|
|
45
|
+
shift: boolean;
|
|
46
|
+
ctrl: boolean;
|
|
47
|
+
alt: boolean;
|
|
48
|
+
repeat: boolean;
|
|
49
|
+
}
|
|
50
|
+
/** A node gaining or losing focus; `visible` is whether a ring is wanted (the browser's `:focus-visible`). */
|
|
51
|
+
export interface RawFocus {
|
|
52
|
+
node: PlatformNode;
|
|
53
|
+
focused: boolean;
|
|
54
|
+
visible: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface Platform {
|
|
57
|
+
readonly name: string;
|
|
58
|
+
/** The node the chart draws into. */
|
|
59
|
+
readonly root: PlatformNode;
|
|
60
|
+
/** The drawing area in CSS pixels. */
|
|
61
|
+
size(): Size;
|
|
62
|
+
createNode(tag: SvgTag): PlatformNode;
|
|
63
|
+
setAttribute(node: PlatformNode, name: string, value: string | number): void;
|
|
64
|
+
setText(node: PlatformNode, text: string): void;
|
|
65
|
+
/** Exactly `Node.insertBefore`: `before` is a child of `parent`, or null to append. Moves the node if it is already placed. */
|
|
66
|
+
insertBefore(parent: PlatformNode, node: PlatformNode, before: PlatformNode | null): void;
|
|
67
|
+
/** Takes the node, and everything inside it, out of whatever it is in. */
|
|
68
|
+
remove(node: PlatformNode): void;
|
|
69
|
+
/** Present: the chart flushes itself after an invalidation. Absent: call `chart.flush()`. */
|
|
70
|
+
schedule?(callback: () => void): Unsubscribe;
|
|
71
|
+
/** Absent on a platform with nothing to observe. */
|
|
72
|
+
onResize?(callback: (size: Size) => void): Unsubscribe;
|
|
73
|
+
/** The callback returns true when the chart used the input; the platform then withholds the browser's own handling (page scroll on a wheel turn). */
|
|
74
|
+
onPointer?(callback: (pointer: RawPointer) => boolean | void): Unsubscribe;
|
|
75
|
+
/** Keys pressed while one of the chart's nodes has focus. The callback gets the node; true means the chart used the key. */
|
|
76
|
+
onKey?(callback: (key: RawKey, node: PlatformNode) => boolean | void): Unsubscribe;
|
|
77
|
+
/** Focus entering and leaving the chart's nodes. */
|
|
78
|
+
onFocus?(callback: (focus: RawFocus) => void): Unsubscribe;
|
|
79
|
+
/** Say something to a screen reader without showing it, politely (after what it is saying now). Absent on a platform with no one to hear. */
|
|
80
|
+
announce?(text: string): void;
|
|
81
|
+
/** Move focus to a node that has a `tabindex`. */
|
|
82
|
+
focus?(node: PlatformNode): void;
|
|
83
|
+
measureText?: TextMeasurer;
|
|
84
|
+
/** The mouse cursor over the chart, as a CSS cursor name; an empty string for the default. */
|
|
85
|
+
setCursor?(cursor: string): void;
|
|
86
|
+
/**
|
|
87
|
+
* What a touch on the chart may do. 'always': the chart takes every touch (the page cannot scroll from it).
|
|
88
|
+
* 'two-finger': a touch with one finger scrolls the page as usual and only two fingers belong to the chart.
|
|
89
|
+
*/
|
|
90
|
+
setTouchMode?(mode: 'always' | 'two-finger'): void;
|
|
91
|
+
dispose(): void;
|
|
92
|
+
}
|
|
93
|
+
/** Errs wide, which costs a little margin and never a clipped label. */
|
|
94
|
+
export declare const estimateTextWidth: TextMeasurer;
|
|
95
|
+
/**
|
|
96
|
+
* Keeps the SVG as a plain in-memory tree and prints it with `toString()`. This is the platform for node, serverless and
|
|
97
|
+
* tests: no DOM, no timers, no observers, so a chart is flushed explicitly and the output is deterministic.
|
|
98
|
+
*/
|
|
99
|
+
export declare class StringPlatform implements Platform {
|
|
100
|
+
#private;
|
|
101
|
+
readonly name = "string";
|
|
102
|
+
readonly root: PlatformNode;
|
|
103
|
+
constructor(size?: Size);
|
|
104
|
+
size(): Size;
|
|
105
|
+
createNode(tag: SvgTag): PlatformNode;
|
|
106
|
+
setAttribute(node: PlatformNode, name: string, value: string | number): void;
|
|
107
|
+
setText(node: PlatformNode, text: string): void;
|
|
108
|
+
insertBefore(parent: PlatformNode, node: PlatformNode, before: PlatformNode | null): void;
|
|
109
|
+
remove(node: PlatformNode): void;
|
|
110
|
+
dispose(): void;
|
|
111
|
+
/** A standalone SVG document with literal attribute values, safe to embed or rasterize. */
|
|
112
|
+
toString(): string;
|
|
113
|
+
}
|
package/dist/platform.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
2
|
+
/** Errs wide, which costs a little margin and never a clipped label. */
|
|
3
|
+
export const estimateTextWidth = (text, font) => ({
|
|
4
|
+
width: text.length * font.size * 0.6,
|
|
5
|
+
height: font.size * 1.2,
|
|
6
|
+
});
|
|
7
|
+
/** The library's handle for a node is opaque; here every handle is one of our own `StringNode`s, and these two are the only conversions. */
|
|
8
|
+
// oxlint-disable-next-line typescript/consistent-type-assertions -- the one place an opaque handle becomes our own node, and back: every handle on this platform is a `StringNode`
|
|
9
|
+
const nodeOf = (node) => node;
|
|
10
|
+
// oxlint-disable-next-line typescript/consistent-type-assertions -- the one place an opaque handle becomes our own node, and back: every handle on this platform is a `StringNode`
|
|
11
|
+
const handleOf = (node) => node;
|
|
12
|
+
const escapeText = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
13
|
+
const escapeAttr = (s) => escapeText(s).replace(/"/g, '"');
|
|
14
|
+
/**
|
|
15
|
+
* Keeps the SVG as a plain in-memory tree and prints it with `toString()`. This is the platform for node, serverless and
|
|
16
|
+
* tests: no DOM, no timers, no observers, so a chart is flushed explicitly and the output is deterministic.
|
|
17
|
+
*/
|
|
18
|
+
export class StringPlatform {
|
|
19
|
+
name = 'string';
|
|
20
|
+
root;
|
|
21
|
+
#size;
|
|
22
|
+
#root;
|
|
23
|
+
constructor(size = { width: 640, height: 400 }) {
|
|
24
|
+
this.#size = size;
|
|
25
|
+
this.#root = { tag: 'svg', attrs: new Map(), children: [], text: null, parent: null };
|
|
26
|
+
this.#root.attrs.set('xmlns', 'http://www.w3.org/2000/svg');
|
|
27
|
+
this.root = handleOf(this.#root);
|
|
28
|
+
}
|
|
29
|
+
size() { return this.#size; }
|
|
30
|
+
createNode(tag) {
|
|
31
|
+
return handleOf({ tag, attrs: new Map(), children: [], text: null, parent: null });
|
|
32
|
+
}
|
|
33
|
+
setAttribute(node, name, value) {
|
|
34
|
+
nodeOf(node).attrs.set(name, value);
|
|
35
|
+
}
|
|
36
|
+
setText(node, text) { nodeOf(node).text = text; }
|
|
37
|
+
insertBefore(parent, node, before) {
|
|
38
|
+
const p = nodeOf(parent), n = nodeOf(node);
|
|
39
|
+
if (n.parent)
|
|
40
|
+
this.remove(node);
|
|
41
|
+
n.parent = p;
|
|
42
|
+
const at = before ? p.children.indexOf(nodeOf(before)) : -1;
|
|
43
|
+
if (at < 0)
|
|
44
|
+
p.children.push(n);
|
|
45
|
+
else
|
|
46
|
+
p.children.splice(at, 0, n);
|
|
47
|
+
}
|
|
48
|
+
remove(node) {
|
|
49
|
+
const n = nodeOf(node);
|
|
50
|
+
if (!n.parent)
|
|
51
|
+
return;
|
|
52
|
+
n.parent.children.splice(n.parent.children.indexOf(n), 1);
|
|
53
|
+
n.parent = null;
|
|
54
|
+
}
|
|
55
|
+
dispose() { }
|
|
56
|
+
/** A standalone SVG document with literal attribute values, safe to embed or rasterize. */
|
|
57
|
+
toString() {
|
|
58
|
+
const lines = [];
|
|
59
|
+
const visit = (n, depth) => {
|
|
60
|
+
const pad = ' '.repeat(depth);
|
|
61
|
+
let open = `${pad}<${n.tag}`;
|
|
62
|
+
for (const [k, v] of n.attrs)
|
|
63
|
+
open += ` ${k}="${escapeAttr(String(v))}"`;
|
|
64
|
+
if (n.text !== null) {
|
|
65
|
+
lines.push(`${open}>${escapeText(n.text)}</${n.tag}>`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (n.children.length === 0) {
|
|
69
|
+
lines.push(`${open}/>`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
lines.push(`${open}>`);
|
|
73
|
+
for (const c of n.children)
|
|
74
|
+
visit(c, depth + 1);
|
|
75
|
+
lines.push(`${pad}</${n.tag}>`);
|
|
76
|
+
};
|
|
77
|
+
visit(this.#root, 0);
|
|
78
|
+
return lines.join('\n') + '\n';
|
|
79
|
+
}
|
|
80
|
+
}
|
package/dist/png.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A chart as a PNG in the browser: `toPng(chart)` for one on the page, `svgToPng(svg)` for an SVG string you already have.
|
|
3
|
+
*
|
|
4
|
+
* const blob = await toPng(chart, { scale: 2, background: '#fff' });
|
|
5
|
+
*
|
|
6
|
+
* It draws the chart's SVG into a canvas, which is the one thing a browser does that node does not, so it lives apart from
|
|
7
|
+
* the core and costs nothing unless imported. For a chart on the page it takes the SVG that is showing, so the picture is
|
|
8
|
+
* what the person sees; for one not attached it renders a copy with `toSvgString` and leaves the chart as it was.
|
|
9
|
+
* `scale` multiplies the pixel size (2 for a sharp picture on a dense screen). Fonts the page loaded are not
|
|
10
|
+
* available to an SVG drawn as an image, so text falls back to the system's; names a system has (the default
|
|
11
|
+
* `system-ui`) come out right. A transparent background stays transparent unless `background` says otherwise. On a server use a WASM
|
|
12
|
+
* renderer instead: see the recipe "Make a PNG on a server".
|
|
13
|
+
*/
|
|
14
|
+
import { type Chart } from './chart.ts';
|
|
15
|
+
import type { Size } from './geometry.ts';
|
|
16
|
+
export interface PngOptions {
|
|
17
|
+
/** Pixels per CSS pixel. Default 1. */
|
|
18
|
+
scale?: number;
|
|
19
|
+
/** Colour painted behind the chart. Default none (transparent). */
|
|
20
|
+
background?: string;
|
|
21
|
+
/** Size in CSS pixels for a chart that is not attached or an SVG without one. Default: the chart's own `width` and `height`, else 640 by 400. */
|
|
22
|
+
size?: Size;
|
|
23
|
+
}
|
|
24
|
+
/** What `svgToPng` needs of the browser, so it can be tested without one. */
|
|
25
|
+
export interface PngHost {
|
|
26
|
+
loadImage(url: string): Promise<{
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
image: unknown;
|
|
30
|
+
}>;
|
|
31
|
+
canvas(width: number, height: number): {
|
|
32
|
+
context: {
|
|
33
|
+
fillStyle: string;
|
|
34
|
+
fillRect(x: number, y: number, w: number, h: number): void;
|
|
35
|
+
drawImage(image: unknown, x: number, y: number, w: number, h: number): void;
|
|
36
|
+
scale(x: number, y: number): void;
|
|
37
|
+
};
|
|
38
|
+
blob(): Promise<Blob>;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** An SVG document as a PNG. Its own `width` and `height` give the size, else `options.size`. */
|
|
42
|
+
export declare function svgToPng(svg: string, options?: PngOptions, host?: PngHost): Promise<Blob>;
|
|
43
|
+
/** The chart as a PNG: what is showing if it is on the page, else a rendering of it. */
|
|
44
|
+
export declare function toPng(chart: Chart, options?: PngOptions, host?: PngHost): Promise<Blob>;
|