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/bars.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bars: from a baseline to a value, one for each point of a series.
|
|
3
|
+
*
|
|
4
|
+
* A bar is drawn in the plane's own terms, never in pixels: its four corners are placed through the axes and the plane
|
|
5
|
+
* like any other point, so the same code gives upright bars on a rectangle, horizontal ones when the domain is on the
|
|
6
|
+
* vertical axis, and wedges on a polar plane (where the two edges along the angle are subdivided into arcs, since a
|
|
7
|
+
* straight line between the corners would cut across: the domain's when the domain is the angle, the value's when the value is,
|
|
8
|
+
* as in a ring or pie).
|
|
9
|
+
*
|
|
10
|
+
* Two renderers share all of that, through `BarRenderer`: `BarPath` fills each bar, and `BarLinePath` draws only its
|
|
11
|
+
* stem, a line from the foot to the value (a spike plot; add a marker to the series for a lollipop). A stem has no
|
|
12
|
+
* width, so the width rule matters only to series that share a band.
|
|
13
|
+
*
|
|
14
|
+
* Where a bar starts is the series' `baseline`, or, in a `Stack`, the row's own start (the layer below it). Its width:
|
|
15
|
+
* - on a band axis, the band's `bandwidth`, which the scale's `gap` sets;
|
|
16
|
+
* - otherwise `width` (default 0.8) of the smallest gap between neighbouring points, since that is what the bars would
|
|
17
|
+
* have to share. With one point and no band there is nothing to measure and the bar takes a fifth of the axis.
|
|
18
|
+
* Series that stack side by side divide that width between them, in the order the stack lists them.
|
|
19
|
+
*
|
|
20
|
+
* The geometry is a free function over `Series.drawn` that hands back polygons, which is also what a pointer hit on a
|
|
21
|
+
* bar is measured against, so what is clicked is exactly what is drawn.
|
|
22
|
+
*/
|
|
23
|
+
/* oxlint-disable typescript/no-non-null-assertion -- numeric kernels over flat typed-array buffers (stride-addressed rows): every index is bounded by the row count, which `noUncheckedIndexedAccess` cannot see */
|
|
24
|
+
import { f2 } from './geometry.js';
|
|
25
|
+
import { SeriesRenderer, paintBox } from './renderer.js';
|
|
26
|
+
import { notImplemented } from './stub.js';
|
|
27
|
+
/** How far outside the axes' range a bar may reach before it is cut short: only to keep the numbers sane, the clip does the rest. */
|
|
28
|
+
const REACH = 2;
|
|
29
|
+
/** Most pieces the edge of a bar on a curved plane is cut into. */
|
|
30
|
+
const MAX_ARC = 16;
|
|
31
|
+
/** The same, along the value, when the value is the angle: a segment may span a whole turn. */
|
|
32
|
+
const MAX_VALUE_ARC = 48;
|
|
33
|
+
export function layoutBars(series, options) {
|
|
34
|
+
const d = series.drawn;
|
|
35
|
+
if (!d)
|
|
36
|
+
return null;
|
|
37
|
+
const n = d.points.count, rs = d.stride, raw = d.raw, first = series.firstAxis, second = series.secondAxis;
|
|
38
|
+
const baseline = options.baseline ?? 0;
|
|
39
|
+
const norm = new Float64Array(n * 3);
|
|
40
|
+
for (let i = 0; i < n; i++) {
|
|
41
|
+
const v = raw[i * rs + d.secondColumn], ok = Number.isFinite(v);
|
|
42
|
+
norm[3 * i] = raw[i * rs + d.firstColumn];
|
|
43
|
+
norm[3 * i + 1] = ok ? (d.base ? d.base[i] : baseline) : NaN;
|
|
44
|
+
norm[3 * i + 2] = ok ? (d.top ? d.top[i] : v) : NaN;
|
|
45
|
+
}
|
|
46
|
+
first.normalize(norm, n, 3, 0);
|
|
47
|
+
second.normalize(norm, n, 3, 1);
|
|
48
|
+
second.normalize(norm, n, 3, 2);
|
|
49
|
+
// The room one bar (or one group of them) has along the domain.
|
|
50
|
+
// oxlint-disable-next-line typescript/consistent-type-assertions -- a scale is read for the optional `bandwidth` and `gap` that only a band scale has
|
|
51
|
+
const scale = first.scale;
|
|
52
|
+
let room = 0;
|
|
53
|
+
if (typeof scale.bandwidth === 'number' && options.width === undefined) {
|
|
54
|
+
room = scale.bandwidth;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
let pitch = Infinity, prev = NaN;
|
|
58
|
+
for (let i = 0; i < n; i++) {
|
|
59
|
+
const x = norm[3 * i];
|
|
60
|
+
if (!Number.isFinite(x))
|
|
61
|
+
continue;
|
|
62
|
+
if (prev === prev && x > prev && x - prev < pitch)
|
|
63
|
+
pitch = x - prev;
|
|
64
|
+
prev = x;
|
|
65
|
+
}
|
|
66
|
+
if (typeof scale.bandwidth === 'number')
|
|
67
|
+
pitch = scale.bandwidth / (1 - (scale.attrs?.gap ?? 0.2));
|
|
68
|
+
room = (options.width ?? 0.8) * (pitch < Infinity ? pitch : 0.25);
|
|
69
|
+
}
|
|
70
|
+
const { index, count } = d.slot, slot = room / count;
|
|
71
|
+
return { n, norm, slot, shift: (index - (count - 1) / 2) * slot };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* How far along the domain (as a fraction of the axis) a series' own bars sit from the point's place, when it shares a band
|
|
75
|
+
* with others (`Stack` in 'group' mode). What goes on the bars (error bars) uses it to stay on them. 0 for a series alone.
|
|
76
|
+
*/
|
|
77
|
+
export function barShift(series, options = {}) {
|
|
78
|
+
const d = series.drawn;
|
|
79
|
+
return d && d.slot.count > 1 ? layoutBars(series, options)?.shift ?? 0 : 0;
|
|
80
|
+
}
|
|
81
|
+
const clamp = (v) => (v < -REACH ? -REACH : v > 1 + REACH ? 1 + REACH : v);
|
|
82
|
+
/** The three stems drawn in a bar series' legend sample: position across, height up. */
|
|
83
|
+
const STEM_SAMPLE = [[0.2, 0.6], [0.5, 1], [0.8, 0.4]];
|
|
84
|
+
/**
|
|
85
|
+
* The outline of every bar of a series in plot pixels, `perShape` points each, in the order of the series' points
|
|
86
|
+
* (a point that is a gap leaves a shape of NaN). Null before the first projection.
|
|
87
|
+
*/
|
|
88
|
+
export function barShapes(series, options = {}) {
|
|
89
|
+
const lay = layoutBars(series, options), plane = series.drawn?.plane;
|
|
90
|
+
if (!lay || !plane)
|
|
91
|
+
return null;
|
|
92
|
+
const { n, norm, slot, shift } = lay;
|
|
93
|
+
// On a curved plane the edges that run along the angle become arcs: the ones along the domain normally, the ones along
|
|
94
|
+
// the value when the value is the angle (a ring or pie: each bar is a segment of a ring).
|
|
95
|
+
const roundValue = plane.curved && series.secondAxis.orientation === 'angle';
|
|
96
|
+
const steps = roundValue ? MAX_VALUE_ARC : plane.curved ? Math.min(MAX_ARC, Math.max(2, Math.ceil(slot * 64))) : 1, per = 2 * (steps + 1);
|
|
97
|
+
const out = new Float64Array(n * per * 2);
|
|
98
|
+
for (let i = 0; i < n; i++) {
|
|
99
|
+
const x = norm[3 * i] + shift, from = norm[3 * i + 1], to = norm[3 * i + 2];
|
|
100
|
+
const bad = !Number.isFinite(x) || !Number.isFinite(from) || !Number.isFinite(to);
|
|
101
|
+
const x0 = x - slot / 2, b = bad ? NaN : clamp(from), t = bad ? NaN : clamp(to);
|
|
102
|
+
let k = i * per * 2;
|
|
103
|
+
// Along the start edge one way, then back along the end edge: a closed outline.
|
|
104
|
+
if (roundValue) {
|
|
105
|
+
for (let j = 0; j <= steps; j++) {
|
|
106
|
+
out[k++] = x0;
|
|
107
|
+
out[k++] = b + ((t - b) * j) / steps;
|
|
108
|
+
}
|
|
109
|
+
for (let j = steps; j >= 0; j--) {
|
|
110
|
+
out[k++] = x0 + slot;
|
|
111
|
+
out[k++] = b + ((t - b) * j) / steps;
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
for (let j = 0; j <= steps; j++) {
|
|
116
|
+
out[k++] = x0 + (slot * j) / steps;
|
|
117
|
+
out[k++] = b;
|
|
118
|
+
}
|
|
119
|
+
for (let j = steps; j >= 0; j--) {
|
|
120
|
+
out[k++] = x0 + (slot * j) / steps;
|
|
121
|
+
out[k++] = t;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
plane.place({ data: out, stride: 2, count: n * per }, series.firstAxis, series.secondAxis);
|
|
125
|
+
return { data: out, perShape: per, count: n };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The stem of every bar: a line from its foot to its value, two points each, as a `PolygonSet` of two-point shapes (so a
|
|
129
|
+
* hit measures the distance to the line). Straight on every plane, since a line along the value is a straight line (a
|
|
130
|
+
* spoke) on a polar one. Gaps leave NaN.
|
|
131
|
+
*/
|
|
132
|
+
export function stemLines(series, options = {}) {
|
|
133
|
+
const lay = layoutBars(series, options), plane = series.drawn?.plane;
|
|
134
|
+
if (!lay || !plane)
|
|
135
|
+
return null;
|
|
136
|
+
const { n, norm, shift } = lay;
|
|
137
|
+
const out = new Float64Array(n * 4);
|
|
138
|
+
for (let i = 0; i < n; i++) {
|
|
139
|
+
const x = norm[3 * i] + shift, from = norm[3 * i + 1], to = norm[3 * i + 2];
|
|
140
|
+
const bad = !Number.isFinite(x) || !Number.isFinite(from) || !Number.isFinite(to);
|
|
141
|
+
out[4 * i] = out[4 * i + 2] = bad ? NaN : x;
|
|
142
|
+
out[4 * i + 1] = bad ? NaN : clamp(from);
|
|
143
|
+
out[4 * i + 3] = bad ? NaN : clamp(to);
|
|
144
|
+
}
|
|
145
|
+
plane.place({ data: out, stride: 2, count: n * 2 }, series.firstAxis, series.secondAxis);
|
|
146
|
+
return { data: out, perShape: 2, count: n };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* What every bar renderer shares: where bars start and how wide they are (`BarOptions`), that the value axis has to reach
|
|
150
|
+
* the baseline, and that a pointer hit is measured against the geometry it draws. A subclass says what that geometry is
|
|
151
|
+
* (`outline`) and how to draw it.
|
|
152
|
+
*/
|
|
153
|
+
export class BarRenderer extends SeriesRenderer {
|
|
154
|
+
get includes() { return [this.attrs.baseline ?? 0]; }
|
|
155
|
+
/** Half of a bar's room: its width, in the gaps between points (a band's own bars fit it already). */
|
|
156
|
+
get spans() { return (this.attrs.width ?? 0.8) / 2; }
|
|
157
|
+
get hitMode() { return 'shape'; }
|
|
158
|
+
shapes() { return this.outline(); }
|
|
159
|
+
}
|
|
160
|
+
/** One path holding every bar of the series: the cost of a bar chart is its path text, not a node for each bar. */
|
|
161
|
+
export class BarPath extends BarRenderer {
|
|
162
|
+
look() {
|
|
163
|
+
const a = this.attrs;
|
|
164
|
+
return this.styleFor(['seriesBar', 'shape'], { fill: a.fill, stroke: a.stroke, strokeWidth: a.strokeWidth, opacity: a.opacity }, { fill: this.requireParent().accentColor });
|
|
165
|
+
}
|
|
166
|
+
get swatch() { return this.look().fill; }
|
|
167
|
+
outline() { return barShapes(this.requireParent(), this.attrs); }
|
|
168
|
+
sample(s, parent, key, width, height) { paintBox(s, parent, key, width, height, this.look()); }
|
|
169
|
+
draw() {
|
|
170
|
+
const counters = this.counters, s = this.surface, a = this.attrs;
|
|
171
|
+
if (a.bind)
|
|
172
|
+
notImplemented('BarPath bindings');
|
|
173
|
+
const look = this.look();
|
|
174
|
+
const t0 = performance.now();
|
|
175
|
+
const shapes = this.outline();
|
|
176
|
+
let d = '';
|
|
177
|
+
if (shapes) {
|
|
178
|
+
const { data, perShape, count } = shapes;
|
|
179
|
+
for (let i = 0, k = 0; i < count; i++, k += perShape * 2) {
|
|
180
|
+
const x = data[k], y = data[k + 1];
|
|
181
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
182
|
+
continue;
|
|
183
|
+
d += 'M' + f2(x) + ' ' + f2(y) + 'L';
|
|
184
|
+
for (let j = 1; j < perShape; j++)
|
|
185
|
+
d += (j > 1 ? ' ' : '') + f2(data[k + 2 * j]) + ' ' + f2(data[k + 2 * j + 1]);
|
|
186
|
+
d += 'Z';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
counters.stages.path += performance.now() - t0;
|
|
190
|
+
counters.pathBytes += d.length;
|
|
191
|
+
const n = s.node('bars', 'path');
|
|
192
|
+
s.set(n, 'd', d);
|
|
193
|
+
s.set(n, 'fill', look.fill);
|
|
194
|
+
s.set(n, 'stroke', look.stroke);
|
|
195
|
+
s.set(n, 'stroke-width', look.strokeWidth);
|
|
196
|
+
s.set(n, 'stroke-linejoin', 'round');
|
|
197
|
+
s.set(n, 'opacity', look.opacity);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Bars as lines: each is a stem from the foot to the value, stroked rather than filled, in one path. The width is the
|
|
202
|
+
* stroke's, so it stays the same however many bars there are, which is what makes it the answer for dense data where
|
|
203
|
+
* filled bars would be slivers. The look is the line's (`seriesLine`), with the series' colour as its stroke; give the
|
|
204
|
+
* series a marker as well for a lollipop.
|
|
205
|
+
*/
|
|
206
|
+
export class BarLinePath extends BarRenderer {
|
|
207
|
+
look() {
|
|
208
|
+
const a = this.attrs;
|
|
209
|
+
return this.styleFor(['seriesLine', 'line'], { color: a.stroke, width: a.strokeWidth, opacity: a.opacity }, { color: this.requireParent().accentColor });
|
|
210
|
+
}
|
|
211
|
+
get swatch() { return this.look().color; }
|
|
212
|
+
outline() { return stemLines(this.requireParent(), this.attrs); }
|
|
213
|
+
/** Three stems of different heights, as wide as the stroke says. */
|
|
214
|
+
sample(s, parent, key, width, height) {
|
|
215
|
+
const look = this.look(), n = s.node(key, 'path', { parent });
|
|
216
|
+
let d = '';
|
|
217
|
+
for (const [at, up] of STEM_SAMPLE)
|
|
218
|
+
d += `M${f2(width * at)} ${f2(height)}V${f2(height * (1 - up))}`;
|
|
219
|
+
s.set(n, 'd', d);
|
|
220
|
+
s.set(n, 'fill', 'none');
|
|
221
|
+
s.set(n, 'stroke', look.color);
|
|
222
|
+
s.set(n, 'stroke-width', look.width);
|
|
223
|
+
s.set(n, 'stroke-linecap', this.attrs.cap ?? 'butt');
|
|
224
|
+
s.set(n, 'opacity', look.opacity);
|
|
225
|
+
s.set(n, 'stroke-dasharray', look.dash);
|
|
226
|
+
}
|
|
227
|
+
draw() {
|
|
228
|
+
const counters = this.counters, s = this.surface, a = this.attrs;
|
|
229
|
+
if (a.bind)
|
|
230
|
+
notImplemented('BarLinePath bindings');
|
|
231
|
+
const look = this.look();
|
|
232
|
+
const t0 = performance.now();
|
|
233
|
+
const stems = this.outline();
|
|
234
|
+
let d = '';
|
|
235
|
+
if (stems) {
|
|
236
|
+
const { data, count } = stems;
|
|
237
|
+
for (let i = 0, k = 0; i < count; i++, k += 4) {
|
|
238
|
+
const x = data[k], y = data[k + 1];
|
|
239
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
240
|
+
continue;
|
|
241
|
+
d += 'M' + f2(x) + ' ' + f2(y) + 'L' + f2(data[k + 2]) + ' ' + f2(data[k + 3]);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
counters.stages.path += performance.now() - t0;
|
|
245
|
+
counters.pathBytes += d.length;
|
|
246
|
+
const n = s.node('stems', 'path');
|
|
247
|
+
s.set(n, 'd', d);
|
|
248
|
+
s.set(n, 'fill', 'none');
|
|
249
|
+
s.set(n, 'stroke', look.color);
|
|
250
|
+
s.set(n, 'stroke-width', look.width);
|
|
251
|
+
s.set(n, 'stroke-linecap', a.cap ?? 'butt');
|
|
252
|
+
s.set(n, 'opacity', look.opacity);
|
|
253
|
+
s.set(n, 'stroke-dasharray', look.dash);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type BarOptions } from './bars.ts';
|
|
2
|
+
import type { HitMode } from './hit.ts';
|
|
3
|
+
import type { PlatformNode } from './platform.ts';
|
|
4
|
+
import { type PolygonSet, SeriesRenderer, type SeriesRendererAttributes, type Spread, type SpreadView } from './renderer.ts';
|
|
5
|
+
import type { Series } from './series.ts';
|
|
6
|
+
import type { Surface } from './surface.ts';
|
|
7
|
+
export interface BoxPathAttributes extends Omit<SeriesRendererAttributes, 'bind'>, Omit<BarOptions, 'baseline'> {
|
|
8
|
+
/** The channel of the lower whisker's end. Default 'min'. */
|
|
9
|
+
low?: string;
|
|
10
|
+
/** The channel of the first quartile (the foot of the box). Default 'q1'. */
|
|
11
|
+
q1?: string;
|
|
12
|
+
/** The channel of the median. Default: the series' own value, which is what a data source's `value` names. */
|
|
13
|
+
median?: string;
|
|
14
|
+
/** The channel of the third quartile (the top of the box). Default 'q3'. */
|
|
15
|
+
q3?: string;
|
|
16
|
+
/** The channel of the upper whisker's end. Default 'max'. */
|
|
17
|
+
high?: string;
|
|
18
|
+
/** Width of the cap on each whisker, as a fraction of the box's width; 0 for none. Default 0.5. */
|
|
19
|
+
capWidth?: number;
|
|
20
|
+
}
|
|
21
|
+
export declare class BoxPath extends SeriesRenderer<BoxPathAttributes> {
|
|
22
|
+
#private;
|
|
23
|
+
static attrTypes: {
|
|
24
|
+
fill: 1;
|
|
25
|
+
maxNodes: 2;
|
|
26
|
+
stroke: 1;
|
|
27
|
+
strokeWidth: 2;
|
|
28
|
+
id: 1;
|
|
29
|
+
meta: {
|
|
30
|
+
title: 1;
|
|
31
|
+
description: 1;
|
|
32
|
+
role: 1;
|
|
33
|
+
};
|
|
34
|
+
low: 1;
|
|
35
|
+
q1: 1;
|
|
36
|
+
median: 1;
|
|
37
|
+
q3: 1;
|
|
38
|
+
high: 1;
|
|
39
|
+
capWidth: 2;
|
|
40
|
+
width: 2;
|
|
41
|
+
};
|
|
42
|
+
private get names();
|
|
43
|
+
get channelNames(): readonly string[];
|
|
44
|
+
/** Half the width of a box, in the gaps between points, which the domain axis leaves room for. */
|
|
45
|
+
get spans(): number;
|
|
46
|
+
get hitMode(): HitMode;
|
|
47
|
+
spread(view: SpreadView): Spread | null;
|
|
48
|
+
limit(series: Series): Spread | null;
|
|
49
|
+
private box;
|
|
50
|
+
private line;
|
|
51
|
+
get swatch(): string;
|
|
52
|
+
sample(s: Surface, parent: PlatformNode, key: string, width: number, height: number): void;
|
|
53
|
+
/** Where everything goes, in plot pixels; made again only when the series has been projected again. */
|
|
54
|
+
private geometry;
|
|
55
|
+
shapes(): PolygonSet | null;
|
|
56
|
+
protected draw(): void;
|
|
57
|
+
}
|
package/dist/boxplot.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box and whisker: the five numbers that summarise a spread, one box for each point of a series.
|
|
3
|
+
*
|
|
4
|
+
* <DomainSeries>
|
|
5
|
+
* <ObjectDataSource source={rows} domain="group" value="median" channels={{ min: 'lo', q1: 'q1', q3: 'q3', max: 'hi' }} />
|
|
6
|
+
* <BoxPath />
|
|
7
|
+
* </DomainSeries>
|
|
8
|
+
*
|
|
9
|
+
* The box runs from the first to the third quartile with a line at the median, and whiskers go from it to the lowest and
|
|
10
|
+
* highest values. The median is the series' own value (what a source's `value` names), and the other four come from **channels** of
|
|
11
|
+
* the data called `min`, `q1`, `q3` and `max`, unless the `low`, `q1`, `q3` and `high` attributes name others (`median` can name a
|
|
12
|
+
* channel too), so numbers worked out elsewhere can be drawn as they are.
|
|
13
|
+
* `BoxStats` (stats.ts) is the other way to get them: a transform that works the five numbers out from the samples, with
|
|
14
|
+
* the points outside the whiskers as a second output to plot as markers.
|
|
15
|
+
*
|
|
16
|
+
* It is drawn in the plane's own terms like bars, so the domain on the vertical axis gives horizontal boxes and a polar plane
|
|
17
|
+
* gives wedges. A box is hit as a shape. The value axis reaches the whiskers; the width of a box is a band's own, else
|
|
18
|
+
* `width` (default 0.8) of the gap between points, and boxes in a `Stack` in 'group' mode sit side by side.
|
|
19
|
+
*/
|
|
20
|
+
/* oxlint-disable typescript/no-non-null-assertion -- numeric kernels over flat typed-array buffers (stride-addressed rows): every index is bounded by the row count, which `noUncheckedIndexedAccess` cannot see */
|
|
21
|
+
import { NUMBER, STRING } from './attrs.js';
|
|
22
|
+
import { layoutBars } from './bars.js';
|
|
23
|
+
import { f2 } from './geometry.js';
|
|
24
|
+
import { SeriesRenderer, paintBox } from './renderer.js';
|
|
25
|
+
const REACH = 2;
|
|
26
|
+
const MAX_ARC = 16;
|
|
27
|
+
const clamp = (v) => (v < -REACH ? -REACH : v > 1 + REACH ? 1 + REACH : v);
|
|
28
|
+
export class BoxPath extends SeriesRenderer {
|
|
29
|
+
static attrTypes = { ...SeriesRenderer.attrTypes, low: STRING, q1: STRING, median: STRING, q3: STRING, high: STRING, capWidth: NUMBER, width: NUMBER };
|
|
30
|
+
#geometry = null;
|
|
31
|
+
get names() {
|
|
32
|
+
const a = this.attrs;
|
|
33
|
+
return { low: a.low ?? 'min', q1: a.q1 ?? 'q1', median: a.median, q3: a.q3 ?? 'q3', high: a.high ?? 'max' };
|
|
34
|
+
}
|
|
35
|
+
get channelNames() { const n = this.names; return [...new Set([n.low, n.q1, ...(n.median ? [n.median] : []), n.q3, n.high])]; }
|
|
36
|
+
/** Half the width of a box, in the gaps between points, which the domain axis leaves room for. */
|
|
37
|
+
get spans() { return (this.attrs.width ?? 0.8) / 2; }
|
|
38
|
+
get hitMode() { return 'shape'; }
|
|
39
|
+
spread(view) {
|
|
40
|
+
const n = this.names, lo = view.channelColumn(n.low), hi = view.channelColumn(n.high);
|
|
41
|
+
let min = Infinity, max = -Infinity;
|
|
42
|
+
for (let i = 0; i < view.count; i++) {
|
|
43
|
+
const a = view.raw[i * view.stride + lo], b = view.raw[i * view.stride + hi];
|
|
44
|
+
if (Number.isFinite(a) && a < min)
|
|
45
|
+
min = a;
|
|
46
|
+
if (Number.isFinite(b) && b > max)
|
|
47
|
+
max = b;
|
|
48
|
+
}
|
|
49
|
+
return min <= max ? { second: { min, max } } : null;
|
|
50
|
+
}
|
|
51
|
+
limit(series) {
|
|
52
|
+
const n = this.names, lo = series.data.channelExtent(n.low), hi = series.data.channelExtent(n.high);
|
|
53
|
+
return lo && hi ? { second: { min: Math.min(lo.min, hi.min), max: Math.max(lo.max, hi.max) } } : null;
|
|
54
|
+
}
|
|
55
|
+
box() {
|
|
56
|
+
const a = this.attrs;
|
|
57
|
+
return this.styleFor(['seriesBox', 'shape'], { fill: a.fill, stroke: a.stroke, strokeWidth: a.strokeWidth, opacity: a.opacity }, { fill: this.requireParent().accentColor, stroke: this.requireParent().accentColor });
|
|
58
|
+
}
|
|
59
|
+
line() {
|
|
60
|
+
const a = this.attrs;
|
|
61
|
+
return this.styleFor(['boxLine', 'line'], { color: a.stroke, width: a.strokeWidth }, { color: this.requireParent().accentColor });
|
|
62
|
+
}
|
|
63
|
+
get swatch() { return this.box().fill; }
|
|
64
|
+
sample(s, parent, key, width, height) {
|
|
65
|
+
paintBox(s, parent, key, width, height, this.box());
|
|
66
|
+
const look = this.line(), m = s.node(`${key}/median`, 'path', { parent });
|
|
67
|
+
s.set(m, 'd', `M0 ${height / 2}H${width}`);
|
|
68
|
+
s.set(m, 'fill', 'none');
|
|
69
|
+
s.set(m, 'stroke', look.color);
|
|
70
|
+
s.set(m, 'stroke-width', look.width * 1.5);
|
|
71
|
+
}
|
|
72
|
+
/** Where everything goes, in plot pixels; made again only when the series has been projected again. */
|
|
73
|
+
geometry() {
|
|
74
|
+
const series = this.requireParent(), d = series.drawn, plane = d?.plane;
|
|
75
|
+
if (!d || !plane)
|
|
76
|
+
return null;
|
|
77
|
+
if (this.#geometry && this.#geometry.projection === series.projection)
|
|
78
|
+
return this.#geometry;
|
|
79
|
+
const lay = layoutBars(series, this.attrs);
|
|
80
|
+
if (!lay)
|
|
81
|
+
return null;
|
|
82
|
+
const names = this.names, n = lay.n, rs = d.stride, base = rs - d.channels.length;
|
|
83
|
+
const col = (name) => base + d.channels.indexOf(name);
|
|
84
|
+
const cols = [col(names.low), col(names.q1), names.median ? col(names.median) : d.secondColumn, col(names.q3), col(names.high)];
|
|
85
|
+
// The five values of each row on the value axis, as fractions.
|
|
86
|
+
const v = new Float64Array(n * 5);
|
|
87
|
+
for (let i = 0; i < n; i++)
|
|
88
|
+
for (let k = 0; k < 5; k++)
|
|
89
|
+
v[i * 5 + k] = d.raw[i * rs + cols[k]];
|
|
90
|
+
series.secondAxis.normalize(v, n * 5, 1, 0);
|
|
91
|
+
const steps = plane.curved ? Math.min(MAX_ARC, Math.max(2, Math.ceil(lay.slot * 64))) : 1, per = 2 * (steps + 1), median = steps + 1;
|
|
92
|
+
const cap = Math.max(0, this.attrs.capWidth ?? 0.5);
|
|
93
|
+
const total = n * (per + median + 8), out = new Float64Array(total * 2);
|
|
94
|
+
const boxAt = 0, medianAt = n * per, whiskerAt = medianAt + n * median, capAt = whiskerAt + n * 4;
|
|
95
|
+
for (let i = 0; i < n; i++) {
|
|
96
|
+
const x = lay.norm[3 * i] + lay.shift, x0 = x - lay.slot / 2;
|
|
97
|
+
const lo = v[i * 5], q1 = v[i * 5 + 1], med = v[i * 5 + 2], q3 = v[i * 5 + 3], hi = v[i * 5 + 4];
|
|
98
|
+
const bad = !Number.isFinite(x) || !Number.isFinite(lo) || !Number.isFinite(q1) || !Number.isFinite(med) || !Number.isFinite(q3) || !Number.isFinite(hi);
|
|
99
|
+
const put = (at, f, s) => { out[2 * at] = f; out[2 * at + 1] = bad ? NaN : Number.isFinite(f) ? clamp(s) : NaN; };
|
|
100
|
+
let k = boxAt + i * per;
|
|
101
|
+
for (let j = 0; j <= steps; j++)
|
|
102
|
+
put(k++, x0 + (lay.slot * j) / steps, q1);
|
|
103
|
+
for (let j = steps; j >= 0; j--)
|
|
104
|
+
put(k++, x0 + (lay.slot * j) / steps, q3);
|
|
105
|
+
k = medianAt + i * median;
|
|
106
|
+
for (let j = 0; j <= steps; j++)
|
|
107
|
+
put(k++, x0 + (lay.slot * j) / steps, med);
|
|
108
|
+
k = whiskerAt + i * 4;
|
|
109
|
+
put(k++, x, q3);
|
|
110
|
+
put(k++, x, hi);
|
|
111
|
+
put(k++, x, q1);
|
|
112
|
+
put(k, x, lo);
|
|
113
|
+
k = capAt + i * 4;
|
|
114
|
+
const c = (lay.slot * cap) / 2;
|
|
115
|
+
put(k++, x - c, hi);
|
|
116
|
+
put(k++, x + c, hi);
|
|
117
|
+
put(k++, x - c, lo);
|
|
118
|
+
put(k, x + c, lo);
|
|
119
|
+
}
|
|
120
|
+
// `put` clamps the value but the domain half of an outline is exact; NaN is already in the second coordinate of a bad row, so mirror it.
|
|
121
|
+
for (let i = 0; i < total; i++)
|
|
122
|
+
if (!Number.isFinite(out[2 * i + 1]))
|
|
123
|
+
out[2 * i] = NaN;
|
|
124
|
+
plane.place({ data: out, stride: 2, count: total }, series.firstAxis, series.secondAxis);
|
|
125
|
+
return (this.#geometry = { projection: series.projection, n, per, steps, data: out });
|
|
126
|
+
}
|
|
127
|
+
shapes() {
|
|
128
|
+
const g = this.geometry();
|
|
129
|
+
return g ? { data: g.data.subarray(0, g.n * g.per * 2), perShape: g.per, count: g.n } : null;
|
|
130
|
+
}
|
|
131
|
+
draw() {
|
|
132
|
+
const s = this.surface, g = this.geometry(), box = this.box(), line = this.line();
|
|
133
|
+
let boxes = '', medians = '', whiskers = '';
|
|
134
|
+
if (g) {
|
|
135
|
+
const path = (from, count, close) => {
|
|
136
|
+
let d = '';
|
|
137
|
+
for (let j = 0; j < count; j++) {
|
|
138
|
+
const x = g.data[2 * (from + j)], y = g.data[2 * (from + j) + 1];
|
|
139
|
+
if (!(Number.isFinite(x) && Number.isFinite(y)))
|
|
140
|
+
return '';
|
|
141
|
+
d += `${j === 0 ? 'M' : 'L'}${f2(x)} ${f2(y)}`;
|
|
142
|
+
}
|
|
143
|
+
return d + (close ? 'Z' : '');
|
|
144
|
+
};
|
|
145
|
+
const { n, per, steps } = g, median = steps + 1, medianAt = n * per, whiskerAt = medianAt + n * median, capAt = whiskerAt + n * 4;
|
|
146
|
+
for (let i = 0; i < n; i++) {
|
|
147
|
+
const b = path(i * per, per, true);
|
|
148
|
+
if (!b)
|
|
149
|
+
continue;
|
|
150
|
+
boxes += b;
|
|
151
|
+
medians += path(medianAt + i * median, median, false);
|
|
152
|
+
whiskers += path(whiskerAt + i * 4, 2, false) + path(whiskerAt + i * 4 + 2, 2, false);
|
|
153
|
+
if ((this.attrs.capWidth ?? 0.5) > 0)
|
|
154
|
+
whiskers += path(capAt + i * 4, 2, false) + path(capAt + i * 4 + 2, 2, false);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const whisker = s.node('whiskers', 'path');
|
|
158
|
+
s.set(whisker, 'd', whiskers);
|
|
159
|
+
s.set(whisker, 'fill', 'none');
|
|
160
|
+
s.set(whisker, 'stroke', line.color);
|
|
161
|
+
s.set(whisker, 'stroke-width', line.width);
|
|
162
|
+
s.set(whisker, 'opacity', line.opacity);
|
|
163
|
+
const b = s.node('boxes', 'path');
|
|
164
|
+
s.set(b, 'd', boxes);
|
|
165
|
+
s.set(b, 'fill', box.fill);
|
|
166
|
+
s.set(b, 'fill-opacity', box.opacity);
|
|
167
|
+
s.set(b, 'stroke', box.stroke);
|
|
168
|
+
s.set(b, 'stroke-width', box.strokeWidth);
|
|
169
|
+
s.set(b, 'stroke-linejoin', 'round');
|
|
170
|
+
const m = s.node('medians', 'path');
|
|
171
|
+
s.set(m, 'd', medians);
|
|
172
|
+
s.set(m, 'fill', 'none');
|
|
173
|
+
s.set(m, 'stroke', line.color);
|
|
174
|
+
s.set(m, 'stroke-width', line.width * 1.5);
|
|
175
|
+
s.set(m, 'opacity', line.opacity);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Axis } from './axis.ts';
|
|
2
|
+
import { type BaseAttributes, ChartElement, type ElementRef } from './element.ts';
|
|
3
|
+
import { inputOf } from './input.ts';
|
|
4
|
+
import { type ChartEvent } from './events.ts';
|
|
5
|
+
import type { Plane } from './plane.ts';
|
|
6
|
+
export interface BoxZoomAttributes extends BaseAttributes {
|
|
7
|
+
/** The axes it may zoom, as elements or ids. Default: all of the plane's that can be followed along (not the rim of a polar plane). */
|
|
8
|
+
axes?: readonly ElementRef<Axis>[];
|
|
9
|
+
/** When a drag makes a box: with Shift held (the default), so a plain drag can pan, or always. */
|
|
10
|
+
activate?: 'shift' | 'always';
|
|
11
|
+
/** Which axes the box zooms. Default 'xy'. */
|
|
12
|
+
mode?: 'xy' | 'x' | 'y' | 'auto';
|
|
13
|
+
/** Smallest side of a box, in pixels, along an axis it zooms; below that the box does nothing. Default 8. */
|
|
14
|
+
minSize?: number;
|
|
15
|
+
/** Called after a zoom, with the box in plot pixels. */
|
|
16
|
+
onZoom?: (box: {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
}) => void;
|
|
22
|
+
}
|
|
23
|
+
export declare class BoxZoom extends ChartElement<Plane, [], BoxZoomAttributes> {
|
|
24
|
+
#private;
|
|
25
|
+
static takesInput: typeof inputOf;
|
|
26
|
+
static attrTypes: {
|
|
27
|
+
minSize: 2;
|
|
28
|
+
onZoom: 0;
|
|
29
|
+
id: 1;
|
|
30
|
+
meta: {
|
|
31
|
+
title: 1;
|
|
32
|
+
description: 1;
|
|
33
|
+
role: 1;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
/** True while a box is being drawn. */
|
|
37
|
+
get selecting(): boolean;
|
|
38
|
+
onEvent(ev: ChartEvent): void;
|
|
39
|
+
}
|
package/dist/boxzoom.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box zoom: drag a rectangle on the plot and the axes zoom to it.
|
|
3
|
+
*
|
|
4
|
+
* new CartesianPlane({}, x, y, series, new BoxZoom(), new PanZoom())
|
|
5
|
+
*
|
|
6
|
+
* It is a plane child like `PanZoom`, and the two share the plane: by default (`activate: 'shift'`) the box is drawn with
|
|
7
|
+
* Shift held and a plain drag still pans. With `activate: 'always'` every drag is a box, which suits a chart whose `PanZoom`
|
|
8
|
+
* has `pan: false`. A box takes a press, so it has to come **before** `PanZoom` in the plane (as a `PointerListener` does).
|
|
9
|
+
*
|
|
10
|
+
* `mode` is which axes the box zooms: both, only the horizontal ones, only the vertical ones, or 'auto', which takes the one
|
|
11
|
+
* direction the box is much longer in (a wide, flat box zooms x alone) and both otherwise. It sets the same view ranges
|
|
12
|
+
* `PanZoom` does, so a pinch, a reset and `setViewRange(null)` all undo it. A box smaller than `minSize` pixels is not a box
|
|
13
|
+
* and does nothing, which is how to take one back: drag it small again before letting go. It looks as `selection` says (a shape).
|
|
14
|
+
*
|
|
15
|
+
* A touch makes no box: two fingers pinch. A rim (polar or radar plane) cannot be boxed.
|
|
16
|
+
*/
|
|
17
|
+
import { AUTO, NUMBER } from './attrs.js';
|
|
18
|
+
import { Axis } from './axis.js';
|
|
19
|
+
import { ChartElement, fail, resolveRef } from './element.js';
|
|
20
|
+
import { inputOf } from './input.js';
|
|
21
|
+
import { EVENT_ATTACH, EVENT_PAINT, EVENT_POINTER } from './events.js';
|
|
22
|
+
import { f2 } from './geometry.js';
|
|
23
|
+
export class BoxZoom extends ChartElement {
|
|
24
|
+
static takesInput = inputOf;
|
|
25
|
+
static attrTypes = { ...ChartElement.attrTypes, minSize: NUMBER, onZoom: AUTO };
|
|
26
|
+
#from = null;
|
|
27
|
+
#to = { x: 0, y: 0 };
|
|
28
|
+
/** True while a box is being drawn. */
|
|
29
|
+
get selecting() { return this.#from !== null; }
|
|
30
|
+
onEvent(ev) {
|
|
31
|
+
switch (ev.type) {
|
|
32
|
+
case EVENT_ATTACH: {
|
|
33
|
+
const plane = this.requireParent();
|
|
34
|
+
for (const sibling of plane) {
|
|
35
|
+
if (sibling === this)
|
|
36
|
+
break;
|
|
37
|
+
if (sibling.claimsPress)
|
|
38
|
+
fail('add a BoxZoom before PanZoom in a plane: the box has to see a press first to take it');
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
case EVENT_PAINT:
|
|
43
|
+
this.#paint();
|
|
44
|
+
return;
|
|
45
|
+
case EVENT_POINTER:
|
|
46
|
+
this.#pointer(ev);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
#pointer(ev) {
|
|
51
|
+
if (ev.pointerType === 'touch' || ev.kind === 'wheel' || ev.kind === 'pinch')
|
|
52
|
+
return;
|
|
53
|
+
const plane = this.requireParent(), a = this.attrs, r = plane.rect;
|
|
54
|
+
const clampX = (x) => Math.min(r.width, Math.max(0, x)), clampY = (y) => Math.min(r.height, Math.max(0, y));
|
|
55
|
+
switch (ev.kind) {
|
|
56
|
+
case 'down':
|
|
57
|
+
if (ev.handled || ev.outside || !(ev.buttons & 1) || !plane.covers(ev.x, ev.y))
|
|
58
|
+
return;
|
|
59
|
+
if ((a.activate ?? 'shift') === 'shift' && !ev.shift)
|
|
60
|
+
return;
|
|
61
|
+
this.#from = this.#to = { x: ev.x, y: ev.y };
|
|
62
|
+
this.chart?.platform?.setCursor?.('crosshair');
|
|
63
|
+
this.invalidate();
|
|
64
|
+
ev.handled = true;
|
|
65
|
+
return;
|
|
66
|
+
case 'move':
|
|
67
|
+
if (!this.#from)
|
|
68
|
+
return;
|
|
69
|
+
if (!(ev.buttons & 1)) {
|
|
70
|
+
this.#cancel();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.#to = { x: clampX(ev.x), y: clampY(ev.y) };
|
|
74
|
+
this.invalidate();
|
|
75
|
+
ev.handled = true;
|
|
76
|
+
return;
|
|
77
|
+
case 'up': {
|
|
78
|
+
const from = this.#from;
|
|
79
|
+
if (!from)
|
|
80
|
+
return;
|
|
81
|
+
const to = { x: clampX(ev.x), y: clampY(ev.y) };
|
|
82
|
+
this.#cancel();
|
|
83
|
+
this.#zoom(from, to);
|
|
84
|
+
ev.handled = true;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
case 'leave':
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
#cancel() {
|
|
92
|
+
this.#from = null;
|
|
93
|
+
this.chart?.platform?.setCursor?.('');
|
|
94
|
+
this.invalidate();
|
|
95
|
+
}
|
|
96
|
+
/** The axes this may zoom. */
|
|
97
|
+
#axes() {
|
|
98
|
+
const a = this.attrs;
|
|
99
|
+
const axes = a.axes ? a.axes.map((ref) => resolveRef(ref, this, Axis)) : [...this.requireParent().axes];
|
|
100
|
+
return axes.filter((axis) => axis.ruler.fractionAt);
|
|
101
|
+
}
|
|
102
|
+
#zoom(from, to) {
|
|
103
|
+
const w = Math.abs(to.x - from.x), h = Math.abs(to.y - from.y), min = this.attrs.minSize ?? 8, mode = this.attrs.mode ?? 'xy';
|
|
104
|
+
// 'auto': a box much longer one way than the other is a zoom along that way only.
|
|
105
|
+
const only = mode === 'auto' ? (w > 3 * h ? 'x' : h > 3 * w ? 'y' : 'xy') : mode;
|
|
106
|
+
const alongX = only !== 'y', alongY = only !== 'x';
|
|
107
|
+
if ((alongX && w < min) || (alongY && h < min))
|
|
108
|
+
return;
|
|
109
|
+
for (const axis of this.#axes()) {
|
|
110
|
+
const ruler = axis.ruler, at = ruler.fractionAt;
|
|
111
|
+
if (!at || (ruler.horizontal ? !alongX : !alongY))
|
|
112
|
+
continue; // `#axes` keeps only rulers that have one
|
|
113
|
+
const v0 = axis.denormalize(at.call(ruler, from.x, from.y)), v1 = axis.denormalize(at.call(ruler, to.x, to.y));
|
|
114
|
+
if (v0 !== v0 || v1 !== v1 || v0 === v1)
|
|
115
|
+
continue;
|
|
116
|
+
axis.setViewRange({ min: Math.min(v0, v1), max: Math.max(v0, v1) });
|
|
117
|
+
}
|
|
118
|
+
this.attrs.onZoom?.({ x: Math.min(from.x, to.x), y: Math.min(from.y, to.y), width: w, height: h });
|
|
119
|
+
}
|
|
120
|
+
#paint() {
|
|
121
|
+
const s = this.surface, from = this.#from;
|
|
122
|
+
if (!from) {
|
|
123
|
+
s.remove('box');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const look = this.styleFor(['selection', 'shape']), to = this.#to;
|
|
127
|
+
const node = s.node('box', 'rect', { above: true });
|
|
128
|
+
s.set(node, 'x', f2(Math.min(from.x, to.x)));
|
|
129
|
+
s.set(node, 'y', f2(Math.min(from.y, to.y)));
|
|
130
|
+
s.set(node, 'width', f2(Math.abs(to.x - from.x)));
|
|
131
|
+
s.set(node, 'height', f2(Math.abs(to.y - from.y)));
|
|
132
|
+
s.set(node, 'fill', look.fill);
|
|
133
|
+
s.set(node, 'stroke', look.stroke);
|
|
134
|
+
s.set(node, 'stroke-width', look.strokeWidth);
|
|
135
|
+
s.set(node, 'opacity', look.opacity);
|
|
136
|
+
s.set(node, 'pointer-events', 'none');
|
|
137
|
+
}
|
|
138
|
+
}
|