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