@motionscript/charts 0.1.0-alpha.5 → 0.1.0-alpha.7
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/CHANGELOG.md +40 -0
- package/README.md +30 -16
- package/dist/bar-chart/bar-chart.d.ts +133 -242
- package/dist/bar-chart/bar-chart.d.ts.map +1 -1
- package/dist/bar-chart/bar-chart.js +378 -569
- package/dist/bar-chart/bar-chart.js.map +1 -1
- package/dist/bar-chart/category-axis.d.ts +35 -41
- package/dist/bar-chart/category-axis.d.ts.map +1 -1
- package/dist/bar-chart/category-axis.js +93 -115
- package/dist/bar-chart/category-axis.js.map +1 -1
- package/dist/bar-chart/chart-bars.d.ts +50 -70
- package/dist/bar-chart/chart-bars.d.ts.map +1 -1
- package/dist/bar-chart/chart-bars.js +156 -210
- package/dist/bar-chart/chart-bars.js.map +1 -1
- package/dist/bar-chart/index.d.ts +2 -2
- package/dist/bar-chart/index.d.ts.map +1 -1
- package/dist/bar-chart/index.js +1 -1
- package/dist/bar-chart/index.js.map +1 -1
- package/dist/bar-chart/shared.d.ts +3 -3
- package/dist/bar-chart/shared.d.ts.map +1 -1
- package/dist/bar-chart/shared.js +3 -3
- package/dist/bar-chart/shared.js.map +1 -1
- package/dist/browser/chunks/chunk-XROBIRQK.js +2 -0
- package/dist/browser/chunks/chunk-XROBIRQK.js.map +7 -0
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +3 -3
- package/dist/browser/kit.js +1 -1
- package/dist/browser/manifest.json +1 -1
- package/dist/engine.d.ts +8 -6
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +8 -6
- package/dist/engine.js.map +1 -1
- package/dist/kit/cartesian.d.ts +44 -0
- package/dist/kit/cartesian.d.ts.map +1 -0
- package/dist/kit/cartesian.js +59 -0
- package/dist/kit/cartesian.js.map +1 -0
- package/dist/kit/chart-border.d.ts +10 -11
- package/dist/kit/chart-border.d.ts.map +1 -1
- package/dist/kit/chart-border.js +15 -32
- package/dist/kit/chart-border.js.map +1 -1
- package/dist/kit/chart-canvas.d.ts +32 -21
- package/dist/kit/chart-canvas.d.ts.map +1 -1
- package/dist/kit/chart-canvas.js +33 -90
- package/dist/kit/chart-canvas.js.map +1 -1
- package/dist/kit/chart-legend.d.ts +123 -64
- package/dist/kit/chart-legend.d.ts.map +1 -1
- package/dist/kit/chart-legend.js +129 -154
- package/dist/kit/chart-legend.js.map +1 -1
- package/dist/kit/entries.d.ts +17 -0
- package/dist/kit/entries.d.ts.map +1 -0
- package/dist/kit/entries.js +19 -0
- package/dist/kit/entries.js.map +1 -0
- package/dist/kit/motion.d.ts +48 -0
- package/dist/kit/motion.d.ts.map +1 -0
- package/dist/kit/motion.js +63 -0
- package/dist/kit/motion.js.map +1 -0
- package/dist/kit/paints.d.ts +10 -0
- package/dist/kit/paints.d.ts.map +1 -0
- package/dist/kit/paints.js +12 -0
- package/dist/kit/paints.js.map +1 -0
- package/dist/kit/shared.d.ts +31 -3
- package/dist/kit/shared.d.ts.map +1 -1
- package/dist/kit/shared.js +42 -3
- package/dist/kit/shared.js.map +1 -1
- package/dist/kit/vertical-axis.d.ts +48 -61
- package/dist/kit/vertical-axis.d.ts.map +1 -1
- package/dist/kit/vertical-axis.js +97 -132
- package/dist/kit/vertical-axis.js.map +1 -1
- package/dist/line-chart/chart-line.d.ts +60 -77
- package/dist/line-chart/chart-line.d.ts.map +1 -1
- package/dist/line-chart/chart-line.js +100 -121
- package/dist/line-chart/chart-line.js.map +1 -1
- package/dist/line-chart/chart-region.d.ts +32 -33
- package/dist/line-chart/chart-region.d.ts.map +1 -1
- package/dist/line-chart/chart-region.js +31 -65
- package/dist/line-chart/chart-region.js.map +1 -1
- package/dist/line-chart/horizontal-axis.d.ts +58 -64
- package/dist/line-chart/horizontal-axis.d.ts.map +1 -1
- package/dist/line-chart/horizontal-axis.js +94 -123
- package/dist/line-chart/horizontal-axis.js.map +1 -1
- package/dist/line-chart/index.d.ts +2 -2
- package/dist/line-chart/index.d.ts.map +1 -1
- package/dist/line-chart/index.js +1 -1
- package/dist/line-chart/index.js.map +1 -1
- package/dist/line-chart/line-chart.d.ts +156 -319
- package/dist/line-chart/line-chart.d.ts.map +1 -1
- package/dist/line-chart/line-chart.js +403 -663
- package/dist/line-chart/line-chart.js.map +1 -1
- package/dist/line-chart/shared.d.ts +3 -3
- package/dist/line-chart/shared.d.ts.map +1 -1
- package/dist/line-chart/shared.js +3 -3
- package/dist/line-chart/shared.js.map +1 -1
- package/dist/nodes.d.ts +6 -9
- package/dist/nodes.d.ts.map +1 -1
- package/dist/nodes.js +7 -9
- package/dist/nodes.js.map +1 -1
- package/dist/pie-chart/index.d.ts +2 -2
- package/dist/pie-chart/index.d.ts.map +1 -1
- package/dist/pie-chart/index.js +1 -1
- package/dist/pie-chart/index.js.map +1 -1
- package/dist/pie-chart/pie-chart.d.ts +108 -132
- package/dist/pie-chart/pie-chart.d.ts.map +1 -1
- package/dist/pie-chart/pie-chart.js +200 -271
- package/dist/pie-chart/pie-chart.js.map +1 -1
- package/dist/pie-chart/pie-legend.d.ts +84 -31
- package/dist/pie-chart/pie-legend.d.ts.map +1 -1
- package/dist/pie-chart/pie-legend.js +93 -107
- package/dist/pie-chart/pie-legend.js.map +1 -1
- package/dist/pie-chart/pie-ring.d.ts +160 -131
- package/dist/pie-chart/pie-ring.d.ts.map +1 -1
- package/dist/pie-chart/pie-ring.js +333 -391
- package/dist/pie-chart/pie-ring.js.map +1 -1
- package/dist/pie-chart/shared.d.ts +2 -2
- package/dist/pie-chart/shared.d.ts.map +1 -1
- package/dist/pie-chart/shared.js +2 -2
- package/dist/pie-chart/shared.js.map +1 -1
- package/package.json +4 -4
- package/src/bar-chart/bar-chart.ts +415 -701
- package/src/bar-chart/category-axis.ts +95 -110
- package/src/bar-chart/chart-bars.ts +178 -200
- package/src/bar-chart/index.ts +2 -2
- package/src/bar-chart/shared.ts +3 -3
- package/src/engine.ts +8 -6
- package/src/kit/cartesian.ts +81 -0
- package/src/kit/chart-border.ts +22 -33
- package/src/kit/chart-canvas.ts +44 -79
- package/src/kit/chart-legend.ts +173 -189
- package/src/kit/entries.ts +20 -0
- package/src/kit/motion.ts +90 -0
- package/src/kit/paints.ts +15 -0
- package/src/kit/shared.ts +52 -3
- package/src/kit/vertical-axis.ts +110 -140
- package/src/line-chart/chart-line.ts +102 -154
- package/src/line-chart/chart-region.ts +35 -80
- package/src/line-chart/horizontal-axis.ts +101 -152
- package/src/line-chart/index.ts +2 -2
- package/src/line-chart/line-chart.ts +449 -825
- package/src/line-chart/shared.ts +3 -3
- package/src/nodes.ts +8 -10
- package/src/pie-chart/index.ts +2 -2
- package/src/pie-chart/pie-chart.ts +221 -311
- package/src/pie-chart/pie-legend.ts +124 -114
- package/src/pie-chart/pie-ring.ts +386 -473
- package/src/pie-chart/shared.ts +2 -2
- package/dist/browser/chunks/chunk-5ZPBV6BU.js +0 -2
- package/dist/browser/chunks/chunk-5ZPBV6BU.js.map +0 -7
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
-
};
|
|
7
|
-
import { Rect, command, Graphics2D, Node2D, PathBuilder, Text, easeOut, fillOps, property, shadowOps, strokeOps, textOps, } from "@motionscript/sdk";
|
|
1
|
+
import { Graphics2D, PathBuilder, Rect, Text, Types, component2d, fillOps, shadowOps, strokeOps, textOps, } from "@motionscript/sdk";
|
|
2
|
+
import { asParts, fresh } from "../kit/entries.js";
|
|
3
|
+
import { frameEase } from "../kit/motion.js";
|
|
4
|
+
import { loadPaints } from "../kit/paints.js";
|
|
8
5
|
import { clampUnit, formatPercent } from "../kit/shared.js";
|
|
9
6
|
import { DEFAULT_PIE_CHART_THEME, pieArcs, relaxLabels, ringPoint, } from "./shared.js";
|
|
10
|
-
import { declarePaints } from "@motionscript/sdk/component";
|
|
11
7
|
/** Vertical breathing room between one label block and the next, in px. */
|
|
12
8
|
const LABEL_GAP = 12;
|
|
13
9
|
/** Breathing room past the outermost label, so it never touches the node's edge, in px. */
|
|
@@ -110,201 +106,25 @@ function wedgePath(a0, a1, rInner, rOuter, halfGap, cx) {
|
|
|
110
106
|
return path;
|
|
111
107
|
}
|
|
112
108
|
/**
|
|
113
|
-
* The ring
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* while its hole edge stays put — so it reads as that band swelling out of the
|
|
118
|
-
* ring rather than breaking off it — and the rest of the ring dims behind it,
|
|
119
|
-
* leaders and labels included.
|
|
120
|
-
*
|
|
121
|
-
* This is the only part of the chart family that draws a path: the wedges are
|
|
122
|
-
* filled {@link PathBuilder} polygons approximating each arc, and the leaders are
|
|
123
|
-
* stroked polylines — both in the node's centred, y-up space, so neither depends
|
|
124
|
-
* on SVG arc-sweep conventions. The **labels are real {@link Text} nodes**
|
|
125
|
-
* (stacked in a hug vertical flow and pinned by their inner edge), so no
|
|
126
|
-
* glyph-width guesswork decides where a caption starts.
|
|
127
|
-
*
|
|
128
|
-
* ### Label placement
|
|
129
|
-
* Each label sits either **inside** its wedge or **outside** on a leader.
|
|
130
|
-
*
|
|
131
|
-
* An inside label is centred on its slice's band and leads with the number, the
|
|
132
|
-
* caption set small underneath — inside a wedge the reader already knows which
|
|
133
|
-
* slice they're on, so the magnitude is the thing being read. It costs no gutter
|
|
134
|
-
* and no eye round-trip, so a ring labelled that way draws considerably bigger in
|
|
135
|
-
* the same box.
|
|
136
|
-
*
|
|
137
|
-
* A leader label sits outside on the end of a polyline that lifts off its wedge,
|
|
138
|
-
* elbows, and runs horizontally into the text. Labels are split into a left and a
|
|
139
|
-
* right column by which way their slice's bisector points, and each column is
|
|
140
|
-
* de-conflicted independently (see {@link relaxLabels}): a label takes the spot
|
|
141
|
-
* nearest its wedge that still clears its neighbour, and the column is squeezed
|
|
142
|
-
* to fit the node's height rather than spilling out of it. The elbow then rides on
|
|
143
|
-
* a circle just outside the ring **at the label's settled y**, so the run into the
|
|
144
|
-
* text is always horizontal and a nudged label never drags its leader back across
|
|
145
|
-
* the ring.
|
|
146
|
-
*
|
|
147
|
-
* Only the sides that actually carry a leader reserve a gutter, and the ring's
|
|
148
|
-
* centre slides into whatever the other side left over ({@link centerX}) — so a
|
|
149
|
-
* ring whose slices all leader one way doesn't leave a bald patch on the other.
|
|
109
|
+
* The ring's geometry for one box: every radius, gutter and label position,
|
|
110
|
+
* derived from the entries and the size the ring was laid out at, and asked
|
|
111
|
+
* afresh wherever it is read — the drawing, and the labels' bound positions
|
|
112
|
+
* and sizes alike, so the two cannot disagree.
|
|
150
113
|
*/
|
|
151
|
-
export class
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
*/
|
|
158
|
-
fillCache = [];
|
|
159
|
-
get resolvedFills() {
|
|
160
|
-
const slices = this.slices;
|
|
161
|
-
this.fillCache.length = slices.length;
|
|
162
|
-
return slices.map((slice, i) => {
|
|
163
|
-
const cached = this.fillCache[i];
|
|
164
|
-
if (cached !== undefined && cached.source === slice.fill)
|
|
165
|
-
return cached.resolved;
|
|
166
|
-
const resolved = fillOps.resolve(slice.fill);
|
|
167
|
-
this.fillCache[i] = { source: slice.fill, resolved };
|
|
168
|
-
return resolved;
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
constructor(props) {
|
|
172
|
-
super(props);
|
|
173
|
-
// One label per *drawn* slice (a zero-valued slice gets no wedge, so no
|
|
174
|
-
// label either), in whichever of the two forms the chart asked for. Both are
|
|
175
|
-
// real Text stacked in a hug vertical flow — the difference is only what they are
|
|
176
|
-
// pinned to and which line leads.
|
|
177
|
-
//
|
|
178
|
-
// The *point* each label is pinned to stays reactive and follows the ring as
|
|
179
|
-
// the box resizes or its slice pops out; only the choice of form, which
|
|
180
|
-
// depends on props alone, is settled here.
|
|
181
|
-
if (this.labelPlacement !== "none" && this.labelMode !== "none") {
|
|
182
|
-
for (const arc of this.arcs)
|
|
183
|
-
this.addLabel(arc);
|
|
184
|
-
}
|
|
185
|
-
// The hole's summary, pinned to the ring's centre so it tracks the sideways
|
|
186
|
-
// shift a one-sided label column puts on the ring.
|
|
187
|
-
if (this.hasCenterLabel)
|
|
188
|
-
this.addCenterLabel();
|
|
114
|
+
export class RingGeometry {
|
|
115
|
+
s;
|
|
116
|
+
box;
|
|
117
|
+
constructor(s, box) {
|
|
118
|
+
this.s = s;
|
|
119
|
+
this.box = box;
|
|
189
120
|
}
|
|
190
|
-
/** Build slice `arc`'s label, in whichever form the chart's placement asks for. */
|
|
191
|
-
addLabel(arc) {
|
|
192
|
-
// Fades in with the ring's labels, then dims along with its own slice
|
|
193
|
-
// whenever another slice takes the focus.
|
|
194
|
-
const opacity = () => clampUnit(this.labelReveal) * this.sliceOpacity(arc.index);
|
|
195
|
-
const wantsCategory = this.labelMode === "both" || this.labelMode === "category";
|
|
196
|
-
const wantsPercent = this.labelMode === "both" || this.labelMode === "percent";
|
|
197
|
-
if (this.labelPlacement === "inside") {
|
|
198
|
-
// The number leads and the caption sits under it: inside a wedge the
|
|
199
|
-
// reader already knows *which* wedge they are looking at, so the magnitude
|
|
200
|
-
// is what the label is for. Both sizes are bound rather than read once —
|
|
201
|
-
// the fitted size derives from the ring's radius, which isn't known until
|
|
202
|
-
// the node has been laid out.
|
|
203
|
-
const inside = [];
|
|
204
|
-
if (wantsPercent) {
|
|
205
|
-
inside.push(new Text({
|
|
206
|
-
...textOps.styleProps(this.label.insideStyle),
|
|
207
|
-
text: () => this.percentText(arc.index),
|
|
208
|
-
fontSize: () => this.insideFont,
|
|
209
|
-
}));
|
|
210
|
-
}
|
|
211
|
-
if (wantsCategory) {
|
|
212
|
-
inside.push(new Text({
|
|
213
|
-
...textOps.styleProps(this.label.insideStyle),
|
|
214
|
-
text: () => this.slices[arc.index]?.category ?? "",
|
|
215
|
-
fontSize: () => this.labelMode === "category"
|
|
216
|
-
? this.insideFont
|
|
217
|
-
: this.insideFont * INSIDE_CAPTION_RATIO,
|
|
218
|
-
}));
|
|
219
|
-
}
|
|
220
|
-
this.add(new Rect({ flow: "vertical",
|
|
221
|
-
width: "hug",
|
|
222
|
-
height: "hug",
|
|
223
|
-
gap: 0,
|
|
224
|
-
align: "center",
|
|
225
|
-
opacity,
|
|
226
|
-
center: () => this.insideAnchor(arc.index),
|
|
227
|
-
children: inside,
|
|
228
|
-
}));
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
const side = this.sideOf(arc.mid);
|
|
232
|
-
const at = () => this.placementFor(arc.index)?.text ?? { x: 0, y: 0 };
|
|
233
|
-
const children = [];
|
|
234
|
-
if (wantsCategory) {
|
|
235
|
-
children.push(new Text({
|
|
236
|
-
...textOps.styleProps(this.label.textStyle),
|
|
237
|
-
text: () => this.slices[arc.index]?.category ?? "",
|
|
238
|
-
textAlign: side > 0 ? "left" : "right",
|
|
239
|
-
}));
|
|
240
|
-
}
|
|
241
|
-
if (wantsPercent) {
|
|
242
|
-
children.push(new Text({
|
|
243
|
-
...textOps.styleProps(this.label.percentStyle),
|
|
244
|
-
text: () => this.percentText(arc.index),
|
|
245
|
-
textAlign: side > 0 ? "left" : "right",
|
|
246
|
-
}));
|
|
247
|
-
}
|
|
248
|
-
this.add(new Rect({ flow: "vertical",
|
|
249
|
-
width: "hug",
|
|
250
|
-
height: "hug",
|
|
251
|
-
gap: 2,
|
|
252
|
-
align: side > 0 ? "centerLeft" : "centerRight",
|
|
253
|
-
opacity,
|
|
254
|
-
// Exactly one of these is set; `undefined` anchors are ignored.
|
|
255
|
-
centerLeft: side > 0 ? at : undefined,
|
|
256
|
-
centerRight: side > 0 ? undefined : at,
|
|
257
|
-
children,
|
|
258
|
-
}));
|
|
259
|
-
}
|
|
260
|
-
/** Slice `index`'s share of the ring as its label reads it, off the live values. */
|
|
261
|
-
percentText(index) {
|
|
262
|
-
const arc = this.arcs.find((a) => a.index === index);
|
|
263
|
-
return formatPercent(arc?.fraction ?? 0);
|
|
264
|
-
}
|
|
265
|
-
/** Build the hole's summary block. */
|
|
266
|
-
addCenterLabel() {
|
|
267
|
-
const children = [];
|
|
268
|
-
if (this.centerLabel !== "") {
|
|
269
|
-
children.push(new Text({
|
|
270
|
-
...textOps.styleProps(this.centerStyle.labelStyle),
|
|
271
|
-
text: this.centerLabel,
|
|
272
|
-
fontSize: () => this.centerFont,
|
|
273
|
-
}));
|
|
274
|
-
}
|
|
275
|
-
if (this.centerCaption !== "") {
|
|
276
|
-
children.push(new Text({
|
|
277
|
-
...textOps.styleProps(this.centerStyle.captionStyle),
|
|
278
|
-
text: this.centerCaption,
|
|
279
|
-
fontSize: () => this.centerFont *
|
|
280
|
-
(sizeOf(this.centerStyle.captionStyle, 22) /
|
|
281
|
-
sizeOf(this.centerStyle.labelStyle, 46)),
|
|
282
|
-
}));
|
|
283
|
-
}
|
|
284
|
-
this.add(new Rect({ flow: "vertical",
|
|
285
|
-
width: "hug",
|
|
286
|
-
height: "hug",
|
|
287
|
-
gap: 2,
|
|
288
|
-
align: "center",
|
|
289
|
-
opacity: () => clampUnit(this.centerReveal),
|
|
290
|
-
center: () => ({ x: this.centerX, y: 0 }),
|
|
291
|
-
children,
|
|
292
|
-
}));
|
|
293
|
-
}
|
|
294
|
-
// ---- Arcs --------------------------------------------------------------
|
|
295
121
|
/** The drawn slices' angular spans, re-read every frame so a value tween re-cuts them. */
|
|
296
122
|
get arcs() {
|
|
297
|
-
return pieArcs(this.slices, this.ring.startAngle);
|
|
298
|
-
}
|
|
299
|
-
/** Which side a leader off bisector `mid` runs to: +1 right, −1 left. */
|
|
300
|
-
sideOf(mid) {
|
|
301
|
-
return Math.sin(mid) >= 0 ? 1 : -1;
|
|
123
|
+
return pieArcs(this.s.slices, this.s.ring.startAngle);
|
|
302
124
|
}
|
|
303
125
|
/** Whether any slice is labelled on a leader — the only thing that costs a gutter. */
|
|
304
126
|
get hasLeaderLabels() {
|
|
305
|
-
return
|
|
306
|
-
this.labelMode !== "none" &&
|
|
307
|
-
this.arcs.length > 0);
|
|
127
|
+
return this.s.labelPlacement === "leader" && this.s.labelMode !== "none" && this.arcs.length > 0;
|
|
308
128
|
}
|
|
309
129
|
// ---- Label sizing ------------------------------------------------------
|
|
310
130
|
/**
|
|
@@ -336,17 +156,12 @@ export class PieRing extends Node2D {
|
|
|
336
156
|
}
|
|
337
157
|
/** Widest line, in glyphs at the leading line's size, an inside label runs to. */
|
|
338
158
|
insideGlyphs(arc) {
|
|
339
|
-
const
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
: 0;
|
|
159
|
+
const mode = this.s.labelMode;
|
|
160
|
+
const category = this.s.slices[arc.index]?.category.length ?? 0;
|
|
161
|
+
const percent = mode === "both" || mode === "percent" ? formatPercent(arc.fraction).length : 0;
|
|
343
162
|
// The caption is set at a fraction of the leading line, except when it *is*
|
|
344
163
|
// the leading line.
|
|
345
|
-
const caption =
|
|
346
|
-
? category
|
|
347
|
-
: this.labelMode === "both"
|
|
348
|
-
? category * INSIDE_CAPTION_RATIO
|
|
349
|
-
: 0;
|
|
164
|
+
const caption = mode === "category" ? category : mode === "both" ? category * INSIDE_CAPTION_RATIO : 0;
|
|
350
165
|
return Math.max(1, percent, caption);
|
|
351
166
|
}
|
|
352
167
|
/**
|
|
@@ -359,18 +174,13 @@ export class PieRing extends Node2D {
|
|
|
359
174
|
* for the size the chart wants and the ring keeps it legal.
|
|
360
175
|
*/
|
|
361
176
|
get insideFont() {
|
|
362
|
-
let size = sizeOf(this.label.insideStyle, FALLBACK_SIZE * 1.9);
|
|
177
|
+
let size = sizeOf(this.s.label.insideStyle, FALLBACK_SIZE * 1.9);
|
|
363
178
|
for (const arc of this.arcs) {
|
|
364
|
-
const fits = (this.insideRoom(arc) * INSIDE_LABEL_FIT) /
|
|
365
|
-
(this.insideGlyphs(arc) * GLYPH_RATIO);
|
|
179
|
+
const fits = (this.insideRoom(arc) * INSIDE_LABEL_FIT) / (this.insideGlyphs(arc) * GLYPH_RATIO);
|
|
366
180
|
size = Math.min(size, fits);
|
|
367
181
|
}
|
|
368
182
|
return Math.max(1, size);
|
|
369
183
|
}
|
|
370
|
-
/** Whether the hole carries a summary at all. */
|
|
371
|
-
get hasCenterLabel() {
|
|
372
|
-
return this.centerLabel !== "" || this.centerCaption !== "";
|
|
373
|
-
}
|
|
374
184
|
/**
|
|
375
185
|
* Font size of the hole's summary: the configured size, capped so it still
|
|
376
186
|
* fits across the hole. The cap matters because the hole's radius derives from
|
|
@@ -378,21 +188,22 @@ export class PieRing extends Node2D {
|
|
|
378
188
|
* through the ring.
|
|
379
189
|
*/
|
|
380
190
|
get centerFont() {
|
|
381
|
-
const
|
|
191
|
+
const { centerStyle, centerLabel, centerCaption } = this.s;
|
|
192
|
+
const wanted = sizeOf(centerStyle.labelStyle, 46);
|
|
382
193
|
const hole = this.holeRadius;
|
|
383
194
|
if (hole <= 0)
|
|
384
195
|
return wanted;
|
|
385
|
-
const captionRatio = sizeOf(
|
|
196
|
+
const captionRatio = sizeOf(centerStyle.captionStyle, 22) / Math.max(1, wanted);
|
|
386
197
|
// Both lines have to clear the hole, and the caption is set at a fraction of
|
|
387
198
|
// this size — so what binds is whichever is wider once drawn, not whichever
|
|
388
199
|
// has more characters.
|
|
389
|
-
const glyphs = Math.max(1,
|
|
200
|
+
const glyphs = Math.max(1, centerLabel.length, centerCaption.length * captionRatio);
|
|
390
201
|
return Math.min(wanted, (hole * 2 * CENTER_LABEL_FIT) / (glyphs * GLYPH_RATIO));
|
|
391
202
|
}
|
|
392
|
-
// ---- Ring geometry, all derived from
|
|
203
|
+
// ---- Ring geometry, all derived from the box ---------------------------
|
|
393
204
|
/** Rim radius of a fully selected slice as a multiple of the ring's, clamped sane. */
|
|
394
205
|
get growFactor() {
|
|
395
|
-
return Math.max(1, Math.min(1.6, this.ring.selectedGrow));
|
|
206
|
+
return Math.max(1, Math.min(1.6, this.s.ring.selectedGrow));
|
|
396
207
|
}
|
|
397
208
|
/** How far past the ring's rim a fully selected slice reaches, in px. */
|
|
398
209
|
get maxPop() {
|
|
@@ -404,19 +215,18 @@ export class PieRing extends Node2D {
|
|
|
404
215
|
// they cost no vertical reserve.
|
|
405
216
|
if (!this.hasLeaderLabels)
|
|
406
217
|
return 0;
|
|
407
|
-
const caption = sizeOf(this.label.textStyle) * 1.25;
|
|
408
|
-
const percent = sizeOf(this.label.percentStyle) * 1.25;
|
|
409
|
-
if (this.labelMode === "both")
|
|
218
|
+
const caption = sizeOf(this.s.label.textStyle) * 1.25;
|
|
219
|
+
const percent = sizeOf(this.s.label.percentStyle) * 1.25;
|
|
220
|
+
if (this.s.labelMode === "both")
|
|
410
221
|
return caption + percent + 2;
|
|
411
|
-
return this.labelMode === "category" ? caption : percent;
|
|
222
|
+
return this.s.labelMode === "category" ? caption : percent;
|
|
412
223
|
}
|
|
413
224
|
/** Widest line, in glyphs, that a leader label for slice `index` can run to. */
|
|
414
225
|
captionGlyphs(index) {
|
|
226
|
+
const mode = this.s.labelMode;
|
|
415
227
|
// "100%" is the widest a percentage gets; captions are their own length.
|
|
416
|
-
const percent =
|
|
417
|
-
const category =
|
|
418
|
-
? (this.slices[index]?.category.length ?? 0)
|
|
419
|
-
: 0;
|
|
228
|
+
const percent = mode === "percent" || mode === "both" ? 4 : 0;
|
|
229
|
+
const category = mode === "category" || mode === "both" ? (this.s.slices[index]?.category.length ?? 0) : 0;
|
|
420
230
|
return Math.max(percent, category);
|
|
421
231
|
}
|
|
422
232
|
/**
|
|
@@ -431,14 +241,10 @@ export class PieRing extends Node2D {
|
|
|
431
241
|
const gutters = { left: BARE_PAD, right: BARE_PAD };
|
|
432
242
|
if (!this.hasLeaderLabels)
|
|
433
243
|
return gutters;
|
|
434
|
-
const glyph = sizeOf(this.label.textStyle) * GLYPH_RATIO;
|
|
244
|
+
const glyph = sizeOf(this.s.label.textStyle) * GLYPH_RATIO;
|
|
435
245
|
for (const arc of this.arcs) {
|
|
436
|
-
const room = LEADER_LENGTH +
|
|
437
|
-
|
|
438
|
-
TEXT_INSET +
|
|
439
|
-
this.captionGlyphs(arc.index) * glyph +
|
|
440
|
-
LABEL_PAD;
|
|
441
|
-
const key = this.sideOf(arc.mid) > 0 ? "right" : "left";
|
|
246
|
+
const room = LEADER_LENGTH + LABEL_STUB + TEXT_INSET + this.captionGlyphs(arc.index) * glyph + LABEL_PAD;
|
|
247
|
+
const key = sideOf(arc.mid) > 0 ? "right" : "left";
|
|
442
248
|
gutters[key] = Math.max(gutters[key], room);
|
|
443
249
|
}
|
|
444
250
|
return gutters;
|
|
@@ -461,12 +267,12 @@ export class PieRing extends Node2D {
|
|
|
461
267
|
*/
|
|
462
268
|
get outerRadius() {
|
|
463
269
|
const { left, right } = this.labelGutters;
|
|
464
|
-
const room = Math.min((this.
|
|
270
|
+
const room = Math.min((this.box.width - left - right) / 2, this.box.height / 2 - this.labelRowHeight - 4);
|
|
465
271
|
return Math.max(0, room / this.growFactor);
|
|
466
272
|
}
|
|
467
273
|
/** Radius of the hole in the middle: a fraction of the outer radius, or explicit px. */
|
|
468
274
|
get holeRadius() {
|
|
469
|
-
const configured = this.ring.holeRadius;
|
|
275
|
+
const configured = this.s.ring.holeRadius;
|
|
470
276
|
if (configured <= 0)
|
|
471
277
|
return 0;
|
|
472
278
|
const px = configured < 1 ? configured * this.outerRadius : configured;
|
|
@@ -478,7 +284,7 @@ export class PieRing extends Node2D {
|
|
|
478
284
|
}
|
|
479
285
|
/** How far slice `index` is currently pushed out, in `[0, 1]`. */
|
|
480
286
|
popOf(index) {
|
|
481
|
-
return clampUnit(this.pops[index] ?? 0);
|
|
287
|
+
return clampUnit(this.s.pops[index] ?? 0);
|
|
482
288
|
}
|
|
483
289
|
/** Radius of slice `index`'s outer edge, its selection growth included. */
|
|
484
290
|
anchorRadius(index) {
|
|
@@ -495,7 +301,7 @@ export class PieRing extends Node2D {
|
|
|
495
301
|
const hole = this.holeRadius;
|
|
496
302
|
return hole > 0 ? (hole + rim) / 2 : rim * PIE_LABEL_RADIUS;
|
|
497
303
|
}
|
|
498
|
-
/**
|
|
304
|
+
/** The point slice `index`'s inside label is centred on. */
|
|
499
305
|
insideAnchor(index) {
|
|
500
306
|
const arc = this.arcs.find((a) => a.index === index);
|
|
501
307
|
if (arc === undefined)
|
|
@@ -510,14 +316,13 @@ export class PieRing extends Node2D {
|
|
|
510
316
|
*/
|
|
511
317
|
get selectionStrength() {
|
|
512
318
|
let strongest = 0;
|
|
513
|
-
for (let i = 0; i < this.slices.length; i++)
|
|
319
|
+
for (let i = 0; i < this.s.slices.length; i++)
|
|
514
320
|
strongest = Math.max(strongest, this.popOf(i));
|
|
515
|
-
}
|
|
516
321
|
return strongest;
|
|
517
322
|
}
|
|
518
323
|
/**
|
|
519
324
|
* Opacity slice `index` (and its leader and label) is drawn at: full while
|
|
520
|
-
* nothing is selected, falling toward
|
|
325
|
+
* nothing is selected, falling toward `dimOpacity` in proportion to how
|
|
521
326
|
* selected the *rest* of the ring is. A slice that's selected itself stays at
|
|
522
327
|
* full — the two factors cancel.
|
|
523
328
|
*/
|
|
@@ -525,7 +330,12 @@ export class PieRing extends Node2D {
|
|
|
525
330
|
const focus = this.selectionStrength;
|
|
526
331
|
if (focus <= 0)
|
|
527
332
|
return 1;
|
|
528
|
-
return 1 - (1 - clampUnit(this.dimOpacity)) * focus * (1 - this.popOf(index));
|
|
333
|
+
return 1 - (1 - clampUnit(this.s.dimOpacity)) * focus * (1 - this.popOf(index));
|
|
334
|
+
}
|
|
335
|
+
/** Slice `index`'s share of the ring as its label reads it, off the live values. */
|
|
336
|
+
percentText(index) {
|
|
337
|
+
const arc = this.arcs.find((a) => a.index === index);
|
|
338
|
+
return formatPercent(arc?.fraction ?? 0);
|
|
529
339
|
}
|
|
530
340
|
// ---- Label placement ---------------------------------------------------
|
|
531
341
|
/**
|
|
@@ -544,21 +354,15 @@ export class PieRing extends Node2D {
|
|
|
544
354
|
return [];
|
|
545
355
|
const radius = this.elbowRadius;
|
|
546
356
|
const rowHeight = this.labelRowHeight;
|
|
547
|
-
const top = this.
|
|
357
|
+
const top = this.box.height / 2 - rowHeight / 2;
|
|
548
358
|
const bottom = -top;
|
|
549
359
|
const cx = this.centerX;
|
|
550
360
|
const right = [];
|
|
551
361
|
const left = [];
|
|
552
362
|
for (const arc of arcs) {
|
|
553
|
-
const side =
|
|
363
|
+
const side = sideOf(arc.mid);
|
|
554
364
|
const ideal = ringPoint(radius, arc.mid).y;
|
|
555
|
-
(side > 0 ? right : left).push({
|
|
556
|
-
index: arc.index,
|
|
557
|
-
side,
|
|
558
|
-
mid: arc.mid,
|
|
559
|
-
ideal,
|
|
560
|
-
y: ideal,
|
|
561
|
-
});
|
|
365
|
+
(side > 0 ? right : left).push({ index: arc.index, side, mid: arc.mid, ideal, y: ideal });
|
|
562
366
|
}
|
|
563
367
|
relaxLabels(right, rowHeight + LABEL_GAP, top, bottom);
|
|
564
368
|
relaxLabels(left, rowHeight + LABEL_GAP, top, bottom);
|
|
@@ -587,152 +391,290 @@ export class PieRing extends Node2D {
|
|
|
587
391
|
placementFor(index) {
|
|
588
392
|
return this.placements().find((placement) => placement.index === index);
|
|
589
393
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
394
|
+
}
|
|
395
|
+
/** Which side a leader off bisector `mid` runs to: +1 right, −1 left. */
|
|
396
|
+
function sideOf(mid) {
|
|
397
|
+
return Math.sin(mid) >= 0 ? 1 : -1;
|
|
398
|
+
}
|
|
399
|
+
const NO_BOX = { width: 0, height: 0 };
|
|
400
|
+
/**
|
|
401
|
+
* Each slice's fill, resolved once per paint rather than every frame: the slices
|
|
402
|
+
* are bound to the chart's rows and palette, so a theme tween hands over new
|
|
403
|
+
* paint and the drawing must see it, but a frame that moves nothing must not
|
|
404
|
+
* re-resolve. Keyed by the paint itself, so it is shared and needs no node.
|
|
405
|
+
*/
|
|
406
|
+
const resolvedFills = new WeakMap();
|
|
407
|
+
function resolvedFill(fill) {
|
|
408
|
+
if (typeof fill !== "object" || fill === null)
|
|
409
|
+
return fillOps.resolve(fill);
|
|
410
|
+
let resolved = resolvedFills.get(fill);
|
|
411
|
+
if (resolved === undefined)
|
|
412
|
+
resolvedFills.set(fill, resolved = fillOps.resolve(fill));
|
|
413
|
+
return resolved;
|
|
414
|
+
}
|
|
415
|
+
/** Each slice's fill, resolved. */
|
|
416
|
+
export function ringFills(slices) {
|
|
417
|
+
return slices.map((slice) => resolvedFill(slice.fill));
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The labels a ring was built with: one per *drawn* slice (a zero-valued slice
|
|
421
|
+
* gets no wedge, so no label either), in whichever of the two forms the chart
|
|
422
|
+
* asked for, then the hole's summary. Both are real Text stacked in a hug
|
|
423
|
+
* vertical flow — the difference is only what they are pinned to and which line
|
|
424
|
+
* leads. The point each is pinned to stays reactive and follows the ring as the
|
|
425
|
+
* box resizes or its slice pops out; only the choice of form, which depends on
|
|
426
|
+
* entries alone, is settled here.
|
|
427
|
+
*/
|
|
428
|
+
function ringLabels(s) {
|
|
429
|
+
const blocks = [];
|
|
430
|
+
// A label block's parent is the ring: its box is what the ring was laid out at.
|
|
431
|
+
const geometry = () => new RingGeometry(s, blocks[0]?.parent?.layoutBounds ?? NO_BOX);
|
|
432
|
+
const label = (arc) => {
|
|
433
|
+
// Fades in with the ring's labels, then dims along with its own slice
|
|
434
|
+
// whenever another slice takes the focus.
|
|
435
|
+
const opacity = () => clampUnit(s.labelReveal) * geometry().sliceOpacity(arc.index);
|
|
436
|
+
const wantsCategory = s.labelMode === "both" || s.labelMode === "category";
|
|
437
|
+
const wantsPercent = s.labelMode === "both" || s.labelMode === "percent";
|
|
438
|
+
if (s.labelPlacement === "inside") {
|
|
439
|
+
// The number leads and the caption sits under it: inside a wedge the
|
|
440
|
+
// reader already knows *which* wedge they are looking at, so the magnitude
|
|
441
|
+
// is what the label is for. Both sizes are bound rather than read once —
|
|
442
|
+
// the fitted size derives from the ring's radius, which isn't known until
|
|
443
|
+
// the node has been laid out.
|
|
444
|
+
const inside = [];
|
|
445
|
+
if (wantsPercent) {
|
|
446
|
+
inside.push(new Text({
|
|
447
|
+
...textOps.styleProps(s.label.insideStyle),
|
|
448
|
+
text: () => geometry().percentText(arc.index),
|
|
449
|
+
fontSize: () => geometry().insideFont,
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
if (wantsCategory) {
|
|
453
|
+
inside.push(new Text({
|
|
454
|
+
...textOps.styleProps(s.label.insideStyle),
|
|
455
|
+
text: () => s.slices[arc.index]?.category ?? "",
|
|
456
|
+
fontSize: () => (s.labelMode === "category" ? geometry().insideFont : geometry().insideFont * INSIDE_CAPTION_RATIO),
|
|
457
|
+
}));
|
|
458
|
+
}
|
|
459
|
+
return new Rect({
|
|
460
|
+
flow: "vertical",
|
|
461
|
+
width: "hug",
|
|
462
|
+
height: "hug",
|
|
463
|
+
gap: 0,
|
|
464
|
+
align: "center",
|
|
465
|
+
opacity,
|
|
466
|
+
center: () => geometry().insideAnchor(arc.index),
|
|
467
|
+
children: inside,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
const side = sideOf(arc.mid);
|
|
471
|
+
const at = () => geometry().placementFor(arc.index)?.text ?? { x: 0, y: 0 };
|
|
472
|
+
const children = [];
|
|
473
|
+
if (wantsCategory) {
|
|
474
|
+
children.push(new Text({
|
|
475
|
+
...textOps.styleProps(s.label.textStyle),
|
|
476
|
+
text: () => s.slices[arc.index]?.category ?? "",
|
|
477
|
+
textAlign: side > 0 ? "left" : "right",
|
|
478
|
+
}));
|
|
479
|
+
}
|
|
480
|
+
if (wantsPercent) {
|
|
481
|
+
children.push(new Text({
|
|
482
|
+
...textOps.styleProps(s.label.percentStyle),
|
|
483
|
+
text: () => geometry().percentText(arc.index),
|
|
484
|
+
textAlign: side > 0 ? "left" : "right",
|
|
485
|
+
}));
|
|
486
|
+
}
|
|
487
|
+
return new Rect({
|
|
488
|
+
flow: "vertical",
|
|
489
|
+
width: "hug",
|
|
490
|
+
height: "hug",
|
|
491
|
+
gap: 2,
|
|
492
|
+
align: side > 0 ? "centerLeft" : "centerRight",
|
|
493
|
+
opacity,
|
|
494
|
+
// Exactly one of these is set; `undefined` anchors are ignored.
|
|
495
|
+
centerLeft: side > 0 ? at : undefined,
|
|
496
|
+
centerRight: side > 0 ? undefined : at,
|
|
497
|
+
children,
|
|
610
498
|
});
|
|
499
|
+
};
|
|
500
|
+
if (s.labelPlacement !== "none" && s.labelMode !== "none") {
|
|
501
|
+
for (const arc of pieArcs(s.slices, s.ring.startAngle))
|
|
502
|
+
blocks.push(label(arc));
|
|
611
503
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
const halfGap = Math.max(0, this.ring.gap) / 2;
|
|
623
|
-
// One shared frame (the whole ring square, grown slices included) pinned onto
|
|
624
|
-
// every wedge path, so `Graphics2D.path()` doesn't re-centre each wedge on its
|
|
625
|
-
// own bbox and collapse them all onto the origin. It stays symmetric about the
|
|
626
|
-
// *node's* origin — so path coordinates land exactly in node space — and is
|
|
627
|
-
// grown by the ring's sideways offset rather than moved with it, which would
|
|
628
|
-
// undo the very shift it has to contain.
|
|
629
|
-
const cx = this.centerX;
|
|
630
|
-
const half = rOuter + this.maxPop + 1 + Math.abs(cx);
|
|
631
|
-
const centerBounds = [
|
|
632
|
-
-half,
|
|
633
|
-
-half,
|
|
634
|
-
half,
|
|
635
|
-
half,
|
|
636
|
-
];
|
|
637
|
-
// Explicit width/height alongside centerBounds: a wedge path's command list
|
|
638
|
-
// carries no implied extent, so a slice filled with an image or a video needs
|
|
639
|
-
// this to size its decode.
|
|
640
|
-
const span = half * 2;
|
|
641
|
-
const fills = this.resolvedFills;
|
|
642
|
-
for (const arc of arcs) {
|
|
643
|
-
// Clip the wedge to the growth-revealed arc; skip it until the sweep
|
|
644
|
-
// reaches it, so the ring draws in as one clockwise wipe.
|
|
645
|
-
const a1 = Math.min(arc.a1, revealEnd);
|
|
646
|
-
if (a1 <= arc.a0)
|
|
647
|
-
continue;
|
|
648
|
-
const path = wedgePath(arc.a0, a1, hole, this.anchorRadius(arc.index), halfGap, cx);
|
|
649
|
-
const g = new Graphics2D().path(path.toPathState({ centerBounds, width: span, height: span }));
|
|
650
|
-
g.shadow(this.ring.shadow);
|
|
651
|
-
g.fill(fills[arc.index] ?? []);
|
|
652
|
-
g.stroke(this.ring.stroke);
|
|
653
|
-
ctx.draw(g.opacity(this.sliceOpacity(arc.index)));
|
|
504
|
+
// The hole's summary, pinned to the ring's centre so it tracks the sideways
|
|
505
|
+
// shift a one-sided label column puts on the ring.
|
|
506
|
+
if (s.centerLabel !== "" || s.centerCaption !== "") {
|
|
507
|
+
const children = [];
|
|
508
|
+
if (s.centerLabel !== "") {
|
|
509
|
+
children.push(new Text({
|
|
510
|
+
...textOps.styleProps(s.centerStyle.labelStyle),
|
|
511
|
+
text: s.centerLabel,
|
|
512
|
+
fontSize: () => geometry().centerFont,
|
|
513
|
+
}));
|
|
654
514
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
return;
|
|
662
|
-
for (const placement of this.placements()) {
|
|
663
|
-
ctx.draw(new Graphics2D()
|
|
664
|
-
.line({ points: placement.leader, radius: ELBOW_RADIUS })
|
|
665
|
-
.stroke(this.label.leaderStroke)
|
|
666
|
-
.opacity(reveal * this.sliceOpacity(placement.index)));
|
|
515
|
+
if (s.centerCaption !== "") {
|
|
516
|
+
children.push(new Text({
|
|
517
|
+
...textOps.styleProps(s.centerStyle.captionStyle),
|
|
518
|
+
text: s.centerCaption,
|
|
519
|
+
fontSize: () => geometry().centerFont * (sizeOf(s.centerStyle.captionStyle, 22) / sizeOf(s.centerStyle.labelStyle, 46)),
|
|
520
|
+
}));
|
|
667
521
|
}
|
|
522
|
+
blocks.push(new Rect({
|
|
523
|
+
flow: "vertical",
|
|
524
|
+
width: "hug",
|
|
525
|
+
height: "hug",
|
|
526
|
+
gap: 2,
|
|
527
|
+
align: "center",
|
|
528
|
+
opacity: () => clampUnit(s.centerReveal),
|
|
529
|
+
center: () => ({ x: geometry().centerX, y: 0 }),
|
|
530
|
+
children,
|
|
531
|
+
}));
|
|
668
532
|
}
|
|
669
|
-
|
|
670
|
-
/** Tween {@link growth} `0 → 1`, wiping the wedges in around the ring. */
|
|
671
|
-
sweep(args) {
|
|
672
|
-
// No priming to 0 first — the value at `t` is a function of `t`, so a repeat
|
|
673
|
-
// call re-sweeps by definition rather than by having reset something.
|
|
674
|
-
return this.command((t) => ({ growth: t }), args.duration, args.easing ?? easeOut());
|
|
675
|
-
}
|
|
676
|
-
/** Tween {@link labelReveal} `0 → 1`, fading the leaders and captions up. */
|
|
677
|
-
revealLabels(args) {
|
|
678
|
-
return this.command((t) => ({ labelReveal: t }), args.duration, args.easing ?? easeOut());
|
|
679
|
-
}
|
|
680
|
-
/**
|
|
681
|
-
* Fade the hole's summary up. Deliberately not part of the ring's entrance: a
|
|
682
|
-
* hole label is usually the chart's conclusion, so the scene decides when it
|
|
683
|
-
* lands.
|
|
684
|
-
*/
|
|
685
|
-
revealCenter(args = {}) {
|
|
686
|
-
const from = clampUnit(this.centerReveal);
|
|
687
|
-
return this.command((t) => ({ centerReveal: from + (1 - from) * t }), args.duration ?? 0.6, args.easing ?? easeOut());
|
|
688
|
-
}
|
|
533
|
+
return asParts(blocks);
|
|
689
534
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
535
|
+
/**
|
|
536
|
+
* The ring itself: one wedge per {@link PieSlice}, sized by its share of the
|
|
537
|
+
* total, swept clockwise from the theme's `startAngle` with a `gap` of clear
|
|
538
|
+
* space between neighbours — a *constant-width* channel, the same at the hole as
|
|
539
|
+
* at the rim (see {@link wedgePath}). A **selected** slice reaches further out
|
|
540
|
+
* while its hole edge stays put — so it reads as that band swelling out of the
|
|
541
|
+
* ring rather than breaking off it — and the rest of the ring dims behind it,
|
|
542
|
+
* leaders and labels included.
|
|
543
|
+
*
|
|
544
|
+
* This is the only part of the chart family that draws a path: the wedges are
|
|
545
|
+
* filled {@link PathBuilder} polygons approximating each arc, and the leaders are
|
|
546
|
+
* stroked polylines — both in the node's centred, y-up space, so neither depends
|
|
547
|
+
* on SVG arc-sweep conventions. The **labels are real {@link Text} nodes**
|
|
548
|
+
* (stacked in a hug vertical flow and pinned by their inner edge), so no
|
|
549
|
+
* glyph-width guesswork decides where a caption starts.
|
|
550
|
+
*
|
|
551
|
+
* ### Label placement
|
|
552
|
+
* Each label sits either **inside** its wedge or **outside** on a leader.
|
|
553
|
+
*
|
|
554
|
+
* An inside label is centred on its slice's band and leads with the number, the
|
|
555
|
+
* caption set small underneath — inside a wedge the reader already knows which
|
|
556
|
+
* slice they're on, so the magnitude is the thing being read. It costs no gutter
|
|
557
|
+
* and no eye round-trip, so a ring labelled that way draws considerably bigger in
|
|
558
|
+
* the same box.
|
|
559
|
+
*
|
|
560
|
+
* A leader label sits outside on the end of a polyline that lifts off its wedge,
|
|
561
|
+
* elbows, and runs horizontally into the text. Labels are split into a left and a
|
|
562
|
+
* right column by which way their slice's bisector points, and each column is
|
|
563
|
+
* de-conflicted independently (see {@link relaxLabels}): a label takes the spot
|
|
564
|
+
* nearest its wedge that still clears its neighbour, and the column is squeezed
|
|
565
|
+
* to fit the node's height rather than spilling out of it. The elbow then rides on
|
|
566
|
+
* a circle just outside the ring **at the label's settled y**, so the run into the
|
|
567
|
+
* text is always horizontal and a nudged label never drags its leader back across
|
|
568
|
+
* the ring.
|
|
569
|
+
*
|
|
570
|
+
* Only the sides that actually carry a leader reserve a gutter, and the ring's
|
|
571
|
+
* centre slides into whatever the other side left over — so a ring whose slices
|
|
572
|
+
* all leader one way doesn't leave a bald patch on the other.
|
|
573
|
+
*/
|
|
574
|
+
export const PieRing = component2d({
|
|
575
|
+
key: "pieRing",
|
|
576
|
+
state: {
|
|
577
|
+
slices: fresh(() => []),
|
|
578
|
+
ring: Types.custom(DEFAULT_PIE_CHART_THEME.ring),
|
|
579
|
+
label: Types.custom(DEFAULT_PIE_CHART_THEME.label),
|
|
580
|
+
centerStyle: Types.custom(DEFAULT_PIE_CHART_THEME.center),
|
|
581
|
+
labelMode: Types.custom("percent"),
|
|
582
|
+
labelPlacement: Types.custom("leader"),
|
|
583
|
+
growth: 1,
|
|
584
|
+
labelReveal: 1,
|
|
585
|
+
pops: fresh(() => []),
|
|
586
|
+
dimOpacity: DEFAULT_PIE_CHART_THEME.spotlight.dimOpacity,
|
|
587
|
+
centerLabel: "",
|
|
588
|
+
centerCaption: "",
|
|
589
|
+
centerReveal: 1,
|
|
590
|
+
},
|
|
591
|
+
// A plain node filled its parent; one made of its labels would hug them.
|
|
592
|
+
defaults: { width: "fill", height: "fill" },
|
|
593
|
+
children: (state) => ringLabels(state),
|
|
594
|
+
// The ring's paints live nowhere a paint entry would look: one fill per slice,
|
|
595
|
+
// the wedge outline and drop shadow of `ring`, and the leader line's own stroke.
|
|
596
|
+
load: (loader, state) => loadPaints(loader, {
|
|
597
|
+
fills: ringFills(state.slices),
|
|
598
|
+
shadows: [shadowOps.resolve(state.ring.shadow)],
|
|
599
|
+
strokes: [strokeOps.resolve(state.ring.stroke), strokeOps.resolve(state.label.leaderStroke)],
|
|
600
|
+
}),
|
|
601
|
+
commands: {
|
|
602
|
+
/**
|
|
603
|
+
* Tween `growth` `0 → 1`, wiping the wedges in around the ring. No priming
|
|
604
|
+
* to 0 first — the value at `t` is a function of `t`, so a repeat call
|
|
605
|
+
* re-sweeps by definition rather than by having reset something.
|
|
606
|
+
*/
|
|
607
|
+
sweep: (timeline) => {
|
|
608
|
+
const ease = frameEase(timeline);
|
|
609
|
+
return timeline.animate((t) => ({ growth: ease(t) }));
|
|
610
|
+
},
|
|
611
|
+
/** Tween `labelReveal` `0 → 1`, fading the leaders and captions up. */
|
|
612
|
+
revealLabels: (timeline) => {
|
|
613
|
+
const ease = frameEase(timeline);
|
|
614
|
+
return timeline.animate((t) => ({ labelReveal: ease(t) }));
|
|
615
|
+
},
|
|
616
|
+
/**
|
|
617
|
+
* Fade the hole's summary up. Deliberately not part of the ring's entrance: a
|
|
618
|
+
* hole label is usually the chart's conclusion, so the scene decides when it
|
|
619
|
+
* lands.
|
|
620
|
+
*/
|
|
621
|
+
revealCenter: (timeline, state) => {
|
|
622
|
+
const ease = frameEase(timeline);
|
|
623
|
+
const from = clampUnit(state.centerReveal);
|
|
624
|
+
return timeline.animate((t) => ({ centerReveal: from + (1 - from) * ease(t) }));
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
render: (ctx, state) => {
|
|
628
|
+
const g = new RingGeometry(state, ctx.size);
|
|
629
|
+
const rOuter = g.outerRadius;
|
|
630
|
+
const arcs = rOuter > 0 ? g.arcs : [];
|
|
631
|
+
if (arcs.length > 0) {
|
|
632
|
+
const sweep = clampUnit(state.growth);
|
|
633
|
+
const revealEnd = state.ring.startAngle * (Math.PI / 180) + Math.PI * 2 * sweep;
|
|
634
|
+
const hole = g.holeRadius;
|
|
635
|
+
const halfGap = Math.max(0, state.ring.gap) / 2;
|
|
636
|
+
// One shared frame (the whole ring square, grown slices included) pinned onto
|
|
637
|
+
// every wedge path, so `Graphics2D.path()` doesn't re-centre each wedge on its
|
|
638
|
+
// own bbox and collapse them all onto the origin. It stays symmetric about the
|
|
639
|
+
// *node's* origin — so path coordinates land exactly in node space — and is
|
|
640
|
+
// grown by the ring's sideways offset rather than moved with it, which would
|
|
641
|
+
// undo the very shift it has to contain.
|
|
642
|
+
const cx = g.centerX;
|
|
643
|
+
const half = rOuter + g.maxPop + 1 + Math.abs(cx);
|
|
644
|
+
const centerBounds = [-half, -half, half, half];
|
|
645
|
+
// Explicit width/height alongside centerBounds: a wedge path's command list
|
|
646
|
+
// carries no implied extent, so a slice filled with an image or a video needs
|
|
647
|
+
// this to size its decode.
|
|
648
|
+
const span = half * 2;
|
|
649
|
+
const fills = ringFills(state.slices);
|
|
650
|
+
for (const arc of arcs) {
|
|
651
|
+
// Clip the wedge to the growth-revealed arc; skip it until the sweep
|
|
652
|
+
// reaches it, so the ring draws in as one clockwise wipe.
|
|
653
|
+
const a1 = Math.min(arc.a1, revealEnd);
|
|
654
|
+
if (a1 <= arc.a0)
|
|
655
|
+
continue;
|
|
656
|
+
const path = wedgePath(arc.a0, a1, hole, g.anchorRadius(arc.index), halfGap, cx);
|
|
657
|
+
const wedge = new Graphics2D().path(path.toPathState({ centerBounds, width: span, height: span }));
|
|
658
|
+
wedge.shadow(state.ring.shadow);
|
|
659
|
+
wedge.fill(fills[arc.index] ?? []);
|
|
660
|
+
wedge.stroke(state.ring.stroke);
|
|
661
|
+
ctx.draw(wedge.opacity(g.sliceOpacity(arc.index)));
|
|
662
|
+
}
|
|
663
|
+
// Leaders. Each polyline gets its own `Graphics2D`: several open polylines
|
|
664
|
+
// chained into one command list are combined into a single surface before the
|
|
665
|
+
// stroke, which closes each contour back on itself and paints the leaders as
|
|
666
|
+
// thin triangles.
|
|
667
|
+
const reveal = clampUnit(state.labelReveal);
|
|
668
|
+
if (reveal > 0.001) {
|
|
669
|
+
for (const placement of g.placements()) {
|
|
670
|
+
ctx.draw(new Graphics2D()
|
|
671
|
+
.line({ points: placement.leader, radius: ELBOW_RADIUS })
|
|
672
|
+
.stroke(state.label.leaderStroke)
|
|
673
|
+
.opacity(reveal * g.sliceOpacity(placement.index)));
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
ctx.children();
|
|
678
|
+
},
|
|
679
|
+
});
|
|
738
680
|
//# sourceMappingURL=pie-ring.js.map
|