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/radial.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Radial planes: the domain goes round a circle (the angle axis), the value out from the middle (the radius axis).
|
|
3
|
+
*
|
|
4
|
+
* A radial plane is three decisions. Where a point goes (`locate`), what a line of constant value looks like (`ring`),
|
|
5
|
+
* and what to do once the circle is settled (`prepare`). Everything else, from layout to axes to gridlines to the
|
|
6
|
+
* clip, is shared, and series and renderers know nothing of it. `PolarPlane` is the continuous one: rings are arcs.
|
|
7
|
+
* `RadarPlane` has a spoke at every tick of the angle axis and joins them with chords, rings and data alike.
|
|
8
|
+
*/
|
|
9
|
+
/* 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 */
|
|
10
|
+
import { NUMBER } from './attrs.js';
|
|
11
|
+
import { Axis, polarSide } from './axis.js';
|
|
12
|
+
import { writeAria } from './aria.js';
|
|
13
|
+
import { fail } from './element.js';
|
|
14
|
+
import { EVENT_ARRANGE, EVENT_ATTACH, EVENT_PAINT } from './events.js';
|
|
15
|
+
import { f2 } from './geometry.js';
|
|
16
|
+
import { MIN_INSET, Plane, nextClipId } from './plane.js';
|
|
17
|
+
const SIDES = ['angle', 'radius'];
|
|
18
|
+
const TAU = 2 * Math.PI;
|
|
19
|
+
/** An arc of a circle as path data, in pieces of at most half a turn so that any sweep is drawn the right way round. */
|
|
20
|
+
function arc(cx, cy, r, start, sweep) {
|
|
21
|
+
const pieces = Math.max(1, Math.ceil(Math.abs(sweep) / Math.PI - 1e-9)), step = sweep / pieces;
|
|
22
|
+
const at = (a) => `${f2(cx + r * Math.sin(a))} ${f2(cy - r * Math.cos(a))}`;
|
|
23
|
+
let d = `M${at(start)}`;
|
|
24
|
+
for (let k = 1; k <= pieces; k++)
|
|
25
|
+
d += `A${f2(r)} ${f2(r)} 0 0 ${sweep > 0 ? 1 : 0} ${at(start + k * step)}`;
|
|
26
|
+
return d;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The shared machinery of radial planes: layout in a circle, rulers for both axes, clip, gridlines, and `place`.
|
|
30
|
+
* Subclasses supply only the geometry (`locate`, `ring`, `outerRadius`, `prepare`).
|
|
31
|
+
*/
|
|
32
|
+
export class RadialPlane extends Plane {
|
|
33
|
+
static attrTypes = { ...Plane.attrTypes, innerRadius: NUMBER, startAngle: NUMBER };
|
|
34
|
+
clipId = nextClipId();
|
|
35
|
+
#cx = 0;
|
|
36
|
+
#cy = 0;
|
|
37
|
+
#radius = 1;
|
|
38
|
+
#xy = new Float64Array(2);
|
|
39
|
+
#rulers = new Map();
|
|
40
|
+
get clipUrl() { return this.attrs.clip === false ? null : `url(#${this.clipId})`; }
|
|
41
|
+
get curved() { return true; }
|
|
42
|
+
get startAngle() { return this.attrs.startAngle ?? 0; }
|
|
43
|
+
get sweep() { return this.attrs.sweep ?? TAU; }
|
|
44
|
+
get innerRadius() { return this.attrs.innerRadius ?? 0; }
|
|
45
|
+
/** The outer radius in plot pixels. Valid after arrange. */
|
|
46
|
+
get radius() { return this.#radius; }
|
|
47
|
+
get cx() { return this.#cx; }
|
|
48
|
+
get cy() { return this.#cy; }
|
|
49
|
+
/** Called when the circle is settled, before any series is projected. */
|
|
50
|
+
prepare() { }
|
|
51
|
+
cyclic(axis) { return axis.orientation === 'angle' && Math.abs(this.sweep) >= TAU - 1e-9; }
|
|
52
|
+
/** On the plot when inside the rim in that direction, outside the hole, and (for a part of a turn) within the sweep. */
|
|
53
|
+
covers(x, y) {
|
|
54
|
+
const dx = x - this.#cx, dy = y - this.#cy, len = Math.hypot(dx, dy);
|
|
55
|
+
if (len > this.outerRadius(dx, dy) + 1e-9 || len < this.#radius * this.innerRadius - 1e-9)
|
|
56
|
+
return false;
|
|
57
|
+
const first = this.defaultAxis('first');
|
|
58
|
+
if (len === 0 || !first || this.cyclic(first))
|
|
59
|
+
return true;
|
|
60
|
+
const f = this.angleFraction(dx, dy);
|
|
61
|
+
return f >= 0 && f <= 1;
|
|
62
|
+
}
|
|
63
|
+
/** A radius is a ring (round, or a polygon on a radar); an angle is a spoke, which the plane's own way of placing gives. */
|
|
64
|
+
guide(role, value) {
|
|
65
|
+
const axis = this.defaultAxis(role);
|
|
66
|
+
if (axis?.orientation !== 'radius')
|
|
67
|
+
return super.guide(role, value);
|
|
68
|
+
const f = axis.fractionOf(value);
|
|
69
|
+
return Number.isFinite(f) ? this.ring(this.#radius * (this.innerRadius + (1 - this.innerRadius) * Math.max(0, f))) : '';
|
|
70
|
+
}
|
|
71
|
+
/** The first coordinate is the angle, the second the radius. */
|
|
72
|
+
defaultAxis(role) {
|
|
73
|
+
return this.axes.find((a) => a.orientation === (role === 'first' ? 'angle' : 'radius'));
|
|
74
|
+
}
|
|
75
|
+
/** Pixels along an axis: what its ticks have to fit into. */
|
|
76
|
+
lengthOf(axis) { return this.#rulers.get(axis)?.length ?? 1; }
|
|
77
|
+
/** The fractions 0..1 of an axis' ticks that fall inside its range. */
|
|
78
|
+
tickFractions(axis) {
|
|
79
|
+
const out = [];
|
|
80
|
+
if (!axis)
|
|
81
|
+
return out;
|
|
82
|
+
for (const t of axis.getTicks(this.lengthOf(axis))) {
|
|
83
|
+
const f = axis.fractionOf(t.value);
|
|
84
|
+
if (f >= -1e-9 && f <= 1 + 1e-9)
|
|
85
|
+
out.push(f);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
unplace(x, y, first, second) {
|
|
90
|
+
const dx = x - this.#cx, dy = y - this.#cy, inner = this.innerRadius;
|
|
91
|
+
const around = this.angleFraction(dx, dy);
|
|
92
|
+
const out = (Math.hypot(dx, dy) / this.outerRadius(dx, dy) - inner) / (1 - inner);
|
|
93
|
+
return first.orientation === 'angle' ? [first.denormalize(around), second.denormalize(out)] : [first.denormalize(out), second.denormalize(around)];
|
|
94
|
+
}
|
|
95
|
+
place(points, first, second) {
|
|
96
|
+
const angleIsDomain = first.orientation === 'angle';
|
|
97
|
+
if ((first.orientation !== 'angle' && first.orientation !== 'radius') || (second.orientation === 'angle') === angleIsDomain) {
|
|
98
|
+
fail('a series needs one angle axis and one radius axis');
|
|
99
|
+
}
|
|
100
|
+
const { data, count, stride } = points;
|
|
101
|
+
const R = this.#radius, inner = this.innerRadius, xy = this.#xy;
|
|
102
|
+
const a0 = angleIsDomain ? 0 : 1, r0 = 1 - a0;
|
|
103
|
+
for (let k = 0, i = 0; k < count; k++, i += stride) {
|
|
104
|
+
// Below the middle would come out on the far side. Above the edge is left for the clip.
|
|
105
|
+
this.locate(data[i + a0], R * (inner + (1 - inner) * Math.max(0, data[i + r0])), xy);
|
|
106
|
+
data[i] = xy[0];
|
|
107
|
+
data[i + 1] = xy[1];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
onEvent(ev) {
|
|
111
|
+
switch (ev.type) {
|
|
112
|
+
case EVENT_ATTACH:
|
|
113
|
+
this.surface.wrapper();
|
|
114
|
+
break;
|
|
115
|
+
case EVENT_ARRANGE:
|
|
116
|
+
this.arrange(ev.rect);
|
|
117
|
+
break;
|
|
118
|
+
case EVENT_PAINT:
|
|
119
|
+
this.paint();
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Every axis gets the curve it runs along: the rim for the angle, a spoke at the start angle for the radius. The line
|
|
125
|
+
* is worked out when it is drawn, since a radar's depends on ticks that are only settled by then.
|
|
126
|
+
*/
|
|
127
|
+
setRulers() {
|
|
128
|
+
const start = this.startAngle, sweep = this.sweep, R = this.#radius, r0 = R * this.innerRadius, xy = this.#xy;
|
|
129
|
+
const ring = (r) => this.ring(r);
|
|
130
|
+
const at0 = (r) => {
|
|
131
|
+
this.locate(0, r, xy);
|
|
132
|
+
return `${f2(xy[0])} ${f2(xy[1])}`;
|
|
133
|
+
};
|
|
134
|
+
const angleAt = (f) => {
|
|
135
|
+
this.locate(f, R, xy);
|
|
136
|
+
const a = start + f * sweep;
|
|
137
|
+
return [xy[0], xy[1], Math.sin(a), -Math.cos(a)];
|
|
138
|
+
};
|
|
139
|
+
const radiusAt = (f) => {
|
|
140
|
+
this.locate(0, r0 + f * (R - r0), xy);
|
|
141
|
+
return [xy[0], xy[1], -Math.cos(start), -Math.sin(start)];
|
|
142
|
+
};
|
|
143
|
+
// A radial axis' title is always on its own line, past its labels: there is no edge of a circle to put it at.
|
|
144
|
+
const spot = (mid) => ({ x: mid[0], y: mid[1], nx: mid[2], ny: mid[3], beyond: true });
|
|
145
|
+
for (const axis of this.axes) {
|
|
146
|
+
const ruler = axis.orientation === 'angle'
|
|
147
|
+
? {
|
|
148
|
+
length: Math.max(1, Math.abs(sweep) * R), tickSpacing: 70, horizontal: false,
|
|
149
|
+
get line() { return ring(R); },
|
|
150
|
+
at: angleAt,
|
|
151
|
+
title: () => spot(angleAt(0.5)),
|
|
152
|
+
}
|
|
153
|
+
: {
|
|
154
|
+
length: Math.max(1, R - r0), tickSpacing: 36, horizontal: false,
|
|
155
|
+
get line() { return `M${at0(r0)}L${at0(R)}`; },
|
|
156
|
+
at: radiusAt,
|
|
157
|
+
title: () => spot(radiusAt(0.5)),
|
|
158
|
+
// However far round the pointer is, it is as far along the radius as it is out from the middle, measured
|
|
159
|
+
// against the edge in its own direction (a radar's edge is nearer between spokes).
|
|
160
|
+
fractionAt: (x, y) => {
|
|
161
|
+
const dx = x - this.#cx, dy = y - this.#cy, inner = this.innerRadius;
|
|
162
|
+
return (Math.hypot(dx, dy) / this.outerRadius(dx, dy) - inner) / (1 - inner);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
this.#rulers.set(axis, ruler);
|
|
166
|
+
axis.setRuler(ruler);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Same three passes as a Cartesian plane (estimate → fit → measure → final size → project), except that the plot is
|
|
171
|
+
* the largest circle in what is left, and the only margin that matters is what the angle axis' labels need around it.
|
|
172
|
+
*/
|
|
173
|
+
arrange(available) {
|
|
174
|
+
const axes = this.axes, series = this.series;
|
|
175
|
+
this.assignAxes(SIDES, polarSide);
|
|
176
|
+
// A first guess at the circle sizes the fills and the tick counts; the labels then say how much room they take.
|
|
177
|
+
this.#radius = Math.max(1, Math.min(available.width, available.height) / 2 - 24);
|
|
178
|
+
this.#cx = this.#cy = 0;
|
|
179
|
+
this.setRulers();
|
|
180
|
+
const estimate = { width: Math.max(1, Math.abs(this.sweep) * this.#radius), height: this.#radius };
|
|
181
|
+
this.fit(estimate);
|
|
182
|
+
let margin = 0;
|
|
183
|
+
for (const a of axes) {
|
|
184
|
+
this.measureChild(a, estimate);
|
|
185
|
+
if (a.orientation === 'angle')
|
|
186
|
+
margin = Math.max(margin, a.thickness, Math.ceil(a.labelOverhang));
|
|
187
|
+
}
|
|
188
|
+
const o = this.attrs.insets ?? {};
|
|
189
|
+
const insets = {
|
|
190
|
+
top: o.top ?? Math.max(MIN_INSET, margin), bottom: o.bottom ?? Math.max(MIN_INSET, margin),
|
|
191
|
+
left: o.left ?? Math.max(MIN_INSET, margin), right: o.right ?? Math.max(MIN_INSET, margin),
|
|
192
|
+
};
|
|
193
|
+
const rect = {
|
|
194
|
+
x: available.x + insets.left, y: available.y + insets.top,
|
|
195
|
+
width: Math.max(1, available.width - insets.left - insets.right),
|
|
196
|
+
height: Math.max(1, available.height - insets.top - insets.bottom),
|
|
197
|
+
};
|
|
198
|
+
this.setRect(rect);
|
|
199
|
+
this.#cx = rect.width / 2;
|
|
200
|
+
this.#cy = rect.height / 2;
|
|
201
|
+
this.#radius = Math.max(1, Math.min(rect.width, rect.height) / 2);
|
|
202
|
+
this.setRulers();
|
|
203
|
+
this.prepare();
|
|
204
|
+
for (const s of series)
|
|
205
|
+
s.project(this);
|
|
206
|
+
}
|
|
207
|
+
/** What the plot covers: the whole outline for a full turn (less the hole), a sector for part of one. */
|
|
208
|
+
region() {
|
|
209
|
+
const R = this.#radius, r0 = R * this.innerRadius;
|
|
210
|
+
if (Math.abs(this.sweep) >= TAU - 1e-9)
|
|
211
|
+
return this.ring(R) + 'Z' + (r0 > 0 ? this.ring(r0) + 'Z' : '');
|
|
212
|
+
return this.ring(R) + 'L' + this.ring(r0, true).slice(1) + 'Z';
|
|
213
|
+
}
|
|
214
|
+
/** Clip region, then spokes at the angle axis' ticks and rings at the radius axis' ticks. */
|
|
215
|
+
paint() {
|
|
216
|
+
const s = this.surface, r = this.rect, a = this.attrs;
|
|
217
|
+
const R = this.#radius, inner = this.innerRadius, xy = this.#xy;
|
|
218
|
+
s.set(s.wrapper(), 'transform', `translate(${f2(r.x)} ${f2(r.y)})`);
|
|
219
|
+
writeAria(s, s.wrapper(), a.meta, { role: 'group' });
|
|
220
|
+
if (a.clip !== false) {
|
|
221
|
+
const clip = s.node('clip', 'clipPath', { parent: s.node('defs', 'defs') });
|
|
222
|
+
s.set(clip, 'id', this.clipId);
|
|
223
|
+
const shape = s.node('clip-shape', 'path', { parent: clip });
|
|
224
|
+
s.set(shape, 'd', this.region());
|
|
225
|
+
s.set(shape, 'clip-rule', 'evenodd');
|
|
226
|
+
}
|
|
227
|
+
if (a.gridlines !== false) {
|
|
228
|
+
let d = '';
|
|
229
|
+
const angle = this.axes.find((x) => x.orientation === 'angle'), radius = this.axes.find((x) => x.orientation === 'radius');
|
|
230
|
+
for (const f of this.tickFractions(angle)) {
|
|
231
|
+
this.locate(f, R * inner, xy);
|
|
232
|
+
d += `M${f2(xy[0])} ${f2(xy[1])}`;
|
|
233
|
+
this.locate(f, R, xy);
|
|
234
|
+
d += `L${f2(xy[0])} ${f2(xy[1])}`;
|
|
235
|
+
}
|
|
236
|
+
for (const f of this.tickFractions(radius))
|
|
237
|
+
d += this.ring(R * (inner + (1 - inner) * f));
|
|
238
|
+
const grid = s.node('grid', 'path');
|
|
239
|
+
s.set(grid, 'd', d);
|
|
240
|
+
s.set(grid, 'fill', 'none');
|
|
241
|
+
this.strokeWith(grid, this.styleFor(['gridline', 'line']));
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
s.remove('grid');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** A continuous plane: the angle is an angle, so lines of constant value are circles. */
|
|
249
|
+
export class PolarPlane extends RadialPlane {
|
|
250
|
+
locate(fraction, r, out) {
|
|
251
|
+
const a = this.startAngle + fraction * this.sweep;
|
|
252
|
+
out[0] = this.cx + r * Math.sin(a);
|
|
253
|
+
out[1] = this.cy - r * Math.cos(a);
|
|
254
|
+
}
|
|
255
|
+
outerRadius() { return this.radius; }
|
|
256
|
+
angleFraction(dx, dy) {
|
|
257
|
+
const f = (Math.atan2(dx, -dy) - this.startAngle) / this.sweep;
|
|
258
|
+
return Math.abs(this.sweep) >= TAU - 1e-9 ? f - Math.floor(f) : f;
|
|
259
|
+
}
|
|
260
|
+
ring(r, reverse = false) {
|
|
261
|
+
return reverse ? arc(this.cx, this.cy, r, this.startAngle + this.sweep, -this.sweep) : arc(this.cx, this.cy, r, this.startAngle, this.sweep);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* A plane with a spoke at every tick of the angle axis, the ends of the sweep too, and straight lines between them.
|
|
266
|
+
* Between two spokes a point is on the chord between where its radius is on each, so a ring is a polygon, a line
|
|
267
|
+
* through dense data follows the same chords, and an `AreaPath` fills a polygon without being told it is one.
|
|
268
|
+
*
|
|
269
|
+
* Give the angle axis a fixed `min` and `max` and a `tickCount` that puts a tick on each category (and `nice` off,
|
|
270
|
+
* or on only for integers); auto-ranging works, but the spokes move as the data does. A full turn ends where it began,
|
|
271
|
+
* so a closed shape needs the first point repeated at the end of the domain.
|
|
272
|
+
*/
|
|
273
|
+
export class RadarPlane extends RadialPlane {
|
|
274
|
+
/** Fractions of the angle axis that have a spoke, ascending, and the direction of each. */
|
|
275
|
+
#at = [0, 1];
|
|
276
|
+
#sin = [0, 0];
|
|
277
|
+
#cos = [1, 1];
|
|
278
|
+
prepare() {
|
|
279
|
+
const set = new Set([0, 1]);
|
|
280
|
+
for (const f of this.tickFractions(this.axes.find((a) => a.orientation === 'angle')))
|
|
281
|
+
set.add(Math.round(f * 1e9) / 1e9);
|
|
282
|
+
this.#at = [...set].sort((p, q) => p - q);
|
|
283
|
+
this.#sin = this.#at.map((f) => Math.sin(this.startAngle + f * this.sweep));
|
|
284
|
+
this.#cos = this.#at.map((f) => Math.cos(this.startAngle + f * this.sweep));
|
|
285
|
+
}
|
|
286
|
+
/** The spokes: a line along the angle axis bends at each. */
|
|
287
|
+
breaks(axis) { return axis.orientation === 'angle' ? this.#at : []; }
|
|
288
|
+
locate(fraction, r, out) {
|
|
289
|
+
const at = this.#at;
|
|
290
|
+
let k = 0;
|
|
291
|
+
while (k < at.length - 2 && fraction > at[k + 1])
|
|
292
|
+
k++;
|
|
293
|
+
const t = (fraction - at[k]) / (at[k + 1] - at[k]);
|
|
294
|
+
const x0 = r * this.#sin[k], y0 = -r * this.#cos[k];
|
|
295
|
+
out[0] = this.cx + x0 + t * (r * this.#sin[k + 1] - x0);
|
|
296
|
+
out[1] = this.cy + y0 + t * (-r * this.#cos[k + 1] - y0);
|
|
297
|
+
}
|
|
298
|
+
/** The wedge between two spokes that the direction (dx, dy) points into: the spoke before it, and how far round the sweep it is. */
|
|
299
|
+
wedge(dx, dy) {
|
|
300
|
+
const at = this.#at;
|
|
301
|
+
let f = (Math.atan2(dx, -dy) - this.startAngle) / this.sweep;
|
|
302
|
+
f = Math.abs(this.sweep) >= TAU - 1e-9 ? f - Math.floor(f) : Math.min(1, Math.max(0, f));
|
|
303
|
+
let k = 0;
|
|
304
|
+
while (k < at.length - 2 && f > at[k + 1])
|
|
305
|
+
k++;
|
|
306
|
+
return { k, f };
|
|
307
|
+
}
|
|
308
|
+
outerRadius(dx, dy) {
|
|
309
|
+
// The chord of the wedge the pointer is in: a ray meets it where the chord's distance from the middle, over the
|
|
310
|
+
// cosine of the angle to the chord's normal, says.
|
|
311
|
+
const { k } = this.wedge(dx, dy);
|
|
312
|
+
const nx = this.#sin[k] + this.#sin[k + 1], ny = -(this.#cos[k] + this.#cos[k + 1]), norm = Math.hypot(nx, ny);
|
|
313
|
+
const chord = (this.#sin[k] * nx - this.#cos[k] * ny) / norm, along = (dx * nx + dy * ny) / norm / Math.hypot(dx, dy);
|
|
314
|
+
const t = (chord / along) * this.radius;
|
|
315
|
+
return t > 0 && Number.isFinite(t) ? t : this.radius;
|
|
316
|
+
}
|
|
317
|
+
/** Where along the chord the pointer's ray crosses it, mapped to the fractions of the two spokes (not linear in the angle). */
|
|
318
|
+
angleFraction(dx, dy) {
|
|
319
|
+
const at = this.#at, { k, f } = this.wedge(dx, dy), R = this.radius;
|
|
320
|
+
const len = Math.hypot(dx, dy);
|
|
321
|
+
if (len === 0)
|
|
322
|
+
return f;
|
|
323
|
+
const reach = this.outerRadius(dx, dy), px = (dx / len) * reach, py = (dy / len) * reach;
|
|
324
|
+
const ax = R * this.#sin[k], ay = -R * this.#cos[k], sx = R * this.#sin[k + 1] - ax, sy = -R * this.#cos[k + 1] - ay;
|
|
325
|
+
const t = ((px - ax) * sx + (py - ay) * sy) / (sx * sx + sy * sy);
|
|
326
|
+
return at[k] + Math.min(1, Math.max(0, t)) * (at[k + 1] - at[k]);
|
|
327
|
+
}
|
|
328
|
+
ring(r, reverse = false) {
|
|
329
|
+
let d = '';
|
|
330
|
+
for (let j = 0; j < this.#at.length; j++) {
|
|
331
|
+
const k = reverse ? this.#at.length - 1 - j : j;
|
|
332
|
+
d += (j === 0 ? 'M' : 'L') + f2(this.cx + r * this.#sin[k]) + ' ' + f2(this.cy - r * this.#cos[k]);
|
|
333
|
+
}
|
|
334
|
+
return d;
|
|
335
|
+
}
|
|
336
|
+
}
|