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/chart.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chart: the root element. Owns the platform, schedules flushes and routes input to planes.
|
|
3
|
+
*
|
|
4
|
+
* One flush, in order (the layout pass the rest of the library is built around):
|
|
5
|
+
* 1. Furniture (keys, titles: anything that is not a plane) is measured; those that name a `side` take a strip there.
|
|
6
|
+
* 2. Planes are arranged into what is left. Arranging a plane is where it fits its axes and series, negotiates the
|
|
7
|
+
* plot rectangle with its axes' sizes, and projects the data (see Plane.fit and CartesianPlane).
|
|
8
|
+
* 3. Furniture is arranged into its strip, now that the planes (and the mapped scales they fed) are settled.
|
|
9
|
+
* 4. PAINT is broadcast and every element draws through its Surface.
|
|
10
|
+
* Each flush re-runs all of it; Surface's write cache and the series' fill cache are what make that cheap.
|
|
11
|
+
* Mapped dimensions (ColorAxis/SizeAxis) are children of the chart rather than a plane, so they use `flushCount` to
|
|
12
|
+
* recognise a new pass and reset what they gathered from the planes.
|
|
13
|
+
*/
|
|
14
|
+
import { AUTO, STRING } from './attrs.js';
|
|
15
|
+
import { writeAria } from './aria.js';
|
|
16
|
+
import { ChartElement, fail } from './element.js';
|
|
17
|
+
import { EVENT_PAINT } from './events.js';
|
|
18
|
+
import {} from './geometry.js';
|
|
19
|
+
import { Plane } from './plane.js';
|
|
20
|
+
import { StringPlatform, estimateTextWidth } from './platform.js';
|
|
21
|
+
import { newCounters } from './surface.js';
|
|
22
|
+
/** Space between the plots and the furniture at their edges. */
|
|
23
|
+
const FURNITURE_GAP = 8;
|
|
24
|
+
/** Space between stacked bands, such as a title and its subtitle. */
|
|
25
|
+
const BAND_GAP = 2;
|
|
26
|
+
// oxlint-disable-next-line typescript/consistent-type-assertions -- a type guard: probing an element for an optional `describe`
|
|
27
|
+
const isDescriber = (e) => typeof e.describe === 'function';
|
|
28
|
+
/** The root: the only element that knows about the platform lifecycle (attach, flush, detach). */
|
|
29
|
+
export class Chart extends ChartElement {
|
|
30
|
+
static attrTypes = { ...ChartElement.attrTypes, measureText: AUTO, onFlush: AUTO, locale: STRING };
|
|
31
|
+
get chart() { return this; }
|
|
32
|
+
#platform = null;
|
|
33
|
+
#lastFlush = null;
|
|
34
|
+
#needsFlush = false;
|
|
35
|
+
#flushCount = 0;
|
|
36
|
+
counts = newCounters();
|
|
37
|
+
unschedule = null;
|
|
38
|
+
subscriptions = [];
|
|
39
|
+
/** The input host, once something in the chart has asked for it (see `inputOf`): null on a chart nothing listens to. */
|
|
40
|
+
input = null;
|
|
41
|
+
get platform() { return this.#platform; }
|
|
42
|
+
get lastFlush() { return this.#lastFlush; }
|
|
43
|
+
/** How many flushes have begun. Elements that belong to the chart rather than a plane use it to tell a new pass from the same one. */
|
|
44
|
+
get flushCount() { return this.#flushCount; }
|
|
45
|
+
/** True from any invalidation until the next flush. */
|
|
46
|
+
get needsFlush() { return this.#needsFlush; }
|
|
47
|
+
/** Construction has no side effects; this makes the chart live. */
|
|
48
|
+
attach(platform) {
|
|
49
|
+
if (this.platform)
|
|
50
|
+
fail('chart is already attached');
|
|
51
|
+
this.#platform = platform;
|
|
52
|
+
this.mount({ platform, counters: this.counts });
|
|
53
|
+
const resize = platform.onResize?.(() => this.invalidate());
|
|
54
|
+
if (resize)
|
|
55
|
+
this.subscriptions.push(resize);
|
|
56
|
+
this.invalidate();
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
/** Undo `attach`: unsubscribe, take the nodes out of the platform. The tree is kept, so the chart can be attached again. */
|
|
60
|
+
detach() {
|
|
61
|
+
if (!this.platform)
|
|
62
|
+
return this;
|
|
63
|
+
this.unschedule?.();
|
|
64
|
+
this.unschedule = null;
|
|
65
|
+
for (const u of this.subscriptions.splice(0))
|
|
66
|
+
u();
|
|
67
|
+
this.input?.stop();
|
|
68
|
+
this.input = null;
|
|
69
|
+
this.unmount(true);
|
|
70
|
+
this.#platform = null;
|
|
71
|
+
this.#needsFlush = false;
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
/** The request has reached the root: note it, and on a scheduling platform arrange a flush. */
|
|
75
|
+
invalidate() {
|
|
76
|
+
super.invalidate();
|
|
77
|
+
const p = this.platform;
|
|
78
|
+
if (!p)
|
|
79
|
+
return;
|
|
80
|
+
this.#needsFlush = true;
|
|
81
|
+
if (p.schedule && !this.unschedule) {
|
|
82
|
+
this.unschedule = p.schedule(() => { this.unschedule = null; this.flush(); });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Put every axis of every plane back to the range its data asks for. */
|
|
86
|
+
resetViews() { for (const c of this)
|
|
87
|
+
if (c instanceof Plane)
|
|
88
|
+
c.resetViews(); }
|
|
89
|
+
/** Send an event through the tree from `root` (default the chart). For the input host, which lives outside this class. */
|
|
90
|
+
deliver(init, root = this) { return this.broadcast(init, root); }
|
|
91
|
+
/** Preference order: the chart's own, the platform's (real fonts), then a deliberately wide estimate. */
|
|
92
|
+
get measureText() {
|
|
93
|
+
return this.attrs.measureText ?? this.platform?.measureText ?? estimateTextWidth;
|
|
94
|
+
}
|
|
95
|
+
/** Run layout and paint now. The only way to render on a platform with no `schedule`. */
|
|
96
|
+
flush() {
|
|
97
|
+
const platform = this.platform;
|
|
98
|
+
if (!platform)
|
|
99
|
+
fail('chart is not attached');
|
|
100
|
+
const t0 = performance.now();
|
|
101
|
+
this.#flushCount++;
|
|
102
|
+
const c = this.counts;
|
|
103
|
+
// Reset in place: every Surface holds this object, so replacing it would orphan them.
|
|
104
|
+
c.writes = c.skipped = c.filled = c.cached = c.projected = c.pointsIn = c.pointsDrawn = c.pathBytes = 0;
|
|
105
|
+
c.names.clear();
|
|
106
|
+
// oxlint-disable-next-line typescript/consistent-type-assertions -- `Object.keys` is `string[]`; these are the keys of `stages`
|
|
107
|
+
for (const k of Object.keys(c.stages))
|
|
108
|
+
c.stages[k] = 0;
|
|
109
|
+
const a = this.attrs, surface = this.surface;
|
|
110
|
+
const size = platform.size();
|
|
111
|
+
const width = a.width ?? size.width, height = a.height ?? size.height;
|
|
112
|
+
const root = platform.root;
|
|
113
|
+
surface.set(root, 'width', width);
|
|
114
|
+
surface.set(root, 'height', height);
|
|
115
|
+
surface.set(root, 'viewBox', `0 0 ${width} ${height}`);
|
|
116
|
+
surface.set(root, 'font-family', this.styleFor(['text']).family);
|
|
117
|
+
// The background is optional, so it is not a root: nearest `style` first, then the theme.
|
|
118
|
+
const background = a.style?.chart?.background ?? a.theme.chart?.background;
|
|
119
|
+
if (background) {
|
|
120
|
+
const bg = surface.node('background', 'rect');
|
|
121
|
+
surface.set(bg, 'width', width);
|
|
122
|
+
surface.set(bg, 'height', height);
|
|
123
|
+
surface.set(bg, 'fill', background);
|
|
124
|
+
}
|
|
125
|
+
else
|
|
126
|
+
surface.remove('background');
|
|
127
|
+
// Content area: the drawing minus the margin. Never below 1px so downstream divisions stay finite.
|
|
128
|
+
const pad = typeof a.padding === 'number'
|
|
129
|
+
? { top: a.padding, right: a.padding, bottom: a.padding, left: a.padding }
|
|
130
|
+
: { top: 10, right: 10, bottom: 10, left: 10, ...a.padding };
|
|
131
|
+
const content = {
|
|
132
|
+
x: pad.left, y: pad.top,
|
|
133
|
+
width: Math.max(1, width - pad.left - pad.right),
|
|
134
|
+
height: Math.max(1, height - pad.top - pad.bottom),
|
|
135
|
+
};
|
|
136
|
+
// Everything that is not a plane and asks for a side takes room along that edge, sized by what it asked for, and the
|
|
137
|
+
// planes get what is left. Bands (titles, captions) span the edge and stack outwards from it, and come first; the
|
|
138
|
+
// rest (keys) take a strip inside the bands, where several on one side stack along it.
|
|
139
|
+
const furniture = [];
|
|
140
|
+
const bands = [];
|
|
141
|
+
const bandSize = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
142
|
+
const strip = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
143
|
+
for (const child of this) {
|
|
144
|
+
if (child instanceof Plane || child.attrs.visible === false)
|
|
145
|
+
continue;
|
|
146
|
+
const request = this.measureChild(child, { width: content.width, height: content.height });
|
|
147
|
+
const side = request.side;
|
|
148
|
+
if (!side)
|
|
149
|
+
continue;
|
|
150
|
+
if (request.band) {
|
|
151
|
+
bands.push({ child, request, side });
|
|
152
|
+
bandSize[side] += request.min.height + (bandSize[side] ? BAND_GAP : 0);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
furniture.push({ child, request, side });
|
|
156
|
+
const across = side === 'left' || side === 'right' ? request.min.width : request.min.height;
|
|
157
|
+
strip[side] = Math.max(strip[side], across);
|
|
158
|
+
}
|
|
159
|
+
// What the bands leave. Everything below is laid out inside it.
|
|
160
|
+
const bandGap = (side) => (bandSize[side] ? FURNITURE_GAP : 0);
|
|
161
|
+
const inner = {
|
|
162
|
+
x: content.x,
|
|
163
|
+
y: content.y + bandSize.top + bandGap('top'),
|
|
164
|
+
width: content.width,
|
|
165
|
+
height: Math.max(1, content.height - bandSize.top - bandSize.bottom - bandGap('top') - bandGap('bottom')),
|
|
166
|
+
};
|
|
167
|
+
const gap = (side) => (strip[side] ? FURNITURE_GAP : 0);
|
|
168
|
+
const plotArea = {
|
|
169
|
+
x: inner.x + strip.left + gap('left'),
|
|
170
|
+
y: inner.y + strip.top + gap('top'),
|
|
171
|
+
width: Math.max(1, inner.width - strip.left - strip.right - gap('left') - gap('right')),
|
|
172
|
+
height: Math.max(1, inner.height - strip.top - strip.bottom - gap('top') - gap('bottom')),
|
|
173
|
+
};
|
|
174
|
+
const edge = { top: content.y, bottom: content.y + content.height };
|
|
175
|
+
for (const { child, request, side } of bands) {
|
|
176
|
+
const height = request.min.height;
|
|
177
|
+
if (side === 'top') {
|
|
178
|
+
this.arrangeChild(child, { x: content.x, y: edge.top, width: content.width, height });
|
|
179
|
+
edge.top += height + BAND_GAP;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
edge.bottom -= height;
|
|
183
|
+
this.arrangeChild(child, { x: content.x, y: edge.bottom, width: content.width, height });
|
|
184
|
+
edge.bottom -= BAND_GAP;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
for (const child of this)
|
|
188
|
+
if (child instanceof Plane)
|
|
189
|
+
this.arrangeChild(child, plotArea);
|
|
190
|
+
const along = { top: inner.x, bottom: inner.x, left: inner.y, right: inner.y };
|
|
191
|
+
for (const { child, request, side } of furniture) {
|
|
192
|
+
const { width, height } = request.min;
|
|
193
|
+
const vertical = side === 'left' || side === 'right';
|
|
194
|
+
const rect = vertical
|
|
195
|
+
? { x: side === 'left' ? inner.x : inner.x + inner.width - strip.right, y: along[side], width: strip[side], height }
|
|
196
|
+
: { x: along[side], y: side === 'top' ? inner.y : inner.y + inner.height - strip.bottom, width, height: strip[side] };
|
|
197
|
+
this.arrangeChild(child, rect);
|
|
198
|
+
along[side] += (vertical ? height : width) + FURNITURE_GAP;
|
|
199
|
+
}
|
|
200
|
+
// What a screen reader is told of the chart as a whole: `meta` first, then what a describer child makes of it, which needs the layout to have settled.
|
|
201
|
+
let said;
|
|
202
|
+
for (const child of this)
|
|
203
|
+
if (isDescriber(child) && child.attrs.visible !== false) {
|
|
204
|
+
said = child.describe();
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
writeAria(surface, root, a.meta, { role: 'group', ...(said && { description: said.description }) });
|
|
208
|
+
if (said?.roleDescription !== undefined && !a.meta?.aria?.['aria-roledescription'] && !a.meta?.aria?.['roledescription']) {
|
|
209
|
+
surface.set(root, 'aria-roledescription', said.roleDescription);
|
|
210
|
+
}
|
|
211
|
+
this.broadcast({ type: EVENT_PAINT });
|
|
212
|
+
this.#needsFlush = false;
|
|
213
|
+
const stats = {
|
|
214
|
+
total: performance.now() - t0,
|
|
215
|
+
stages: { ...c.stages },
|
|
216
|
+
filled: c.filled,
|
|
217
|
+
fillsCached: c.cached,
|
|
218
|
+
projected: c.projected,
|
|
219
|
+
attributeWrites: c.writes,
|
|
220
|
+
attributeWritesSkipped: c.skipped,
|
|
221
|
+
attributesWritten: [...c.names],
|
|
222
|
+
pointsIn: c.pointsIn,
|
|
223
|
+
pointsDrawn: c.pointsDrawn,
|
|
224
|
+
pathBytes: c.pathBytes,
|
|
225
|
+
};
|
|
226
|
+
this.#lastFlush = stats;
|
|
227
|
+
a.onFlush?.(stats);
|
|
228
|
+
return stats;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** FNV-1a over the UTF-16 units of a string, as eight hex digits. */
|
|
232
|
+
function fnv(text) {
|
|
233
|
+
let h = 0x811c9dc5;
|
|
234
|
+
for (let i = 0; i < text.length; i++)
|
|
235
|
+
h = Math.imul(h ^ text.charCodeAt(i), 0x01000193);
|
|
236
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
237
|
+
}
|
|
238
|
+
/** Rename every id in an SVG to `${prefix}-${n}`, n by first appearance, so the text does not depend on how many charts were drawn before. */
|
|
239
|
+
function scopeIds(svg, prefix) {
|
|
240
|
+
// oxlint-disable-next-line typescript/no-non-null-assertion -- in range: the index comes from a loop bound or a length checked in the same function
|
|
241
|
+
const ids = [...new Set([...svg.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]))];
|
|
242
|
+
if (ids.length === 0)
|
|
243
|
+
return svg;
|
|
244
|
+
const number = new Map(ids.map((id, i) => [id, i + 1]));
|
|
245
|
+
const swap = (text, head) => text.replace(/(\sid="|url\(#)([^")]+)/g, (whole, lead, id) => number.has(id) ? `${lead}${head}${number.get(id)}` : whole);
|
|
246
|
+
return swap(svg, `${prefix ?? `ink${fnv(swap(svg, ''))}`}-`);
|
|
247
|
+
}
|
|
248
|
+
/** One-shot export: attach a StringPlatform, flush, detach. */
|
|
249
|
+
export function toSvgString(chart, options) {
|
|
250
|
+
const a = chart.attrs;
|
|
251
|
+
const platform = new StringPlatform({ width: options?.width ?? a.width ?? 640, height: options?.height ?? a.height ?? 400 });
|
|
252
|
+
// The chart's own width and height attributes beat the platform's size (see `layout`), so a size given here only matters for what they leave out.
|
|
253
|
+
chart.attach(platform).flush();
|
|
254
|
+
const svg = platform.toString();
|
|
255
|
+
chart.detach();
|
|
256
|
+
return scopeIds(svg, options?.idPrefix);
|
|
257
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type BaseAttributes, ChartElement } from './element.ts';
|
|
2
|
+
import { type ChartEvent } from './events.ts';
|
|
3
|
+
import type { Cursor, CursorChild } from './cursor.ts';
|
|
4
|
+
export interface CrosshairAttributes extends BaseAttributes {
|
|
5
|
+
/** Which guide: the line of constant first coordinate (across a line chart: vertical), of the second, or both. Default 'both'. */
|
|
6
|
+
lines?: 'first' | 'second' | 'both';
|
|
7
|
+
/** A dot in the series' colour on each reading under the cursor. Default false. */
|
|
8
|
+
dots?: boolean;
|
|
9
|
+
/** Radius of those dots, in pixels. Default 4. */
|
|
10
|
+
dotSize?: number;
|
|
11
|
+
/** Put the lines through the first reading (a point) rather than through the cursor itself. Default false. */
|
|
12
|
+
snap?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare class Crosshair extends ChartElement<Cursor, [], CrosshairAttributes> implements CursorChild {
|
|
15
|
+
#private;
|
|
16
|
+
static attrTypes: {
|
|
17
|
+
dotSize: 2;
|
|
18
|
+
id: 1;
|
|
19
|
+
meta: {
|
|
20
|
+
title: 1;
|
|
21
|
+
description: 1;
|
|
22
|
+
role: 1;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
/** At the pointer's own place unless it is snapped to a reading. */
|
|
26
|
+
get follows(): boolean;
|
|
27
|
+
onEvent(ev: ChartEvent): void;
|
|
28
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The crosshair: guide lines through the cursor, and optionally a dot on each reading.
|
|
3
|
+
*
|
|
4
|
+
* new Cursor({}, new Crosshair()) // both lines, following the pointer
|
|
5
|
+
* new Cursor({ mode: 'shared' }, new Crosshair({ lines: 'first', dots: true }), new ChartTooltip())
|
|
6
|
+
*
|
|
7
|
+
* The lines are lines of constant value, as the plane draws its own gridlines: a vertical and a horizontal line on a
|
|
8
|
+
* rectangular plane, but a spoke (the angle) and a circle (the radius) on a polar one, and a spoke and a polygon on a radar.
|
|
9
|
+
* `lines` chooses which. They are clipped to the plot.
|
|
10
|
+
*
|
|
11
|
+
* It looks as `crosshair` says (a line look), falling back to `line`.
|
|
12
|
+
*/
|
|
13
|
+
import { NUMBER } from './attrs.js';
|
|
14
|
+
import { ChartElement } from './element.js';
|
|
15
|
+
import { EVENT_ATTACH, EVENT_PAINT } from './events.js';
|
|
16
|
+
import { circlePath, f2 } from './geometry.js';
|
|
17
|
+
export class Crosshair extends ChartElement {
|
|
18
|
+
static attrTypes = { ...ChartElement.attrTypes, dotSize: NUMBER };
|
|
19
|
+
#dots = 0;
|
|
20
|
+
/** At the pointer's own place unless it is snapped to a reading. */
|
|
21
|
+
get follows() { return this.attrs.snap !== true; }
|
|
22
|
+
onEvent(ev) {
|
|
23
|
+
switch (ev.type) {
|
|
24
|
+
case EVENT_ATTACH:
|
|
25
|
+
this.surface.wrapper();
|
|
26
|
+
break;
|
|
27
|
+
case EVENT_PAINT:
|
|
28
|
+
this.#paint();
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
#paint() {
|
|
33
|
+
const s = this.surface, root = s.wrapper(), state = this.requireParent().state, a = this.attrs;
|
|
34
|
+
if (!state) {
|
|
35
|
+
s.remove('guide');
|
|
36
|
+
for (let i = 0; i < this.#dots; i++)
|
|
37
|
+
s.remove(`dot-${i}`);
|
|
38
|
+
this.#dots = 0;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const { plane, hits } = state, r = plane.rect;
|
|
42
|
+
s.set(root, 'transform', `translate(${f2(r.x)} ${f2(r.y)})`);
|
|
43
|
+
const clip = plane.clipUrl;
|
|
44
|
+
if (clip)
|
|
45
|
+
s.set(root, 'clip-path', clip);
|
|
46
|
+
// oxlint-disable-next-line typescript/no-non-null-assertion -- in range: the index comes from a loop bound or a length checked in the same function
|
|
47
|
+
const snapped = a.snap === true && hits.length > 0 ? hits[0] : null;
|
|
48
|
+
const x = snapped ? snapped.px : state.x, y = snapped ? snapped.py : state.y;
|
|
49
|
+
const first = plane.defaultAxis('first'), second = plane.defaultAxis('second');
|
|
50
|
+
let d = '';
|
|
51
|
+
if (first && second) {
|
|
52
|
+
const at = plane.unplace(x, y, first, second), which = a.lines ?? 'both';
|
|
53
|
+
if (which !== 'second')
|
|
54
|
+
d += plane.guide('first', at[0]);
|
|
55
|
+
if (which !== 'first')
|
|
56
|
+
d += plane.guide('second', at[1]);
|
|
57
|
+
}
|
|
58
|
+
const look = this.styleFor(['crosshair', 'line']);
|
|
59
|
+
const line = s.node('guide', 'path');
|
|
60
|
+
s.set(line, 'd', d);
|
|
61
|
+
s.set(line, 'fill', 'none');
|
|
62
|
+
s.set(line, 'stroke', look.color);
|
|
63
|
+
s.set(line, 'stroke-width', look.width);
|
|
64
|
+
s.set(line, 'stroke-dasharray', look.dash);
|
|
65
|
+
s.set(line, 'opacity', look.opacity);
|
|
66
|
+
const halo = this.styleFor(['calloutBox', 'shape']).fill, size = a.dotSize ?? 4, shown = a.dots ? hits : [];
|
|
67
|
+
shown.forEach((hit, i) => {
|
|
68
|
+
const dot = s.node(`dot-${i}`, 'path');
|
|
69
|
+
s.set(dot, 'd', circlePath(hit.px, hit.py, size));
|
|
70
|
+
s.set(dot, 'fill', hit.series.color);
|
|
71
|
+
s.set(dot, 'stroke', halo);
|
|
72
|
+
s.set(dot, 'stroke-width', 1.5);
|
|
73
|
+
});
|
|
74
|
+
for (let i = shown.length; i < this.#dots; i++)
|
|
75
|
+
s.remove(`dot-${i}`);
|
|
76
|
+
this.#dots = shown.length;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/cursor.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Chart } from './chart.ts';
|
|
2
|
+
import { type BaseAttributes, ChartElement, type ElementRef } from './element.ts';
|
|
3
|
+
import { inputOf } from './input.ts';
|
|
4
|
+
import { type ChartEvent, type Hit } from './events.ts';
|
|
5
|
+
import { Plane } from './plane.ts';
|
|
6
|
+
import type { Series } from './series.ts';
|
|
7
|
+
import type { ChartTooltip } from './furniture.ts';
|
|
8
|
+
import type { Crosshair } from './crosshair.ts';
|
|
9
|
+
/** What the keyboard can do to the cursor. */
|
|
10
|
+
export type CursorAction = 'next' | 'previous' | 'nextSeries' | 'previousSeries' | 'first' | 'last' | 'jumpNext' | 'jumpPrevious' | 'clear';
|
|
11
|
+
/** Keys, by `KeyboardEvent.key`, for each action. An action left out has the default keys; an empty list turns it off. */
|
|
12
|
+
export type CursorKeys = Partial<Record<CursorAction, readonly string[]>>;
|
|
13
|
+
export declare const DEFAULT_CURSOR_KEYS: Readonly<Record<CursorAction, readonly string[]>>;
|
|
14
|
+
export interface CursorAttributes extends BaseAttributes {
|
|
15
|
+
/** What it finds: the 'nearest' point, or every series 'shared' at its place along the first axis. Default 'nearest'. */
|
|
16
|
+
mode?: 'nearest' | 'shared';
|
|
17
|
+
/** Read the line between its two points at the cursor instead of reporting a point. Domain series only; others keep their nearest point. Default false. */
|
|
18
|
+
interpolate?: boolean;
|
|
19
|
+
/** The plane it works in. Default: the plane the pointer is over, and the first plane for the keyboard. */
|
|
20
|
+
plane?: ElementRef<Plane>;
|
|
21
|
+
/** Keys for moving it from the keyboard, or `false` for none (and no tab stop). */
|
|
22
|
+
keys?: CursorKeys | false;
|
|
23
|
+
}
|
|
24
|
+
/** Where the cursor is and what is there. */
|
|
25
|
+
export interface CursorState {
|
|
26
|
+
readonly plane: Plane;
|
|
27
|
+
/** The cursor in the plane's plot pixels: the pointer's place, or the point stepped to with the keyboard. */
|
|
28
|
+
readonly x: number;
|
|
29
|
+
readonly y: number;
|
|
30
|
+
/** Which of the two moved it last. */
|
|
31
|
+
readonly source: 'pointer' | 'keyboard';
|
|
32
|
+
/** What is there under the cursor's mode, topmost series first; none when the pointer is over nothing. */
|
|
33
|
+
readonly hits: readonly Hit[];
|
|
34
|
+
}
|
|
35
|
+
/** What an element inside a cursor says about itself. */
|
|
36
|
+
export interface CursorChild {
|
|
37
|
+
/** True when it is drawn at the pointer's own place, so the cursor must redraw for each movement and not only for a change of hit. */
|
|
38
|
+
readonly follows: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface CursorChildMap {
|
|
41
|
+
crosshair: Crosshair;
|
|
42
|
+
tooltip: ChartTooltip;
|
|
43
|
+
}
|
|
44
|
+
export type CursorChildren = CursorChildMap[keyof CursorChildMap][];
|
|
45
|
+
export declare class Cursor extends ChartElement<Chart, CursorChildren, CursorAttributes> {
|
|
46
|
+
#private;
|
|
47
|
+
static takesInput: typeof inputOf;
|
|
48
|
+
static attrTypes: {
|
|
49
|
+
plane: 1;
|
|
50
|
+
keys: {
|
|
51
|
+
nextSeries: 0;
|
|
52
|
+
previousSeries: 0;
|
|
53
|
+
jumpNext: 0;
|
|
54
|
+
jumpPrevious: 0;
|
|
55
|
+
};
|
|
56
|
+
id: 1;
|
|
57
|
+
meta: {
|
|
58
|
+
title: 1;
|
|
59
|
+
description: 1;
|
|
60
|
+
role: 1;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
/** The keys in force: the defaults with the ones asked for laid over them, or none. */
|
|
64
|
+
get keyMap(): CursorKeys | null;
|
|
65
|
+
/**
|
|
66
|
+
* Where the cursor is and what is there, or null when it is nowhere: the pointer is off the plot (or on its corner, for a
|
|
67
|
+
* round plot), a button is held (a drag), or the keyboard has put it away. Worked out afresh after each flush, since
|
|
68
|
+
* the data may have been edited, or the view panned, under a pointer that has not moved.
|
|
69
|
+
*/
|
|
70
|
+
get state(): CursorState | null;
|
|
71
|
+
/** The plane the cursor is tied to by `plane`, else the first. */
|
|
72
|
+
get plane(): Plane | null;
|
|
73
|
+
/** Put the keyboard cursor on a point of a series, as if stepped to, and show it. */
|
|
74
|
+
select(series: Series, index: number): void;
|
|
75
|
+
onEvent(ev: ChartEvent): void;
|
|
76
|
+
}
|