@motionscript/charts 0.1.0-alpha.0 → 0.1.0-alpha.10
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 +105 -0
- package/NOTICE +49 -0
- package/README.md +99 -0
- package/dist/bar-chart/bar-chart.d.ts +133 -228
- package/dist/bar-chart/bar-chart.d.ts.map +1 -1
- package/dist/bar-chart/bar-chart.js +386 -565
- 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 +155 -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 +7 -7
- package/dist/bar-chart/shared.d.ts.map +1 -1
- package/dist/bar-chart/shared.js +7 -7
- package/dist/bar-chart/shared.js.map +1 -1
- package/dist/browser/chunks/chunk-TJNHLQGV.js +2 -0
- package/dist/browser/chunks/chunk-TJNHLQGV.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 +4 -3
- package/dist/engine.d.ts +10 -8
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +10 -8
- 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 -85
- package/dist/kit/chart-legend.d.ts.map +1 -1
- package/dist/kit/chart-legend.js +137 -186
- 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 +61 -10
- package/dist/kit/shared.d.ts.map +1 -1
- package/dist/kit/shared.js +108 -13
- 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 +3 -3
- package/dist/line-chart/index.d.ts.map +1 -1
- package/dist/line-chart/index.js +2 -2
- package/dist/line-chart/index.js.map +1 -1
- package/dist/line-chart/line-chart.d.ts +156 -303
- package/dist/line-chart/line-chart.d.ts.map +1 -1
- package/dist/line-chart/line-chart.js +410 -638
- package/dist/line-chart/line-chart.js.map +1 -1
- package/dist/line-chart/shared.d.ts +7 -7
- package/dist/line-chart/shared.d.ts.map +1 -1
- package/dist/line-chart/shared.js +23 -8
- package/dist/line-chart/shared.js.map +1 -1
- package/dist/line-chart/x-scale.d.ts +4 -11
- package/dist/line-chart/x-scale.d.ts.map +1 -1
- package/dist/line-chart/x-scale.js +16 -5
- package/dist/line-chart/x-scale.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 -126
- package/dist/pie-chart/pie-chart.d.ts.map +1 -1
- package/dist/pie-chart/pie-chart.js +202 -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 -122
- package/dist/pie-chart/pie-ring.d.ts.map +1 -1
- package/dist/pie-chart/pie-ring.js +333 -361
- package/dist/pie-chart/pie-ring.js.map +1 -1
- package/dist/pie-chart/shared.d.ts +12 -6
- package/dist/pie-chart/shared.d.ts.map +1 -1
- package/dist/pie-chart/shared.js +14 -8
- package/dist/pie-chart/shared.js.map +1 -1
- package/package.json +7 -4
- package/src/bar-chart/bar-chart.ts +424 -686
- package/src/bar-chart/category-axis.ts +96 -111
- package/src/bar-chart/chart-bars.ts +178 -201
- package/src/bar-chart/index.ts +2 -2
- package/src/bar-chart/shared.ts +7 -7
- package/src/engine.ts +10 -8
- package/src/kit/cartesian.ts +81 -0
- package/src/kit/chart-border.ts +23 -34
- package/src/kit/chart-canvas.ts +45 -80
- package/src/kit/chart-legend.ts +185 -206
- 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 +120 -13
- package/src/kit/vertical-axis.ts +111 -141
- package/src/line-chart/chart-line.ts +103 -155
- package/src/line-chart/chart-region.ts +36 -81
- package/src/line-chart/horizontal-axis.ts +102 -153
- package/src/line-chart/index.ts +3 -3
- package/src/line-chart/line-chart.ts +463 -759
- package/src/line-chart/shared.ts +22 -8
- package/src/line-chart/x-scale.ts +20 -5
- package/src/nodes.ts +8 -10
- package/src/pie-chart/index.ts +2 -2
- package/src/pie-chart/pie-chart.ts +225 -303
- package/src/pie-chart/pie-legend.ts +125 -115
- package/src/pie-chart/pie-ring.ts +388 -443
- package/src/pie-chart/shared.ts +14 -8
- package/dist/browser/chunks/chunk-ZMBOBHYL.js +0 -2
- package/dist/browser/chunks/chunk-ZMBOBHYL.js.map +0 -7
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
import {
|
|
2
|
-
Rect,
|
|
3
|
-
command,
|
|
4
|
-
type AssetScope,
|
|
5
2
|
Graphics2D,
|
|
6
|
-
Node2D,
|
|
7
3
|
PathBuilder,
|
|
4
|
+
Rect,
|
|
8
5
|
Text,
|
|
9
|
-
|
|
6
|
+
Types,
|
|
7
|
+
component2d,
|
|
10
8
|
fillOps,
|
|
11
|
-
|
|
9
|
+
shadowOps,
|
|
12
10
|
strokeOps,
|
|
13
11
|
textOps,
|
|
14
|
-
type
|
|
15
|
-
type CommandArgs,
|
|
12
|
+
type Fill,
|
|
16
13
|
type FillResolved,
|
|
17
|
-
type
|
|
14
|
+
type NodeOf,
|
|
15
|
+
type Node2D,
|
|
18
16
|
type Node2DProps,
|
|
19
|
-
type RenderContext2D,
|
|
20
17
|
type TextStyle,
|
|
21
18
|
type Vector2,
|
|
22
|
-
} from "@motionscript/
|
|
19
|
+
} from "@motionscript/sdk"
|
|
23
20
|
|
|
21
|
+
import { asParts, fresh } from "../kit/entries"
|
|
22
|
+
import { frameEase } from "../kit/motion"
|
|
23
|
+
import { loadPaints } from "../kit/paints"
|
|
24
24
|
import { clampUnit, formatPercent } from "../kit/shared"
|
|
25
25
|
import {
|
|
26
26
|
DEFAULT_PIE_CHART_THEME,
|
|
@@ -35,7 +35,6 @@ import {
|
|
|
35
35
|
type PieSlice,
|
|
36
36
|
type RingConfig,
|
|
37
37
|
} from "./shared"
|
|
38
|
-
import { declarePaints } from "@motionscript/core/component"
|
|
39
38
|
|
|
40
39
|
export interface PieRingProps extends Node2DProps {
|
|
41
40
|
/** One entry per slice: its `category`, `value` and `fill`. */
|
|
@@ -217,234 +216,40 @@ function wedgePath(
|
|
|
217
216
|
return path
|
|
218
217
|
}
|
|
219
218
|
|
|
219
|
+
/** What the ring's geometry reads off its entries. */
|
|
220
|
+
interface RingState {
|
|
221
|
+
readonly slices: PieSlice[]
|
|
222
|
+
readonly ring: RingConfig
|
|
223
|
+
readonly label: LabelConfig
|
|
224
|
+
readonly centerStyle: CenterConfig
|
|
225
|
+
readonly labelMode: PieLabelMode
|
|
226
|
+
readonly labelPlacement: PieLabelPlacement
|
|
227
|
+
readonly pops: readonly number[]
|
|
228
|
+
readonly dimOpacity: number
|
|
229
|
+
readonly centerLabel: string
|
|
230
|
+
readonly centerCaption: string
|
|
231
|
+
}
|
|
232
|
+
|
|
220
233
|
/**
|
|
221
|
-
* The ring
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* while its hole edge stays put — so it reads as that band swelling out of the
|
|
226
|
-
* ring rather than breaking off it — and the rest of the ring dims behind it,
|
|
227
|
-
* leaders and labels included.
|
|
228
|
-
*
|
|
229
|
-
* This is the only part of the chart family that draws a path: the wedges are
|
|
230
|
-
* filled {@link PathBuilder} polygons approximating each arc, and the leaders are
|
|
231
|
-
* stroked polylines — both in the node's centred, y-up space, so neither depends
|
|
232
|
-
* on SVG arc-sweep conventions. The **labels are real {@link Text} nodes**
|
|
233
|
-
* (stacked in a hug vertical flow and pinned by their inner edge), so no
|
|
234
|
-
* glyph-width guesswork decides where a caption starts.
|
|
235
|
-
*
|
|
236
|
-
* ### Label placement
|
|
237
|
-
* Each label sits either **inside** its wedge or **outside** on a leader.
|
|
238
|
-
*
|
|
239
|
-
* An inside label is centred on its slice's band and leads with the number, the
|
|
240
|
-
* caption set small underneath — inside a wedge the reader already knows which
|
|
241
|
-
* slice they're on, so the magnitude is the thing being read. It costs no gutter
|
|
242
|
-
* and no eye round-trip, so a ring labelled that way draws considerably bigger in
|
|
243
|
-
* the same box.
|
|
244
|
-
*
|
|
245
|
-
* A leader label sits outside on the end of a polyline that lifts off its wedge,
|
|
246
|
-
* elbows, and runs horizontally into the text. Labels are split into a left and a
|
|
247
|
-
* right column by which way their slice's bisector points, and each column is
|
|
248
|
-
* de-conflicted independently (see {@link relaxLabels}): a label takes the spot
|
|
249
|
-
* nearest its wedge that still clears its neighbour, and the column is squeezed
|
|
250
|
-
* to fit the node's height rather than spilling out of it. The elbow then rides on
|
|
251
|
-
* a circle just outside the ring **at the label's settled y**, so the run into the
|
|
252
|
-
* text is always horizontal and a nudged label never drags its leader back across
|
|
253
|
-
* the ring.
|
|
254
|
-
*
|
|
255
|
-
* Only the sides that actually carry a leader reserve a gutter, and the ring's
|
|
256
|
-
* centre slides into whatever the other side left over ({@link centerX}) — so a
|
|
257
|
-
* ring whose slices all leader one way doesn't leave a bald patch on the other.
|
|
234
|
+
* The ring's geometry for one box: every radius, gutter and label position,
|
|
235
|
+
* derived from the entries and the size the ring was laid out at, and asked
|
|
236
|
+
* afresh wherever it is read — the drawing, and the labels' bound positions
|
|
237
|
+
* and sizes alike, so the two cannot disagree.
|
|
258
238
|
*/
|
|
259
|
-
export class
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
@property({ default: DEFAULT_PIE_CHART_THEME.center })
|
|
265
|
-
declare centerStyle: CenterConfig
|
|
266
|
-
@property({ default: "percent" }) declare labelMode: PieLabelMode
|
|
267
|
-
@property({ default: "leader" }) declare labelPlacement: PieLabelPlacement
|
|
268
|
-
@property({ default: 1 }) declare growth: number
|
|
269
|
-
@property({ default: 1 }) declare labelReveal: number
|
|
270
|
-
@property({ default: () => [] as number[] }) declare pops: number[]
|
|
271
|
-
@property({ default: DEFAULT_PIE_CHART_THEME.spotlight.dimOpacity })
|
|
272
|
-
declare dimOpacity: number
|
|
273
|
-
@property({ default: "" }) declare centerLabel: string
|
|
274
|
-
@property({ default: "" }) declare centerCaption: string
|
|
275
|
-
@property({ default: 1 }) declare centerReveal: number
|
|
276
|
-
|
|
277
|
-
/** Each slice's fill, resolved once so `renderSelf` doesn't re-resolve every frame. */
|
|
278
|
-
private fills: FillResolved[][] = []
|
|
279
|
-
|
|
280
|
-
constructor(props?: NodeConfig<PieRing, PieRingProps>) {
|
|
281
|
-
super(props)
|
|
282
|
-
|
|
283
|
-
this.fills = this.slices.map((slice) => fillOps.resolve(slice.fill))
|
|
284
|
-
|
|
285
|
-
// One label per *drawn* slice (a zero-valued slice gets no wedge, so no
|
|
286
|
-
// label either), in whichever of the two forms the chart asked for. Both are
|
|
287
|
-
// real Text stacked in a hug vertical flow — the difference is only what they are
|
|
288
|
-
// pinned to and which line leads.
|
|
289
|
-
//
|
|
290
|
-
// The *point* each label is pinned to stays reactive and follows the ring as
|
|
291
|
-
// the box resizes or its slice pops out; only the choice of form, which
|
|
292
|
-
// depends on props alone, is settled here.
|
|
293
|
-
if (this.labelPlacement !== "none" && this.labelMode !== "none") {
|
|
294
|
-
for (const arc of this.arcs) this.addLabel(arc)
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// The hole's summary, pinned to the ring's centre so it tracks the sideways
|
|
298
|
-
// shift a one-sided label column puts on the ring.
|
|
299
|
-
if (this.hasCenterLabel) this.addCenterLabel()
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** Build slice `arc`'s label, in whichever form the chart's placement asks for. */
|
|
303
|
-
private addLabel(arc: PieArc): void {
|
|
304
|
-
const slice = this.slices[arc.index]
|
|
305
|
-
// Fades in with the ring's labels, then dims along with its own slice
|
|
306
|
-
// whenever another slice takes the focus.
|
|
307
|
-
const opacity = () =>
|
|
308
|
-
clampUnit(this.labelReveal) * this.sliceOpacity(arc.index)
|
|
309
|
-
const wantsCategory =
|
|
310
|
-
this.labelMode === "both" || this.labelMode === "category"
|
|
311
|
-
const wantsPercent =
|
|
312
|
-
this.labelMode === "both" || this.labelMode === "percent"
|
|
313
|
-
|
|
314
|
-
if (this.labelPlacement === "inside") {
|
|
315
|
-
// The number leads and the caption sits under it: inside a wedge the
|
|
316
|
-
// reader already knows *which* wedge they are looking at, so the magnitude
|
|
317
|
-
// is what the label is for. Both sizes are bound rather than read once —
|
|
318
|
-
// the fitted size derives from the ring's radius, which isn't known until
|
|
319
|
-
// the node has been laid out.
|
|
320
|
-
const inside: Node2D[] = []
|
|
321
|
-
if (wantsPercent) {
|
|
322
|
-
inside.push(
|
|
323
|
-
new Text({
|
|
324
|
-
...textOps.styleProps(this.label.insideStyle),
|
|
325
|
-
text: formatPercent(arc.fraction),
|
|
326
|
-
fontSize: () => this.insideFont,
|
|
327
|
-
})
|
|
328
|
-
)
|
|
329
|
-
}
|
|
330
|
-
if (wantsCategory) {
|
|
331
|
-
inside.push(
|
|
332
|
-
new Text({
|
|
333
|
-
...textOps.styleProps(this.label.insideStyle),
|
|
334
|
-
text: slice.category,
|
|
335
|
-
fontSize: () =>
|
|
336
|
-
this.labelMode === "category"
|
|
337
|
-
? this.insideFont
|
|
338
|
-
: this.insideFont * INSIDE_CAPTION_RATIO,
|
|
339
|
-
})
|
|
340
|
-
)
|
|
341
|
-
}
|
|
342
|
-
this.add(
|
|
343
|
-
new Rect({ flow: "vertical",
|
|
344
|
-
width: "hug",
|
|
345
|
-
height: "hug",
|
|
346
|
-
gap: 0,
|
|
347
|
-
align: "center",
|
|
348
|
-
opacity,
|
|
349
|
-
center: () => this.insideAnchor(arc.index),
|
|
350
|
-
children: inside,
|
|
351
|
-
})
|
|
352
|
-
)
|
|
353
|
-
return
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const side = this.sideOf(arc.mid)
|
|
357
|
-
const at = () => this.placementFor(arc.index)?.text ?? { x: 0, y: 0 }
|
|
358
|
-
const children: Node2D[] = []
|
|
359
|
-
if (wantsCategory) {
|
|
360
|
-
children.push(
|
|
361
|
-
new Text({
|
|
362
|
-
...textOps.styleProps(this.label.textStyle),
|
|
363
|
-
text: slice.category,
|
|
364
|
-
textAlign: side > 0 ? "left" : "right",
|
|
365
|
-
})
|
|
366
|
-
)
|
|
367
|
-
}
|
|
368
|
-
if (wantsPercent) {
|
|
369
|
-
children.push(
|
|
370
|
-
new Text({
|
|
371
|
-
...textOps.styleProps(this.label.percentStyle),
|
|
372
|
-
text: formatPercent(arc.fraction),
|
|
373
|
-
textAlign: side > 0 ? "left" : "right",
|
|
374
|
-
})
|
|
375
|
-
)
|
|
376
|
-
}
|
|
377
|
-
this.add(
|
|
378
|
-
new Rect({ flow: "vertical",
|
|
379
|
-
width: "hug",
|
|
380
|
-
height: "hug",
|
|
381
|
-
gap: 2,
|
|
382
|
-
align: side > 0 ? "centerLeft" : "centerRight",
|
|
383
|
-
opacity,
|
|
384
|
-
// Exactly one of these is set; `undefined` anchors are ignored.
|
|
385
|
-
centerLeft: side > 0 ? at : undefined,
|
|
386
|
-
centerRight: side > 0 ? undefined : at,
|
|
387
|
-
children,
|
|
388
|
-
})
|
|
389
|
-
)
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/** Build the hole's summary block. */
|
|
393
|
-
private addCenterLabel(): void {
|
|
394
|
-
const children: Node2D[] = []
|
|
395
|
-
if (this.centerLabel !== "") {
|
|
396
|
-
children.push(
|
|
397
|
-
new Text({
|
|
398
|
-
...textOps.styleProps(this.centerStyle.labelStyle),
|
|
399
|
-
text: this.centerLabel,
|
|
400
|
-
fontSize: () => this.centerFont,
|
|
401
|
-
})
|
|
402
|
-
)
|
|
403
|
-
}
|
|
404
|
-
if (this.centerCaption !== "") {
|
|
405
|
-
children.push(
|
|
406
|
-
new Text({
|
|
407
|
-
...textOps.styleProps(this.centerStyle.captionStyle),
|
|
408
|
-
text: this.centerCaption,
|
|
409
|
-
fontSize: () =>
|
|
410
|
-
this.centerFont *
|
|
411
|
-
(sizeOf(this.centerStyle.captionStyle, 22) /
|
|
412
|
-
sizeOf(this.centerStyle.labelStyle, 46)),
|
|
413
|
-
})
|
|
414
|
-
)
|
|
415
|
-
}
|
|
416
|
-
this.add(
|
|
417
|
-
new Rect({ flow: "vertical",
|
|
418
|
-
width: "hug",
|
|
419
|
-
height: "hug",
|
|
420
|
-
gap: 2,
|
|
421
|
-
align: "center",
|
|
422
|
-
opacity: () => clampUnit(this.centerReveal),
|
|
423
|
-
center: () => ({ x: this.centerX, y: 0 }),
|
|
424
|
-
children,
|
|
425
|
-
})
|
|
426
|
-
)
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// ---- Arcs --------------------------------------------------------------
|
|
239
|
+
export class RingGeometry {
|
|
240
|
+
constructor(
|
|
241
|
+
private readonly s: RingState,
|
|
242
|
+
private readonly box: { readonly width: number; readonly height: number }
|
|
243
|
+
) { }
|
|
430
244
|
|
|
431
245
|
/** The drawn slices' angular spans, re-read every frame so a value tween re-cuts them. */
|
|
432
|
-
|
|
433
|
-
return pieArcs(this.slices, this.ring.startAngle)
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/** Which side a leader off bisector `mid` runs to: +1 right, −1 left. */
|
|
437
|
-
private sideOf(mid: number): 1 | -1 {
|
|
438
|
-
return Math.sin(mid) >= 0 ? 1 : -1
|
|
246
|
+
get arcs(): PieArc[] {
|
|
247
|
+
return pieArcs(this.s.slices, this.s.ring.startAngle)
|
|
439
248
|
}
|
|
440
249
|
|
|
441
250
|
/** Whether any slice is labelled on a leader — the only thing that costs a gutter. */
|
|
442
|
-
|
|
443
|
-
return
|
|
444
|
-
this.labelPlacement === "leader" &&
|
|
445
|
-
this.labelMode !== "none" &&
|
|
446
|
-
this.arcs.length > 0
|
|
447
|
-
)
|
|
251
|
+
get hasLeaderLabels(): boolean {
|
|
252
|
+
return this.s.labelPlacement === "leader" && this.s.labelMode !== "none" && this.arcs.length > 0
|
|
448
253
|
}
|
|
449
254
|
|
|
450
255
|
// ---- Label sizing ------------------------------------------------------
|
|
@@ -465,7 +270,7 @@ export class PieRing extends Node2D<PieRingProps> {
|
|
|
465
270
|
* Measured off the *unpopped* radius, so a slice growing out of the ring
|
|
466
271
|
* doesn't resize its own text mid-animation.
|
|
467
272
|
*/
|
|
468
|
-
|
|
273
|
+
insideRoom(arc: PieArc): number {
|
|
469
274
|
const outer = this.outerRadius
|
|
470
275
|
const hole = this.holeRadius
|
|
471
276
|
const band = hole > 0 ? outer - hole : outer * (1 - PIE_LABEL_RADIUS) * 2
|
|
@@ -478,20 +283,13 @@ export class PieRing extends Node2D<PieRingProps> {
|
|
|
478
283
|
}
|
|
479
284
|
|
|
480
285
|
/** Widest line, in glyphs at the leading line's size, an inside label runs to. */
|
|
481
|
-
|
|
482
|
-
const
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
? formatPercent(arc.fraction).length
|
|
486
|
-
: 0
|
|
286
|
+
insideGlyphs(arc: PieArc): number {
|
|
287
|
+
const mode = this.s.labelMode
|
|
288
|
+
const category = this.s.slices[arc.index]?.category.length ?? 0
|
|
289
|
+
const percent = mode === "both" || mode === "percent" ? formatPercent(arc.fraction).length : 0
|
|
487
290
|
// The caption is set at a fraction of the leading line, except when it *is*
|
|
488
291
|
// the leading line.
|
|
489
|
-
const caption =
|
|
490
|
-
this.labelMode === "category"
|
|
491
|
-
? category
|
|
492
|
-
: this.labelMode === "both"
|
|
493
|
-
? category * INSIDE_CAPTION_RATIO
|
|
494
|
-
: 0
|
|
292
|
+
const caption = mode === "category" ? category : mode === "both" ? category * INSIDE_CAPTION_RATIO : 0
|
|
495
293
|
return Math.max(1, percent, caption)
|
|
496
294
|
}
|
|
497
295
|
|
|
@@ -504,77 +302,63 @@ export class PieRing extends Node2D<PieRingProps> {
|
|
|
504
302
|
* So the theme's `insideStyle.fontSize` is an upper bound, not a promise: ask
|
|
505
303
|
* for the size the chart wants and the ring keeps it legal.
|
|
506
304
|
*/
|
|
507
|
-
|
|
508
|
-
let size = sizeOf(this.label.insideStyle, FALLBACK_SIZE * 1.9)
|
|
305
|
+
get insideFont(): number {
|
|
306
|
+
let size = sizeOf(this.s.label.insideStyle, FALLBACK_SIZE * 1.9)
|
|
509
307
|
for (const arc of this.arcs) {
|
|
510
|
-
const fits =
|
|
511
|
-
(this.insideRoom(arc) * INSIDE_LABEL_FIT) /
|
|
512
|
-
(this.insideGlyphs(arc) * GLYPH_RATIO)
|
|
308
|
+
const fits = (this.insideRoom(arc) * INSIDE_LABEL_FIT) / (this.insideGlyphs(arc) * GLYPH_RATIO)
|
|
513
309
|
size = Math.min(size, fits)
|
|
514
310
|
}
|
|
515
311
|
return Math.max(1, size)
|
|
516
312
|
}
|
|
517
313
|
|
|
518
|
-
/** Whether the hole carries a summary at all. */
|
|
519
|
-
private get hasCenterLabel(): boolean {
|
|
520
|
-
return this.centerLabel !== "" || this.centerCaption !== ""
|
|
521
|
-
}
|
|
522
|
-
|
|
523
314
|
/**
|
|
524
315
|
* Font size of the hole's summary: the configured size, capped so it still
|
|
525
316
|
* fits across the hole. The cap matters because the hole's radius derives from
|
|
526
317
|
* the box, so a chart that reflows smaller would otherwise push its summary out
|
|
527
318
|
* through the ring.
|
|
528
319
|
*/
|
|
529
|
-
|
|
530
|
-
const
|
|
320
|
+
get centerFont(): number {
|
|
321
|
+
const { centerStyle, centerLabel, centerCaption } = this.s
|
|
322
|
+
const wanted = sizeOf(centerStyle.labelStyle, 46)
|
|
531
323
|
const hole = this.holeRadius
|
|
532
324
|
if (hole <= 0) return wanted
|
|
533
|
-
const captionRatio =
|
|
534
|
-
sizeOf(this.centerStyle.captionStyle, 22) / Math.max(1, wanted)
|
|
325
|
+
const captionRatio = sizeOf(centerStyle.captionStyle, 22) / Math.max(1, wanted)
|
|
535
326
|
// Both lines have to clear the hole, and the caption is set at a fraction of
|
|
536
327
|
// this size — so what binds is whichever is wider once drawn, not whichever
|
|
537
328
|
// has more characters.
|
|
538
|
-
const glyphs = Math.max(
|
|
539
|
-
1,
|
|
540
|
-
this.centerLabel.length,
|
|
541
|
-
this.centerCaption.length * captionRatio
|
|
542
|
-
)
|
|
329
|
+
const glyphs = Math.max(1, centerLabel.length, centerCaption.length * captionRatio)
|
|
543
330
|
return Math.min(wanted, (hole * 2 * CENTER_LABEL_FIT) / (glyphs * GLYPH_RATIO))
|
|
544
331
|
}
|
|
545
332
|
|
|
546
|
-
// ---- Ring geometry, all derived from
|
|
333
|
+
// ---- Ring geometry, all derived from the box ---------------------------
|
|
547
334
|
|
|
548
335
|
/** Rim radius of a fully selected slice as a multiple of the ring's, clamped sane. */
|
|
549
|
-
|
|
550
|
-
return Math.max(1, Math.min(1.6, this.ring.selectedGrow))
|
|
336
|
+
get growFactor(): number {
|
|
337
|
+
return Math.max(1, Math.min(1.6, this.s.ring.selectedGrow))
|
|
551
338
|
}
|
|
552
339
|
|
|
553
340
|
/** How far past the ring's rim a fully selected slice reaches, in px. */
|
|
554
|
-
|
|
341
|
+
get maxPop(): number {
|
|
555
342
|
return this.outerRadius * (this.growFactor - 1)
|
|
556
343
|
}
|
|
557
344
|
|
|
558
345
|
/** Height of one **leader** label block, in px — the unit those columns are spaced by. */
|
|
559
|
-
|
|
346
|
+
get labelRowHeight(): number {
|
|
560
347
|
// Inside labels are stacked in the band, not in a column beside the ring, so
|
|
561
348
|
// they cost no vertical reserve.
|
|
562
349
|
if (!this.hasLeaderLabels) return 0
|
|
563
|
-
const caption = sizeOf(this.label.textStyle) * 1.25
|
|
564
|
-
const percent = sizeOf(this.label.percentStyle) * 1.25
|
|
565
|
-
if (this.labelMode === "both") return caption + percent + 2
|
|
566
|
-
return this.labelMode === "category" ? caption : percent
|
|
350
|
+
const caption = sizeOf(this.s.label.textStyle) * 1.25
|
|
351
|
+
const percent = sizeOf(this.s.label.percentStyle) * 1.25
|
|
352
|
+
if (this.s.labelMode === "both") return caption + percent + 2
|
|
353
|
+
return this.s.labelMode === "category" ? caption : percent
|
|
567
354
|
}
|
|
568
355
|
|
|
569
356
|
/** Widest line, in glyphs, that a leader label for slice `index` can run to. */
|
|
570
|
-
|
|
357
|
+
captionGlyphs(index: number): number {
|
|
358
|
+
const mode = this.s.labelMode
|
|
571
359
|
// "100%" is the widest a percentage gets; captions are their own length.
|
|
572
|
-
const percent =
|
|
573
|
-
|
|
574
|
-
const category =
|
|
575
|
-
this.labelMode === "category" || this.labelMode === "both"
|
|
576
|
-
? (this.slices[index]?.category.length ?? 0)
|
|
577
|
-
: 0
|
|
360
|
+
const percent = mode === "percent" || mode === "both" ? 4 : 0
|
|
361
|
+
const category = mode === "category" || mode === "both" ? (this.s.slices[index]?.category.length ?? 0) : 0
|
|
578
362
|
return Math.max(percent, category)
|
|
579
363
|
}
|
|
580
364
|
|
|
@@ -586,18 +370,13 @@ export class PieRing extends Node2D<PieRingProps> {
|
|
|
586
370
|
* Excludes the ring's own selection-growth allowance, which scales with the
|
|
587
371
|
* radius and so is divided out in {@link outerRadius} instead.
|
|
588
372
|
*/
|
|
589
|
-
|
|
373
|
+
get labelGutters(): { left: number; right: number } {
|
|
590
374
|
const gutters = { left: BARE_PAD, right: BARE_PAD }
|
|
591
375
|
if (!this.hasLeaderLabels) return gutters
|
|
592
|
-
const glyph = sizeOf(this.label.textStyle) * GLYPH_RATIO
|
|
376
|
+
const glyph = sizeOf(this.s.label.textStyle) * GLYPH_RATIO
|
|
593
377
|
for (const arc of this.arcs) {
|
|
594
|
-
const room =
|
|
595
|
-
|
|
596
|
-
LABEL_STUB +
|
|
597
|
-
TEXT_INSET +
|
|
598
|
-
this.captionGlyphs(arc.index) * glyph +
|
|
599
|
-
LABEL_PAD
|
|
600
|
-
const key = this.sideOf(arc.mid) > 0 ? "right" : "left"
|
|
378
|
+
const room = LEADER_LENGTH + LABEL_STUB + TEXT_INSET + this.captionGlyphs(arc.index) * glyph + LABEL_PAD
|
|
379
|
+
const key = sideOf(arc.mid) > 0 ? "right" : "left"
|
|
601
380
|
gutters[key] = Math.max(gutters[key], room)
|
|
602
381
|
}
|
|
603
382
|
return gutters
|
|
@@ -609,7 +388,7 @@ export class PieRing extends Node2D<PieRingProps> {
|
|
|
609
388
|
* empty half so the ring-plus-labels group stays centred in the box instead of
|
|
610
389
|
* the ring sitting dead centre with a bald patch beside it.
|
|
611
390
|
*/
|
|
612
|
-
|
|
391
|
+
get centerX(): number {
|
|
613
392
|
const { left, right } = this.labelGutters
|
|
614
393
|
return (left - right) / 2
|
|
615
394
|
}
|
|
@@ -620,35 +399,32 @@ export class PieRing extends Node2D<PieRingProps> {
|
|
|
620
399
|
* cover that — hence the divisor, which keeps even the grown slice inside the
|
|
621
400
|
* box (and holds the ring the same size whether or not anything is selected).
|
|
622
401
|
*/
|
|
623
|
-
|
|
402
|
+
get outerRadius(): number {
|
|
624
403
|
const { left, right } = this.labelGutters
|
|
625
|
-
const room = Math.min(
|
|
626
|
-
(this.layoutBounds.width - left - right) / 2,
|
|
627
|
-
this.layoutBounds.height / 2 - this.labelRowHeight - 4
|
|
628
|
-
)
|
|
404
|
+
const room = Math.min((this.box.width - left - right) / 2, this.box.height / 2 - this.labelRowHeight - 4)
|
|
629
405
|
return Math.max(0, room / this.growFactor)
|
|
630
406
|
}
|
|
631
407
|
|
|
632
408
|
/** Radius of the hole in the middle: a fraction of the outer radius, or explicit px. */
|
|
633
|
-
|
|
634
|
-
const configured = this.ring.holeRadius
|
|
409
|
+
get holeRadius(): number {
|
|
410
|
+
const configured = this.s.ring.holeRadius
|
|
635
411
|
if (configured <= 0) return 0
|
|
636
412
|
const px = configured < 1 ? configured * this.outerRadius : configured
|
|
637
413
|
return Math.min(px, this.outerRadius * 0.95)
|
|
638
414
|
}
|
|
639
415
|
|
|
640
416
|
/** Radius of the circle every leader elbows on, clear of even a popped-out slice. */
|
|
641
|
-
|
|
417
|
+
get elbowRadius(): number {
|
|
642
418
|
return this.outerRadius + this.maxPop + LEADER_LENGTH
|
|
643
419
|
}
|
|
644
420
|
|
|
645
421
|
/** How far slice `index` is currently pushed out, in `[0, 1]`. */
|
|
646
|
-
|
|
647
|
-
return clampUnit(this.pops[index] ?? 0)
|
|
422
|
+
popOf(index: number): number {
|
|
423
|
+
return clampUnit(this.s.pops[index] ?? 0)
|
|
648
424
|
}
|
|
649
425
|
|
|
650
426
|
/** Radius of slice `index`'s outer edge, its selection growth included. */
|
|
651
|
-
|
|
427
|
+
anchorRadius(index: number): number {
|
|
652
428
|
return this.outerRadius + this.maxPop * this.popOf(index)
|
|
653
429
|
}
|
|
654
430
|
|
|
@@ -658,14 +434,14 @@ export class PieRing extends Node2D<PieRingProps> {
|
|
|
658
434
|
* crowd the point every wedge converges on. Rides the slice's own pop, so an
|
|
659
435
|
* inside label travels out with the wedge it belongs to.
|
|
660
436
|
*/
|
|
661
|
-
|
|
437
|
+
bandMidRadius(index: number): number {
|
|
662
438
|
const rim = this.anchorRadius(index)
|
|
663
439
|
const hole = this.holeRadius
|
|
664
440
|
return hole > 0 ? (hole + rim) / 2 : rim * PIE_LABEL_RADIUS
|
|
665
441
|
}
|
|
666
442
|
|
|
667
|
-
/**
|
|
668
|
-
|
|
443
|
+
/** The point slice `index`'s inside label is centred on. */
|
|
444
|
+
insideAnchor(index: number): Vector2 {
|
|
669
445
|
const arc = this.arcs.find((a) => a.index === index)
|
|
670
446
|
if (arc === undefined) return { x: this.centerX, y: 0 }
|
|
671
447
|
const point = ringPoint(this.bandMidRadius(index), arc.mid)
|
|
@@ -677,24 +453,28 @@ export class PieRing extends Node2D<PieRingProps> {
|
|
|
677
453
|
* the same tween the pops do, so the dim below arrives with the growth rather
|
|
678
454
|
* than needing an animation of its own.
|
|
679
455
|
*/
|
|
680
|
-
|
|
456
|
+
get selectionStrength(): number {
|
|
681
457
|
let strongest = 0
|
|
682
|
-
for (let i = 0; i < this.slices.length; i++)
|
|
683
|
-
strongest = Math.max(strongest, this.popOf(i))
|
|
684
|
-
}
|
|
458
|
+
for (let i = 0; i < this.s.slices.length; i++) strongest = Math.max(strongest, this.popOf(i))
|
|
685
459
|
return strongest
|
|
686
460
|
}
|
|
687
461
|
|
|
688
462
|
/**
|
|
689
463
|
* Opacity slice `index` (and its leader and label) is drawn at: full while
|
|
690
|
-
* nothing is selected, falling toward
|
|
464
|
+
* nothing is selected, falling toward `dimOpacity` in proportion to how
|
|
691
465
|
* selected the *rest* of the ring is. A slice that's selected itself stays at
|
|
692
466
|
* full — the two factors cancel.
|
|
693
467
|
*/
|
|
694
|
-
|
|
468
|
+
sliceOpacity(index: number): number {
|
|
695
469
|
const focus = this.selectionStrength
|
|
696
470
|
if (focus <= 0) return 1
|
|
697
|
-
return 1 - (1 - clampUnit(this.dimOpacity)) * focus * (1 - this.popOf(index))
|
|
471
|
+
return 1 - (1 - clampUnit(this.s.dimOpacity)) * focus * (1 - this.popOf(index))
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Slice `index`'s share of the ring as its label reads it, off the live values. */
|
|
475
|
+
percentText(index: number): string {
|
|
476
|
+
const arc = this.arcs.find((a) => a.index === index)
|
|
477
|
+
return formatPercent(arc?.fraction ?? 0)
|
|
698
478
|
}
|
|
699
479
|
|
|
700
480
|
// ---- Label placement ---------------------------------------------------
|
|
@@ -707,14 +487,14 @@ export class PieRing extends Node2D<PieRingProps> {
|
|
|
707
487
|
* the label's *settled* y, which keeps the final run horizontal and the whole
|
|
708
488
|
* polyline outside the ring however far the label was nudged.
|
|
709
489
|
*/
|
|
710
|
-
|
|
490
|
+
placements(): LabelPlacement[] {
|
|
711
491
|
if (!this.hasLeaderLabels) return []
|
|
712
492
|
const arcs = this.arcs
|
|
713
493
|
if (arcs.length === 0 || this.outerRadius <= 0) return []
|
|
714
494
|
|
|
715
495
|
const radius = this.elbowRadius
|
|
716
496
|
const rowHeight = this.labelRowHeight
|
|
717
|
-
const top = this.
|
|
497
|
+
const top = this.box.height / 2 - rowHeight / 2
|
|
718
498
|
const bottom = -top
|
|
719
499
|
const cx = this.centerX
|
|
720
500
|
|
|
@@ -728,31 +508,20 @@ export class PieRing extends Node2D<PieRingProps> {
|
|
|
728
508
|
const right: Slot[] = []
|
|
729
509
|
const left: Slot[] = []
|
|
730
510
|
for (const arc of arcs) {
|
|
731
|
-
const side =
|
|
511
|
+
const side = sideOf(arc.mid)
|
|
732
512
|
const ideal = ringPoint(radius, arc.mid).y
|
|
733
|
-
;(side > 0 ? right : left).push({
|
|
734
|
-
index: arc.index,
|
|
735
|
-
side,
|
|
736
|
-
mid: arc.mid,
|
|
737
|
-
ideal,
|
|
738
|
-
y: ideal,
|
|
739
|
-
})
|
|
513
|
+
;(side > 0 ? right : left).push({ index: arc.index, side, mid: arc.mid, ideal, y: ideal })
|
|
740
514
|
}
|
|
741
515
|
relaxLabels(right, rowHeight + LABEL_GAP, top, bottom)
|
|
742
516
|
relaxLabels(left, rowHeight + LABEL_GAP, top, bottom)
|
|
743
517
|
|
|
744
518
|
return [...right, ...left].map((slot) => {
|
|
745
|
-
const start = ringPoint(
|
|
746
|
-
this.anchorRadius(slot.index) + LEADER_LIFTOFF,
|
|
747
|
-
slot.mid
|
|
748
|
-
)
|
|
519
|
+
const start = ringPoint(this.anchorRadius(slot.index) + LEADER_LIFTOFF, slot.mid)
|
|
749
520
|
// The elbow sits where the label's y crosses the leader circle. Solving
|
|
750
521
|
// x² + y² = radius² for x keeps it *on* that circle (never inside the
|
|
751
522
|
// ring); a label pushed past the circle's top or bottom falls back to the
|
|
752
523
|
// sideways floor so its leader still reads as pointing outward.
|
|
753
|
-
const onCircle = Math.sqrt(
|
|
754
|
-
Math.max(0, radius * radius - slot.y * slot.y)
|
|
755
|
-
)
|
|
524
|
+
const onCircle = Math.sqrt(Math.max(0, radius * radius - slot.y * slot.y))
|
|
756
525
|
const elbowX = cx + slot.side * Math.max(onCircle, radius * MIN_ELBOW)
|
|
757
526
|
const endX = cx + slot.side * (radius + LABEL_STUB)
|
|
758
527
|
|
|
@@ -770,134 +539,310 @@ export class PieRing extends Node2D<PieRingProps> {
|
|
|
770
539
|
}
|
|
771
540
|
|
|
772
541
|
/** This frame's placement for slice `index`, or `undefined` if it isn't leadered. */
|
|
773
|
-
|
|
542
|
+
placementFor(index: number): LabelPlacement | undefined {
|
|
774
543
|
return this.placements().find((placement) => placement.index === index)
|
|
775
544
|
}
|
|
545
|
+
}
|
|
776
546
|
|
|
777
|
-
|
|
547
|
+
/** Which side a leader off bisector `mid` runs to: +1 right, −1 left. */
|
|
548
|
+
function sideOf(mid: number): 1 | -1 {
|
|
549
|
+
return Math.sin(mid) >= 0 ? 1 : -1
|
|
550
|
+
}
|
|
778
551
|
|
|
779
|
-
|
|
780
|
-
* The ring's paints live nowhere `ShapeNode` would look: one fill per slice,
|
|
781
|
-
* resolved once into {@link fills}, plus the leader line's own stroke. Both are
|
|
782
|
-
* passed explicitly — see {@link declarePaints}.
|
|
783
|
-
*/
|
|
784
|
-
override declareAssets(assets: AssetScope): void {
|
|
785
|
-
super.declareAssets(assets)
|
|
786
|
-
declarePaints(this, assets, {
|
|
787
|
-
fills: this.fills,
|
|
788
|
-
strokes: [strokeOps.resolve(this.label.leaderStroke)],
|
|
789
|
-
})
|
|
790
|
-
}
|
|
552
|
+
const NO_BOX = { width: 0, height: 0 }
|
|
791
553
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
554
|
+
/**
|
|
555
|
+
* Each slice's fill, resolved once per paint rather than every frame: the slices
|
|
556
|
+
* are bound to the chart's rows and palette, so a theme tween hands over new
|
|
557
|
+
* paint and the drawing must see it, but a frame that moves nothing must not
|
|
558
|
+
* re-resolve. Keyed by the paint itself, so it is shared and needs no node.
|
|
559
|
+
*/
|
|
560
|
+
const resolvedFills = new WeakMap<object, FillResolved[]>()
|
|
795
561
|
|
|
796
|
-
|
|
797
|
-
|
|
562
|
+
function resolvedFill(fill: Fill): FillResolved[] {
|
|
563
|
+
if (typeof fill !== "object" || fill === null) return fillOps.resolve(fill)
|
|
564
|
+
let resolved = resolvedFills.get(fill)
|
|
565
|
+
if (resolved === undefined) resolvedFills.set(fill, resolved = fillOps.resolve(fill))
|
|
566
|
+
return resolved
|
|
567
|
+
}
|
|
798
568
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
// One shared frame (the whole ring square, grown slices included) pinned onto
|
|
805
|
-
// every wedge path, so `Graphics2D.path()` doesn't re-centre each wedge on its
|
|
806
|
-
// own bbox and collapse them all onto the origin. It stays symmetric about the
|
|
807
|
-
// *node's* origin — so path coordinates land exactly in node space — and is
|
|
808
|
-
// grown by the ring's sideways offset rather than moved with it, which would
|
|
809
|
-
// undo the very shift it has to contain.
|
|
810
|
-
const cx = this.centerX
|
|
811
|
-
const half = rOuter + this.maxPop + 1 + Math.abs(cx)
|
|
812
|
-
const centerBounds: [number, number, number, number] = [
|
|
813
|
-
-half,
|
|
814
|
-
-half,
|
|
815
|
-
half,
|
|
816
|
-
half,
|
|
817
|
-
]
|
|
818
|
-
// Explicit width/height alongside centerBounds: a wedge path's command list
|
|
819
|
-
// carries no implied extent, so a slice filled with an image or a video needs
|
|
820
|
-
// this to size its decode.
|
|
821
|
-
const span = half * 2
|
|
569
|
+
/** Each slice's fill, resolved. */
|
|
570
|
+
export function ringFills(slices: readonly PieSlice[]): FillResolved[][] {
|
|
571
|
+
return slices.map((slice) => resolvedFill(slice.fill))
|
|
572
|
+
}
|
|
822
573
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
)
|
|
837
|
-
const g = new Graphics2D().path(
|
|
838
|
-
path.toPathState({ centerBounds, width: span, height: span })
|
|
839
|
-
)
|
|
840
|
-
g.shadow(this.ring.shadow)
|
|
841
|
-
g.fill(this.fills[arc.index] ?? [])
|
|
842
|
-
g.stroke(this.ring.stroke)
|
|
843
|
-
ctx.draw(g.opacity(this.sliceOpacity(arc.index)))
|
|
844
|
-
}
|
|
574
|
+
/**
|
|
575
|
+
* The labels a ring was built with: one per *drawn* slice (a zero-valued slice
|
|
576
|
+
* gets no wedge, so no label either), in whichever of the two forms the chart
|
|
577
|
+
* asked for, then the hole's summary. Both are real Text stacked in a hug
|
|
578
|
+
* vertical flow — the difference is only what they are pinned to and which line
|
|
579
|
+
* leads. The point each is pinned to stays reactive and follows the ring as the
|
|
580
|
+
* box resizes or its slice pops out; only the choice of form, which depends on
|
|
581
|
+
* entries alone, is settled here.
|
|
582
|
+
*/
|
|
583
|
+
function ringLabels(s: RingState & { readonly labelReveal: number; readonly centerReveal: number }) {
|
|
584
|
+
const blocks: Node2D[] = []
|
|
585
|
+
// A label block's parent is the ring: its box is what the ring was laid out at.
|
|
586
|
+
const geometry = () => new RingGeometry(s, (blocks[0]?.parent as Node2D | null | undefined)?.layoutBounds ?? NO_BOX)
|
|
845
587
|
|
|
846
|
-
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
for (const placement of this.placements()) {
|
|
853
|
-
ctx.draw(
|
|
854
|
-
new Graphics2D()
|
|
855
|
-
.line({ points: placement.leader, radius: ELBOW_RADIUS })
|
|
856
|
-
.stroke(this.label.leaderStroke)
|
|
857
|
-
.opacity(reveal * this.sliceOpacity(placement.index))
|
|
858
|
-
)
|
|
859
|
-
}
|
|
860
|
-
}
|
|
588
|
+
const label = (arc: PieArc): Node2D => {
|
|
589
|
+
// Fades in with the ring's labels, then dims along with its own slice
|
|
590
|
+
// whenever another slice takes the focus.
|
|
591
|
+
const opacity = () => clampUnit(s.labelReveal) * geometry().sliceOpacity(arc.index)
|
|
592
|
+
const wantsCategory = s.labelMode === "both" || s.labelMode === "category"
|
|
593
|
+
const wantsPercent = s.labelMode === "both" || s.labelMode === "percent"
|
|
861
594
|
|
|
862
|
-
|
|
595
|
+
if (s.labelPlacement === "inside") {
|
|
596
|
+
// The number leads and the caption sits under it: inside a wedge the
|
|
597
|
+
// reader already knows *which* wedge they are looking at, so the magnitude
|
|
598
|
+
// is what the label is for. Both sizes are bound rather than read once —
|
|
599
|
+
// the fitted size derives from the ring's radius, which isn't known until
|
|
600
|
+
// the node has been laid out.
|
|
601
|
+
const inside: Node2D[] = []
|
|
602
|
+
if (wantsPercent) {
|
|
603
|
+
inside.push(new Text({
|
|
604
|
+
...textOps.styleProps(s.label.insideStyle),
|
|
605
|
+
text: () => geometry().percentText(arc.index),
|
|
606
|
+
fontSize: () => geometry().insideFont,
|
|
607
|
+
}))
|
|
608
|
+
}
|
|
609
|
+
if (wantsCategory) {
|
|
610
|
+
inside.push(new Text({
|
|
611
|
+
...textOps.styleProps(s.label.insideStyle),
|
|
612
|
+
text: () => s.slices[arc.index]?.category ?? "",
|
|
613
|
+
fontSize: () => (s.labelMode === "category" ? geometry().insideFont : geometry().insideFont * INSIDE_CAPTION_RATIO),
|
|
614
|
+
}))
|
|
615
|
+
}
|
|
616
|
+
return new Rect({
|
|
617
|
+
flow: "vertical",
|
|
618
|
+
width: "hug",
|
|
619
|
+
height: "hug",
|
|
620
|
+
gap: 0,
|
|
621
|
+
align: "center",
|
|
622
|
+
opacity,
|
|
623
|
+
center: () => geometry().insideAnchor(arc.index),
|
|
624
|
+
children: inside,
|
|
625
|
+
})
|
|
626
|
+
}
|
|
863
627
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
628
|
+
const side = sideOf(arc.mid)
|
|
629
|
+
const at = () => geometry().placementFor(arc.index)?.text ?? { x: 0, y: 0 }
|
|
630
|
+
const children: Node2D[] = []
|
|
631
|
+
if (wantsCategory) {
|
|
632
|
+
children.push(new Text({
|
|
633
|
+
...textOps.styleProps(s.label.textStyle),
|
|
634
|
+
text: () => s.slices[arc.index]?.category ?? "",
|
|
635
|
+
textAlign: side > 0 ? "left" : "right",
|
|
636
|
+
}))
|
|
637
|
+
}
|
|
638
|
+
if (wantsPercent) {
|
|
639
|
+
children.push(new Text({
|
|
640
|
+
...textOps.styleProps(s.label.percentStyle),
|
|
641
|
+
text: () => geometry().percentText(arc.index),
|
|
642
|
+
textAlign: side > 0 ? "left" : "right",
|
|
643
|
+
}))
|
|
644
|
+
}
|
|
645
|
+
return new Rect({
|
|
646
|
+
flow: "vertical",
|
|
647
|
+
width: "hug",
|
|
648
|
+
height: "hug",
|
|
649
|
+
gap: 2,
|
|
650
|
+
align: side > 0 ? "centerLeft" : "centerRight",
|
|
651
|
+
opacity,
|
|
652
|
+
// Exactly one of these is set; `undefined` anchors are ignored.
|
|
653
|
+
centerLeft: side > 0 ? at : undefined,
|
|
654
|
+
centerRight: side > 0 ? undefined : at,
|
|
655
|
+
children,
|
|
656
|
+
})
|
|
872
657
|
}
|
|
873
658
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
revealLabels(
|
|
877
|
-
args: CommandArgs<Record<string, never>> & { duration: number }
|
|
878
|
-
): Command<RingLabels> {
|
|
879
|
-
return this.command<RingLabels>((t) => ({ labelReveal: t }), args.duration, args.easing ?? easeOut())
|
|
659
|
+
if (s.labelPlacement !== "none" && s.labelMode !== "none") {
|
|
660
|
+
for (const arc of pieArcs(s.slices, s.ring.startAngle)) blocks.push(label(arc))
|
|
880
661
|
}
|
|
881
662
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
663
|
+
// The hole's summary, pinned to the ring's centre so it tracks the sideways
|
|
664
|
+
// shift a one-sided label column puts on the ring.
|
|
665
|
+
if (s.centerLabel !== "" || s.centerCaption !== "") {
|
|
666
|
+
const children: Node2D[] = []
|
|
667
|
+
if (s.centerLabel !== "") {
|
|
668
|
+
children.push(new Text({
|
|
669
|
+
...textOps.styleProps(s.centerStyle.labelStyle),
|
|
670
|
+
text: s.centerLabel,
|
|
671
|
+
fontSize: () => geometry().centerFont,
|
|
672
|
+
}))
|
|
673
|
+
}
|
|
674
|
+
if (s.centerCaption !== "") {
|
|
675
|
+
children.push(new Text({
|
|
676
|
+
...textOps.styleProps(s.centerStyle.captionStyle),
|
|
677
|
+
text: s.centerCaption,
|
|
678
|
+
fontSize: () =>
|
|
679
|
+
geometry().centerFont * (sizeOf(s.centerStyle.captionStyle, 22) / sizeOf(s.centerStyle.labelStyle, 46)),
|
|
680
|
+
}))
|
|
681
|
+
}
|
|
682
|
+
blocks.push(new Rect({
|
|
683
|
+
flow: "vertical",
|
|
684
|
+
width: "hug",
|
|
685
|
+
height: "hug",
|
|
686
|
+
gap: 2,
|
|
687
|
+
align: "center",
|
|
688
|
+
opacity: () => clampUnit(s.centerReveal),
|
|
689
|
+
center: () => ({ x: geometry().centerX, y: 0 }),
|
|
690
|
+
children,
|
|
691
|
+
}))
|
|
692
|
+
}
|
|
693
|
+
return asParts(blocks)
|
|
898
694
|
}
|
|
899
695
|
|
|
900
|
-
/**
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
696
|
+
/**
|
|
697
|
+
* The ring itself: one wedge per {@link PieSlice}, sized by its share of the
|
|
698
|
+
* total, swept clockwise from the theme's `startAngle` with a `gap` of clear
|
|
699
|
+
* space between neighbours — a *constant-width* channel, the same at the hole as
|
|
700
|
+
* at the rim (see {@link wedgePath}). A **selected** slice reaches further out
|
|
701
|
+
* while its hole edge stays put — so it reads as that band swelling out of the
|
|
702
|
+
* ring rather than breaking off it — and the rest of the ring dims behind it,
|
|
703
|
+
* leaders and labels included.
|
|
704
|
+
*
|
|
705
|
+
* This is the only part of the chart family that draws a path: the wedges are
|
|
706
|
+
* filled {@link PathBuilder} polygons approximating each arc, and the leaders are
|
|
707
|
+
* stroked polylines — both in the node's centred, y-up space, so neither depends
|
|
708
|
+
* on SVG arc-sweep conventions. The **labels are real {@link Text} nodes**
|
|
709
|
+
* (stacked in a hug vertical flow and pinned by their inner edge), so no
|
|
710
|
+
* glyph-width guesswork decides where a caption starts.
|
|
711
|
+
*
|
|
712
|
+
* ### Label placement
|
|
713
|
+
* Each label sits either **inside** its wedge or **outside** on a leader.
|
|
714
|
+
*
|
|
715
|
+
* An inside label is centred on its slice's band and leads with the number, the
|
|
716
|
+
* caption set small underneath — inside a wedge the reader already knows which
|
|
717
|
+
* slice they're on, so the magnitude is the thing being read. It costs no gutter
|
|
718
|
+
* and no eye round-trip, so a ring labelled that way draws considerably bigger in
|
|
719
|
+
* the same box.
|
|
720
|
+
*
|
|
721
|
+
* A leader label sits outside on the end of a polyline that lifts off its wedge,
|
|
722
|
+
* elbows, and runs horizontally into the text. Labels are split into a left and a
|
|
723
|
+
* right column by which way their slice's bisector points, and each column is
|
|
724
|
+
* de-conflicted independently (see {@link relaxLabels}): a label takes the spot
|
|
725
|
+
* nearest its wedge that still clears its neighbour, and the column is squeezed
|
|
726
|
+
* to fit the node's height rather than spilling out of it. The elbow then rides on
|
|
727
|
+
* a circle just outside the ring **at the label's settled y**, so the run into the
|
|
728
|
+
* text is always horizontal and a nudged label never drags its leader back across
|
|
729
|
+
* the ring.
|
|
730
|
+
*
|
|
731
|
+
* Only the sides that actually carry a leader reserve a gutter, and the ring's
|
|
732
|
+
* centre slides into whatever the other side left over — so a ring whose slices
|
|
733
|
+
* all leader one way doesn't leave a bald patch on the other.
|
|
734
|
+
*/
|
|
735
|
+
export const PieRing = component2d({
|
|
736
|
+
key: "pieRing",
|
|
737
|
+
state: {
|
|
738
|
+
slices: fresh<PieSlice[]>(() => []),
|
|
739
|
+
ring: Types.custom<RingConfig>(DEFAULT_PIE_CHART_THEME.ring),
|
|
740
|
+
label: Types.custom<LabelConfig>(DEFAULT_PIE_CHART_THEME.label),
|
|
741
|
+
centerStyle: Types.custom<CenterConfig>(DEFAULT_PIE_CHART_THEME.center),
|
|
742
|
+
labelMode: Types.custom<PieLabelMode>("percent"),
|
|
743
|
+
labelPlacement: Types.custom<PieLabelPlacement>("leader"),
|
|
744
|
+
growth: 1,
|
|
745
|
+
labelReveal: 1,
|
|
746
|
+
pops: fresh<number[]>(() => []),
|
|
747
|
+
dimOpacity: DEFAULT_PIE_CHART_THEME.spotlight.dimOpacity,
|
|
748
|
+
centerLabel: "",
|
|
749
|
+
centerCaption: "",
|
|
750
|
+
centerReveal: 1,
|
|
751
|
+
},
|
|
752
|
+
// A plain node filled its parent; one made of its labels would hug them.
|
|
753
|
+
defaults: { width: "fill", height: "fill" },
|
|
754
|
+
children: (state) => ringLabels(state),
|
|
755
|
+
// The ring's paints live nowhere a paint entry would look: one fill per slice,
|
|
756
|
+
// the wedge outline and drop shadow of `ring`, and the leader line's own stroke.
|
|
757
|
+
load: (loader, state) =>
|
|
758
|
+
loadPaints(loader, {
|
|
759
|
+
fills: ringFills(state.slices),
|
|
760
|
+
shadows: [shadowOps.resolve(state.ring.shadow)],
|
|
761
|
+
strokes: [strokeOps.resolve(state.ring.stroke), strokeOps.resolve(state.label.leaderStroke)],
|
|
762
|
+
}),
|
|
763
|
+
commands: {
|
|
764
|
+
/**
|
|
765
|
+
* Tween `growth` `0 → 1`, wiping the wedges in around the ring. No priming
|
|
766
|
+
* to 0 first — the value at `t` is a function of `t`, so a repeat call
|
|
767
|
+
* re-sweeps by definition rather than by having reset something.
|
|
768
|
+
*/
|
|
769
|
+
sweep: (timeline) => {
|
|
770
|
+
const ease = frameEase(timeline)
|
|
771
|
+
return timeline.animate((t) => ({ growth: ease(t) }))
|
|
772
|
+
},
|
|
773
|
+
/** Tween `labelReveal` `0 → 1`, fading the leaders and captions up. */
|
|
774
|
+
revealLabels: (timeline) => {
|
|
775
|
+
const ease = frameEase(timeline)
|
|
776
|
+
return timeline.animate((t) => ({ labelReveal: ease(t) }))
|
|
777
|
+
},
|
|
778
|
+
/**
|
|
779
|
+
* Fade the hole's summary up. Deliberately not part of the ring's entrance: a
|
|
780
|
+
* hole label is usually the chart's conclusion, so the scene decides when it
|
|
781
|
+
* lands.
|
|
782
|
+
*/
|
|
783
|
+
revealCenter: (timeline, state) => {
|
|
784
|
+
const ease = frameEase(timeline)
|
|
785
|
+
const from = clampUnit(state.centerReveal)
|
|
786
|
+
return timeline.animate((t) => ({ centerReveal: from + (1 - from) * ease(t) }))
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
render: (ctx, state) => {
|
|
790
|
+
const g = new RingGeometry(state, ctx.size)
|
|
791
|
+
const rOuter = g.outerRadius
|
|
792
|
+
const arcs = rOuter > 0 ? g.arcs : []
|
|
793
|
+
if (arcs.length > 0) {
|
|
794
|
+
const sweep = clampUnit(state.growth)
|
|
795
|
+
const revealEnd = state.ring.startAngle * (Math.PI / 180) + Math.PI * 2 * sweep
|
|
796
|
+
const hole = g.holeRadius
|
|
797
|
+
const halfGap = Math.max(0, state.ring.gap) / 2
|
|
798
|
+
|
|
799
|
+
// One shared frame (the whole ring square, grown slices included) pinned onto
|
|
800
|
+
// every wedge path, so `Graphics2D.path()` doesn't re-centre each wedge on its
|
|
801
|
+
// own bbox and collapse them all onto the origin. It stays symmetric about the
|
|
802
|
+
// *node's* origin — so path coordinates land exactly in node space — and is
|
|
803
|
+
// grown by the ring's sideways offset rather than moved with it, which would
|
|
804
|
+
// undo the very shift it has to contain.
|
|
805
|
+
const cx = g.centerX
|
|
806
|
+
const half = rOuter + g.maxPop + 1 + Math.abs(cx)
|
|
807
|
+
const centerBounds: [number, number, number, number] = [-half, -half, half, half]
|
|
808
|
+
// Explicit width/height alongside centerBounds: a wedge path's command list
|
|
809
|
+
// carries no implied extent, so a slice filled with an image or a video needs
|
|
810
|
+
// this to size its decode.
|
|
811
|
+
const span = half * 2
|
|
812
|
+
|
|
813
|
+
const fills = ringFills(state.slices)
|
|
814
|
+
for (const arc of arcs) {
|
|
815
|
+
// Clip the wedge to the growth-revealed arc; skip it until the sweep
|
|
816
|
+
// reaches it, so the ring draws in as one clockwise wipe.
|
|
817
|
+
const a1 = Math.min(arc.a1, revealEnd)
|
|
818
|
+
if (a1 <= arc.a0) continue
|
|
819
|
+
|
|
820
|
+
const path = wedgePath(arc.a0, a1, hole, g.anchorRadius(arc.index), halfGap, cx)
|
|
821
|
+
const wedge = new Graphics2D().path(path.toPathState({ centerBounds, width: span, height: span }))
|
|
822
|
+
wedge.shadow(state.ring.shadow)
|
|
823
|
+
wedge.fill(fills[arc.index] ?? [])
|
|
824
|
+
wedge.stroke(state.ring.stroke)
|
|
825
|
+
ctx.draw(wedge.opacity(g.sliceOpacity(arc.index)))
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Leaders. Each polyline gets its own `Graphics2D`: several open polylines
|
|
829
|
+
// chained into one command list are combined into a single surface before the
|
|
830
|
+
// stroke, which closes each contour back on itself and paints the leaders as
|
|
831
|
+
// thin triangles.
|
|
832
|
+
const reveal = clampUnit(state.labelReveal)
|
|
833
|
+
if (reveal > 0.001) {
|
|
834
|
+
for (const placement of g.placements()) {
|
|
835
|
+
ctx.draw(
|
|
836
|
+
new Graphics2D()
|
|
837
|
+
.line({ points: placement.leader, radius: ELBOW_RADIUS })
|
|
838
|
+
.stroke(state.label.leaderStroke)
|
|
839
|
+
.opacity(reveal * g.sliceOpacity(placement.index))
|
|
840
|
+
)
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
ctx.children()
|
|
845
|
+
},
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
export type PieRing = NodeOf<typeof PieRing>
|