@motionscript/charts 0.1.0 → 0.1.1-alpha.0
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 +19 -0
- package/dist/bar-chart/bar-chart.d.ts +22 -93
- package/dist/bar-chart/bar-chart.d.ts.map +1 -1
- package/dist/bar-chart/bar-chart.js +48 -121
- package/dist/bar-chart/bar-chart.js.map +1 -1
- package/dist/bar-chart/category-axis.d.ts +11 -38
- package/dist/bar-chart/category-axis.d.ts.map +1 -1
- package/dist/bar-chart/category-axis.js +15 -38
- package/dist/bar-chart/category-axis.js.map +1 -1
- package/dist/bar-chart/chart-bars.d.ts +43 -74
- package/dist/bar-chart/chart-bars.d.ts.map +1 -1
- package/dist/bar-chart/chart-bars.js +31 -59
- package/dist/bar-chart/chart-bars.js.map +1 -1
- package/dist/bar-chart/index.d.ts +2 -10
- package/dist/bar-chart/index.d.ts.map +1 -1
- package/dist/bar-chart/index.js +0 -7
- package/dist/bar-chart/index.js.map +1 -1
- package/dist/bar-chart/shared.d.ts +27 -90
- package/dist/bar-chart/shared.d.ts.map +1 -1
- package/dist/bar-chart/shared.js +15 -54
- package/dist/bar-chart/shared.js.map +1 -1
- package/dist/browser/chunks/chunk-4SMRCU24.js +2 -0
- package/dist/browser/chunks/chunk-4SMRCU24.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 +4 -16
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +4 -16
- package/dist/engine.js.map +1 -1
- package/dist/kit/cartesian.d.ts +8 -28
- package/dist/kit/cartesian.d.ts.map +1 -1
- package/dist/kit/cartesian.js +7 -19
- package/dist/kit/cartesian.js.map +1 -1
- package/dist/kit/chart-border.d.ts +9 -18
- package/dist/kit/chart-border.d.ts.map +1 -1
- package/dist/kit/chart-border.js +4 -9
- package/dist/kit/chart-border.js.map +1 -1
- package/dist/kit/chart-canvas.d.ts +25 -36
- package/dist/kit/chart-canvas.d.ts.map +1 -1
- package/dist/kit/chart-canvas.js +13 -13
- package/dist/kit/chart-canvas.js.map +1 -1
- package/dist/kit/chart-legend.d.ts +14 -61
- package/dist/kit/chart-legend.d.ts.map +1 -1
- package/dist/kit/chart-legend.js +17 -65
- package/dist/kit/chart-legend.js.map +1 -1
- package/dist/kit/entries.d.ts +2 -0
- package/dist/kit/entries.d.ts.map +1 -1
- package/dist/kit/entries.js +2 -0
- package/dist/kit/entries.js.map +1 -1
- package/dist/kit/motion.d.ts +11 -3
- package/dist/kit/motion.d.ts.map +1 -1
- package/dist/kit/motion.js +7 -1
- package/dist/kit/motion.js.map +1 -1
- package/dist/kit/shared.d.ts +60 -175
- package/dist/kit/shared.d.ts.map +1 -1
- package/dist/kit/shared.js +40 -157
- package/dist/kit/shared.js.map +1 -1
- package/dist/kit/vertical-axis.d.ts +16 -45
- package/dist/kit/vertical-axis.d.ts.map +1 -1
- package/dist/kit/vertical-axis.js +17 -42
- package/dist/kit/vertical-axis.js.map +1 -1
- package/dist/line-chart/chart-line.d.ts +16 -41
- package/dist/line-chart/chart-line.d.ts.map +1 -1
- package/dist/line-chart/chart-line.js +14 -43
- package/dist/line-chart/chart-line.js.map +1 -1
- package/dist/line-chart/chart-region.d.ts +14 -27
- package/dist/line-chart/chart-region.d.ts.map +1 -1
- package/dist/line-chart/chart-region.js +8 -22
- package/dist/line-chart/chart-region.js.map +1 -1
- package/dist/line-chart/horizontal-axis.d.ts +16 -43
- package/dist/line-chart/horizontal-axis.d.ts.map +1 -1
- package/dist/line-chart/horizontal-axis.js +13 -42
- package/dist/line-chart/horizontal-axis.js.map +1 -1
- package/dist/line-chart/index.d.ts +1 -13
- package/dist/line-chart/index.d.ts.map +1 -1
- package/dist/line-chart/index.js +0 -12
- package/dist/line-chart/index.js.map +1 -1
- package/dist/line-chart/line-chart.d.ts +23 -109
- package/dist/line-chart/line-chart.d.ts.map +1 -1
- package/dist/line-chart/line-chart.js +45 -141
- package/dist/line-chart/line-chart.js.map +1 -1
- package/dist/line-chart/shared.d.ts +33 -107
- package/dist/line-chart/shared.d.ts.map +1 -1
- package/dist/line-chart/shared.js +28 -81
- package/dist/line-chart/shared.js.map +1 -1
- package/dist/line-chart/x-scale.d.ts +14 -55
- package/dist/line-chart/x-scale.d.ts.map +1 -1
- package/dist/line-chart/x-scale.js +29 -111
- package/dist/line-chart/x-scale.js.map +1 -1
- package/dist/nodes.d.ts +0 -12
- package/dist/nodes.d.ts.map +1 -1
- package/dist/nodes.js +0 -12
- package/dist/nodes.js.map +1 -1
- package/dist/pie-chart/index.d.ts +1 -12
- package/dist/pie-chart/index.d.ts.map +1 -1
- package/dist/pie-chart/index.js +0 -9
- package/dist/pie-chart/index.js.map +1 -1
- package/dist/pie-chart/pie-chart.d.ts +37 -71
- package/dist/pie-chart/pie-chart.d.ts.map +1 -1
- package/dist/pie-chart/pie-chart.js +31 -65
- package/dist/pie-chart/pie-chart.js.map +1 -1
- package/dist/pie-chart/pie-legend.d.ts +11 -26
- package/dist/pie-chart/pie-legend.d.ts.map +1 -1
- package/dist/pie-chart/pie-legend.js +14 -39
- package/dist/pie-chart/pie-legend.js.map +1 -1
- package/dist/pie-chart/pie-ring.d.ts +50 -192
- package/dist/pie-chart/pie-ring.d.ts.map +1 -1
- package/dist/pie-chart/pie-ring.js +75 -247
- package/dist/pie-chart/pie-ring.js.map +1 -1
- package/dist/pie-chart/shared.d.ts +45 -131
- package/dist/pie-chart/shared.d.ts.map +1 -1
- package/dist/pie-chart/shared.js +29 -72
- package/dist/pie-chart/shared.js.map +1 -1
- package/package.json +4 -4
- package/src/bar-chart/bar-chart.ts +51 -165
- package/src/bar-chart/category-axis.ts +16 -57
- package/src/bar-chart/chart-bars.ts +43 -120
- package/src/bar-chart/index.ts +2 -10
- package/src/bar-chart/shared.ts +29 -97
- package/src/engine.ts +4 -16
- package/src/kit/cartesian.ts +11 -26
- package/src/kit/chart-border.ts +6 -20
- package/src/kit/chart-canvas.ts +15 -40
- package/src/kit/chart-legend.ts +17 -86
- package/src/kit/entries.ts +4 -1
- package/src/kit/motion.ts +11 -2
- package/src/kit/shared.ts +58 -219
- package/src/kit/vertical-axis.ts +18 -71
- package/src/line-chart/chart-line.ts +16 -44
- package/src/line-chart/chart-region.ts +10 -23
- package/src/line-chart/horizontal-axis.ts +13 -42
- package/src/line-chart/index.ts +1 -13
- package/src/line-chart/line-chart.ts +50 -184
- package/src/line-chart/shared.ts +51 -145
- package/src/line-chart/x-scale.ts +31 -121
- package/src/nodes.ts +0 -12
- package/src/pie-chart/index.ts +1 -12
- package/src/pie-chart/pie-chart.ts +34 -102
- package/src/pie-chart/pie-legend.ts +14 -55
- package/src/pie-chart/pie-ring.ts +80 -307
- package/src/pie-chart/shared.ts +50 -139
- package/dist/browser/chunks/chunk-TJNHLQGV.js +0 -2
- package/dist/browser/chunks/chunk-TJNHLQGV.js.map +0 -7
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
Rect,
|
|
5
5
|
Text,
|
|
6
6
|
Types,
|
|
7
|
+
clamp,
|
|
7
8
|
component2d,
|
|
8
9
|
fillOps,
|
|
9
10
|
shadowOps,
|
|
@@ -12,8 +13,9 @@ import {
|
|
|
12
13
|
type Fill,
|
|
13
14
|
type FillResolved,
|
|
14
15
|
type NodeOf,
|
|
16
|
+
// Named so this component's emitted type reaches `Node2D` and its props through the sdk, not core.
|
|
15
17
|
type Node2D,
|
|
16
|
-
type Node2DProps,
|
|
18
|
+
type Node2DProps as _Node2DProps,
|
|
17
19
|
type TextStyle,
|
|
18
20
|
type Vector2,
|
|
19
21
|
} from "@motionscript/sdk"
|
|
@@ -21,12 +23,13 @@ import {
|
|
|
21
23
|
import { asParts, fresh } from "../kit/entries"
|
|
22
24
|
import { frameEase } from "../kit/motion"
|
|
23
25
|
import { loadPaints } from "../kit/paints"
|
|
24
|
-
import {
|
|
26
|
+
import { formatPercent } from "../kit/shared"
|
|
25
27
|
import {
|
|
26
28
|
DEFAULT_PIE_CHART_THEME,
|
|
27
29
|
pieArcs,
|
|
28
30
|
relaxLabels,
|
|
29
31
|
ringPoint,
|
|
32
|
+
selectionFade,
|
|
30
33
|
type CenterConfig,
|
|
31
34
|
type LabelConfig,
|
|
32
35
|
type PieArc,
|
|
@@ -36,133 +39,51 @@ import {
|
|
|
36
39
|
type RingConfig,
|
|
37
40
|
} from "./shared"
|
|
38
41
|
|
|
39
|
-
export interface PieRingProps extends Node2DProps {
|
|
40
|
-
/** One entry per slice: its `category`, `value` and `fill`. */
|
|
41
|
-
slices: PieSlice[]
|
|
42
|
-
/** Ring geometry and paint — gap, hole, start angle, selection growth. */
|
|
43
|
-
ring: RingConfig
|
|
44
|
-
/** How the labels are set, and the leader lines that point at them. */
|
|
45
|
-
label: LabelConfig
|
|
46
|
-
/**
|
|
47
|
-
* How the hole's summary is set.
|
|
48
|
-
*
|
|
49
|
-
* Named `centerStyle` rather than `center` because every node already has a
|
|
50
|
-
* `center` — the anchor its position is measured from.
|
|
51
|
-
*/
|
|
52
|
-
centerStyle: CenterConfig
|
|
53
|
-
/** What the labels say, or `"none"` to drop them entirely. */
|
|
54
|
-
labelMode: PieLabelMode
|
|
55
|
-
/** Where the labels sit: inside their wedge, outside on a leader, or nowhere. */
|
|
56
|
-
labelPlacement: PieLabelPlacement
|
|
57
|
-
/**
|
|
58
|
-
* Sweep of the ring in `[0, 1]`: 0 draws nothing, 1 the full circle. The
|
|
59
|
-
* wedges grow around clockwise from the ring's `startAngle` as this rises.
|
|
60
|
-
*/
|
|
61
|
-
growth: number
|
|
62
|
-
/**
|
|
63
|
-
* Fade of the leader lines and labels in `[0, 1]`, held separate from
|
|
64
|
-
* {@link growth} so they arrive *after* the ring has swept in.
|
|
65
|
-
*/
|
|
66
|
-
labelReveal: number
|
|
67
|
-
/**
|
|
68
|
-
* How far each slice is currently pushed out of the ring, `0`–`1` per index.
|
|
69
|
-
* Owned by the chart, so the ring and the legend read one signal.
|
|
70
|
-
*/
|
|
71
|
-
pops: number[]
|
|
72
|
-
/** Opacity everything *unselected* falls back to while any slice is selected. */
|
|
73
|
-
dimOpacity: number
|
|
74
|
-
/**
|
|
75
|
-
* Text set in the middle of the ring's hole — the largest uninterrupted area
|
|
76
|
-
* the chart owns, and on a donut otherwise empty. A one-line summary there
|
|
77
|
-
* routinely does more work than the whole ring around it. Empty (the default)
|
|
78
|
-
* leaves the hole clear; ignored when there is no hole.
|
|
79
|
-
*/
|
|
80
|
-
centerLabel: string
|
|
81
|
-
/** Smaller second line under {@link centerLabel}. Empty (the default) drops it. */
|
|
82
|
-
centerCaption: string
|
|
83
|
-
/**
|
|
84
|
-
* Fade of the hole's summary in `[0, 1]`. Its own signal, untouched by the
|
|
85
|
-
* ring's entrance — a hole label is usually a *conclusion*, so a scene that
|
|
86
|
-
* wants it to land on a later beat sets this to 0 and calls
|
|
87
|
-
* `PieChart.revealCenter` when it gets there.
|
|
88
|
-
*/
|
|
89
|
-
centerReveal: number
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Vertical breathing room between one label block and the next, in px. */
|
|
93
42
|
const LABEL_GAP = 12
|
|
94
|
-
/** Breathing room past the outermost label, so it never touches the node's edge, in px. */
|
|
95
43
|
const LABEL_PAD = 14
|
|
96
|
-
/** Rough width
|
|
44
|
+
/** Rough glyph width as a fraction of the font size, for gutter sizing. */
|
|
97
45
|
const GLYPH_RATIO = 0.62
|
|
98
|
-
/** Gap between a wedge's outer edge and where its leader lifts off, in px. */
|
|
99
46
|
const LEADER_LIFTOFF = 6
|
|
100
|
-
/** How far past the ring's outer edge a leader runs before it elbows, in px. */
|
|
101
47
|
const LEADER_LENGTH = 34
|
|
102
|
-
/** Horizontal run from the elbow to the label, in px. */
|
|
103
48
|
const LABEL_STUB = 26
|
|
104
|
-
/** Gap between a leader's end and the label text it points at, in px. */
|
|
105
49
|
const TEXT_INSET = 8
|
|
106
|
-
/**
|
|
107
|
-
* Floor on the elbow's horizontal distance from the ring's axis, as a fraction
|
|
108
|
-
* of the leader radius — so a label pushed clear above (or below) the ring still
|
|
109
|
-
* elbows out sideways rather than running straight down the 12 o'clock line.
|
|
110
|
-
*/
|
|
50
|
+
/** Floor on the elbow's distance from the ring's axis, as a fraction of the leader radius, so a label pushed above or below the ring still elbows out sideways. */
|
|
111
51
|
const MIN_ELBOW = 0.18
|
|
112
|
-
/** Segments used to tessellate a full turn of arc; a wedge takes its share. */
|
|
113
52
|
const ARC_STEPS = 240
|
|
114
|
-
/** Rounding applied to the leader's elbow, in px. */
|
|
115
53
|
const ELBOW_RADIUS = 8
|
|
116
|
-
/** Side padding used when nothing is leadered out — no gutter to reserve. */
|
|
117
54
|
const BARE_PAD = 2
|
|
118
|
-
/** An inside label's caption size, as a fraction of its leading line. */
|
|
119
55
|
const INSIDE_CAPTION_RATIO = 0.44
|
|
120
|
-
/**
|
|
121
|
-
* Where an inside label sits on a **solid pie** (no hole to split the band
|
|
122
|
-
* with), as a fraction of the wedge's radius — far enough out that the label
|
|
123
|
-
* clears the point every wedge converges on.
|
|
124
|
-
*/
|
|
56
|
+
/** Where an inside label sits on a solid pie, as a fraction of the radius: far enough out to clear the point every wedge converges on. */
|
|
125
57
|
const PIE_LABEL_RADIUS = 0.62
|
|
126
|
-
/** Fraction of the hole's diameter its summary is allowed to run to. */
|
|
127
58
|
const CENTER_LABEL_FIT = 0.78
|
|
128
|
-
/** Fraction of the room inside a wedge that its label is allowed to run to. */
|
|
129
59
|
const INSIDE_LABEL_FIT = 0.9
|
|
130
|
-
/** Below this
|
|
60
|
+
/** Below this a wedge's bisector counts as axis-aligned and that bound stops binding. */
|
|
131
61
|
const AXIS_EPSILON = 1e-3
|
|
132
|
-
/**
|
|
62
|
+
/** Type size when a theme style leaves it unset or on autofit. */
|
|
133
63
|
const FALLBACK_SIZE = 26
|
|
134
64
|
|
|
135
|
-
/** A style's numeric size, guarding the autofit and unset cases. */
|
|
136
65
|
function sizeOf(style: TextStyle, fallback = FALLBACK_SIZE): number {
|
|
137
66
|
const size = style.fontSize
|
|
138
67
|
return typeof size === "number" ? size : fallback
|
|
139
68
|
}
|
|
140
69
|
|
|
141
|
-
/** Where one slice's label sits and the polyline that points at it. */
|
|
142
70
|
interface LabelPlacement {
|
|
143
|
-
/** Index of the slice. */
|
|
144
71
|
index: number
|
|
145
|
-
/**
|
|
72
|
+
/** +1 right, -1 left. */
|
|
146
73
|
side: 1 | -1
|
|
147
|
-
/**
|
|
74
|
+
/** Wedge edge, elbow, label. */
|
|
148
75
|
leader: Vector2[]
|
|
149
|
-
/**
|
|
76
|
+
/** Where the label's inner edge is pinned. */
|
|
150
77
|
text: Vector2
|
|
151
78
|
}
|
|
152
79
|
|
|
153
80
|
/**
|
|
154
|
-
* A
|
|
155
|
-
* `
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* ends of the wedge. It's converted to an angle *per radius* (`halfGap / r` — a
|
|
161
|
-
* wider angle near the hole than out at the rim), which is what keeps the channel
|
|
162
|
-
* between two slices the same width all the way across the band. A fixed angular
|
|
163
|
-
* gap instead reads as a thin pizza slice: right at the rim, pinched shut at the
|
|
164
|
-
* hole. The inset is capped at the wedge's own half-span so a sliver of a slice
|
|
165
|
-
* narrows to nothing rather than turning inside out.
|
|
81
|
+
* A wedge over `a0`..`a1` (radians clockwise from 12 o'clock) from `rInner` to
|
|
82
|
+
* `rOuter`, centred on `cx`; with `rInner <= 0` it closes through the centre.
|
|
83
|
+
* `halfGap` is half the px gap to the neighbour, converted to an angle per
|
|
84
|
+
* radius (wider near the hole) so the channel keeps one width across the band.
|
|
85
|
+
* The inset is capped at the wedge's half-span so a sliver narrows to nothing
|
|
86
|
+
* rather than turning inside out.
|
|
166
87
|
*/
|
|
167
88
|
function wedgePath(
|
|
168
89
|
a0: number,
|
|
@@ -178,7 +99,6 @@ function wedgePath(
|
|
|
178
99
|
2,
|
|
179
100
|
Math.ceil((Math.abs(span) / (Math.PI * 2)) * ARC_STEPS)
|
|
180
101
|
)
|
|
181
|
-
/** Angular inset at radius `r` that puts the wedge's edge `halfGap` px off the divide. */
|
|
182
102
|
const insetAt = (r: number): number =>
|
|
183
103
|
r <= 0 ? 0 : Math.min(halfGap / r, span / 2.2)
|
|
184
104
|
const arcTo = (r: number, i: number): Vector2 => {
|
|
@@ -190,8 +110,7 @@ function wedgePath(
|
|
|
190
110
|
}
|
|
191
111
|
|
|
192
112
|
if (rInner <= 0) {
|
|
193
|
-
//
|
|
194
|
-
// carries the gap.
|
|
113
|
+
// The sides converge on the centre, so only the rim carries the gap.
|
|
195
114
|
path.moveTo(cx, 0)
|
|
196
115
|
for (let i = 0; i <= steps; i++) {
|
|
197
116
|
const p = arcTo(rOuter, i)
|
|
@@ -206,8 +125,7 @@ function wedgePath(
|
|
|
206
125
|
if (i === 0) path.moveTo(p.x, p.y)
|
|
207
126
|
else path.lineTo(p.x, p.y)
|
|
208
127
|
}
|
|
209
|
-
//
|
|
210
|
-
// straight chord sitting `halfGap` px off the divide at both ends.
|
|
128
|
+
// The inner arc's own wider inset makes each side edge a chord `halfGap` px off the divide at both ends.
|
|
211
129
|
for (let i = steps; i >= 0; i--) {
|
|
212
130
|
const p = arcTo(rInner, i)
|
|
213
131
|
path.lineTo(p.x, p.y)
|
|
@@ -216,7 +134,6 @@ function wedgePath(
|
|
|
216
134
|
return path
|
|
217
135
|
}
|
|
218
136
|
|
|
219
|
-
/** What the ring's geometry reads off its entries. */
|
|
220
137
|
interface RingState {
|
|
221
138
|
readonly slices: PieSlice[]
|
|
222
139
|
readonly ring: RingConfig
|
|
@@ -230,45 +147,28 @@ interface RingState {
|
|
|
230
147
|
readonly centerCaption: string
|
|
231
148
|
}
|
|
232
149
|
|
|
233
|
-
/**
|
|
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.
|
|
238
|
-
*/
|
|
150
|
+
/** Every radius, gutter and label position for one box, derived afresh wherever read so the drawing and the labels' bound positions cannot disagree. */
|
|
239
151
|
export class RingGeometry {
|
|
240
152
|
constructor(
|
|
241
153
|
private readonly s: RingState,
|
|
242
154
|
private readonly box: { readonly width: number; readonly height: number }
|
|
243
155
|
) { }
|
|
244
156
|
|
|
245
|
-
/**
|
|
157
|
+
/** Re-read every frame so a value tween re-cuts them. */
|
|
246
158
|
get arcs(): PieArc[] {
|
|
247
159
|
return pieArcs(this.s.slices, this.s.ring.startAngle)
|
|
248
160
|
}
|
|
249
161
|
|
|
250
|
-
/**
|
|
162
|
+
/** Leader labels are the only thing that costs a gutter. */
|
|
251
163
|
get hasLeaderLabels(): boolean {
|
|
252
164
|
return this.s.labelPlacement === "leader" && this.s.labelMode !== "none" && this.arcs.length > 0
|
|
253
165
|
}
|
|
254
166
|
|
|
255
|
-
// ---- Label sizing ------------------------------------------------------
|
|
256
|
-
|
|
257
167
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* sits. Stepping sideways from the label's centre moves you *radially* by
|
|
263
|
-
* `dx·sin(mid)` and *tangentially* by `dx·cos(mid)`, so a wedge at 3 o'clock is
|
|
264
|
-
* limited by the band's thickness (its label runs straight across the band)
|
|
265
|
-
* while a wedge at 12 o'clock is limited by its own arc (its label runs along
|
|
266
|
-
* the band, which is much roomier). Taking the smaller of the two is what stops
|
|
267
|
-
* a label overflowing the ring on one chart and being pointlessly shrunk on the
|
|
268
|
-
* next.
|
|
269
|
-
*
|
|
270
|
-
* Measured off the *unpopped* radius, so a slice growing out of the ring
|
|
271
|
-
* doesn't resize its own text mid-animation.
|
|
168
|
+
* Px a horizontal label centred in `arc`'s band can run before leaving the
|
|
169
|
+
* wedge: bound by the band's thickness near 3 o'clock, by its arc near 12.
|
|
170
|
+
* Measured off the unpopped radius, so a slice growing out of the ring does not
|
|
171
|
+
* resize its own text mid-animation.
|
|
272
172
|
*/
|
|
273
173
|
insideRoom(arc: PieArc): number {
|
|
274
174
|
const outer = this.outerRadius
|
|
@@ -287,21 +187,12 @@ export class RingGeometry {
|
|
|
287
187
|
const mode = this.s.labelMode
|
|
288
188
|
const category = this.s.slices[arc.index]?.category.length ?? 0
|
|
289
189
|
const percent = mode === "both" || mode === "percent" ? formatPercent(arc.fraction).length : 0
|
|
290
|
-
// The caption is
|
|
291
|
-
// the leading line.
|
|
190
|
+
// The caption is a fraction of the leading line, unless it is the leading line.
|
|
292
191
|
const caption = mode === "category" ? category : mode === "both" ? category * INSIDE_CAPTION_RATIO : 0
|
|
293
192
|
return Math.max(1, percent, caption)
|
|
294
193
|
}
|
|
295
194
|
|
|
296
|
-
/**
|
|
297
|
-
* Font size of an inside label's leading line: what the theme asked for,
|
|
298
|
-
* shrunk if that wouldn't fit inside its wedge. **One size across every inside
|
|
299
|
-
* label** — the smallest any of them needs — because labels at mismatched
|
|
300
|
-
* sizes read as a mistake rather than as emphasis.
|
|
301
|
-
*
|
|
302
|
-
* So the theme's `insideStyle.fontSize` is an upper bound, not a promise: ask
|
|
303
|
-
* for the size the chart wants and the ring keeps it legal.
|
|
304
|
-
*/
|
|
195
|
+
/** The theme's `insideStyle.fontSize` shrunk to fit, an upper bound. One size across every inside label (the smallest any needs): mismatched sizes read as a mistake. */
|
|
305
196
|
get insideFont(): number {
|
|
306
197
|
let size = sizeOf(this.s.label.insideStyle, FALLBACK_SIZE * 1.9)
|
|
307
198
|
for (const arc of this.arcs) {
|
|
@@ -311,41 +202,29 @@ export class RingGeometry {
|
|
|
311
202
|
return Math.max(1, size)
|
|
312
203
|
}
|
|
313
204
|
|
|
314
|
-
/**
|
|
315
|
-
* Font size of the hole's summary: the configured size, capped so it still
|
|
316
|
-
* fits across the hole. The cap matters because the hole's radius derives from
|
|
317
|
-
* the box, so a chart that reflows smaller would otherwise push its summary out
|
|
318
|
-
* through the ring.
|
|
319
|
-
*/
|
|
205
|
+
/** The configured size capped to fit across the hole, which derives from the box: a smaller reflow would otherwise push the summary through the ring. */
|
|
320
206
|
get centerFont(): number {
|
|
321
207
|
const { centerStyle, centerLabel, centerCaption } = this.s
|
|
322
208
|
const wanted = sizeOf(centerStyle.labelStyle, 46)
|
|
323
209
|
const hole = this.holeRadius
|
|
324
210
|
if (hole <= 0) return wanted
|
|
325
211
|
const captionRatio = sizeOf(centerStyle.captionStyle, 22) / Math.max(1, wanted)
|
|
326
|
-
// Both lines
|
|
327
|
-
// this size — so what binds is whichever is wider once drawn, not whichever
|
|
328
|
-
// has more characters.
|
|
212
|
+
// Both lines must clear the hole; the caption is a fraction of this size, so the wider once drawn binds, not the longer.
|
|
329
213
|
const glyphs = Math.max(1, centerLabel.length, centerCaption.length * captionRatio)
|
|
330
214
|
return Math.min(wanted, (hole * 2 * CENTER_LABEL_FIT) / (glyphs * GLYPH_RATIO))
|
|
331
215
|
}
|
|
332
216
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
/** Rim radius of a fully selected slice as a multiple of the ring's, clamped sane. */
|
|
217
|
+
/** Rim radius of a fully selected slice as a multiple of the ring's. */
|
|
336
218
|
get growFactor(): number {
|
|
337
219
|
return Math.max(1, Math.min(1.6, this.s.ring.selectedGrow))
|
|
338
220
|
}
|
|
339
221
|
|
|
340
|
-
/** How far past the ring's rim a fully selected slice reaches, in px. */
|
|
341
222
|
get maxPop(): number {
|
|
342
223
|
return this.outerRadius * (this.growFactor - 1)
|
|
343
224
|
}
|
|
344
225
|
|
|
345
|
-
/** Height of one
|
|
226
|
+
/** Height of one leader label block, the unit the columns are spaced by. Inside labels cost no vertical reserve. */
|
|
346
227
|
get labelRowHeight(): number {
|
|
347
|
-
// Inside labels are stacked in the band, not in a column beside the ring, so
|
|
348
|
-
// they cost no vertical reserve.
|
|
349
228
|
if (!this.hasLeaderLabels) return 0
|
|
350
229
|
const caption = sizeOf(this.s.label.textStyle) * 1.25
|
|
351
230
|
const percent = sizeOf(this.s.label.percentStyle) * 1.25
|
|
@@ -353,7 +232,7 @@ export class RingGeometry {
|
|
|
353
232
|
return this.s.labelMode === "category" ? caption : percent
|
|
354
233
|
}
|
|
355
234
|
|
|
356
|
-
/** Widest line
|
|
235
|
+
/** Widest line in glyphs a leader label for slice `index` runs to. */
|
|
357
236
|
captionGlyphs(index: number): number {
|
|
358
237
|
const mode = this.s.labelMode
|
|
359
238
|
// "100%" is the widest a percentage gets; captions are their own length.
|
|
@@ -362,14 +241,7 @@ export class RingGeometry {
|
|
|
362
241
|
return Math.max(percent, category)
|
|
363
242
|
}
|
|
364
243
|
|
|
365
|
-
/**
|
|
366
|
-
* Horizontal room reserved on each side for the leaders and their labels, in
|
|
367
|
-
* px — measured **per side**, from the slices that actually leader out that
|
|
368
|
-
* way. A ring whose labels are all inside reserves nothing and draws bigger.
|
|
369
|
-
*
|
|
370
|
-
* Excludes the ring's own selection-growth allowance, which scales with the
|
|
371
|
-
* radius and so is divided out in {@link outerRadius} instead.
|
|
372
|
-
*/
|
|
244
|
+
/** Room reserved per side for leaders and labels, from the slices that leader out that way. Excludes selection growth, which scales with the radius and is divided out in {@link outerRadius}. */
|
|
373
245
|
get labelGutters(): { left: number; right: number } {
|
|
374
246
|
const gutters = { left: BARE_PAD, right: BARE_PAD }
|
|
375
247
|
if (!this.hasLeaderLabels) return gutters
|
|
@@ -382,30 +254,19 @@ export class RingGeometry {
|
|
|
382
254
|
return gutters
|
|
383
255
|
}
|
|
384
256
|
|
|
385
|
-
/**
|
|
386
|
-
* How far the ring's centre sits from the node's, in px. Zero while the
|
|
387
|
-
* gutters match; when the labels all fall one way it slides the ring into the
|
|
388
|
-
* empty half so the ring-plus-labels group stays centred in the box instead of
|
|
389
|
-
* the ring sitting dead centre with a bald patch beside it.
|
|
390
|
-
*/
|
|
257
|
+
/** Slides the ring into the empty half when labels all fall one way, keeping the ring-plus-labels group centred. */
|
|
391
258
|
get centerX(): number {
|
|
392
259
|
const { left, right } = this.labelGutters
|
|
393
260
|
return (left - right) / 2
|
|
394
261
|
}
|
|
395
262
|
|
|
396
|
-
/**
|
|
397
|
-
* Radius of the ring's outer edge, fitted to whatever the label gutters leave.
|
|
398
|
-
* A fully selected slice reaches `radius × growFactor`, so the room has to
|
|
399
|
-
* cover that — hence the divisor, which keeps even the grown slice inside the
|
|
400
|
-
* box (and holds the ring the same size whether or not anything is selected).
|
|
401
|
-
*/
|
|
263
|
+
/** Fitted to what the gutters leave. The divisor keeps a fully selected slice (`radius * growFactor`) inside the box, and the ring one size whether or not anything is selected. */
|
|
402
264
|
get outerRadius(): number {
|
|
403
265
|
const { left, right } = this.labelGutters
|
|
404
266
|
const room = Math.min((this.box.width - left - right) / 2, this.box.height / 2 - this.labelRowHeight - 4)
|
|
405
267
|
return Math.max(0, room / this.growFactor)
|
|
406
268
|
}
|
|
407
269
|
|
|
408
|
-
/** Radius of the hole in the middle: a fraction of the outer radius, or explicit px. */
|
|
409
270
|
get holeRadius(): number {
|
|
410
271
|
const configured = this.s.ring.holeRadius
|
|
411
272
|
if (configured <= 0) return 0
|
|
@@ -413,34 +274,26 @@ export class RingGeometry {
|
|
|
413
274
|
return Math.min(px, this.outerRadius * 0.95)
|
|
414
275
|
}
|
|
415
276
|
|
|
416
|
-
/**
|
|
277
|
+
/** The circle every leader elbows on, clear of even a popped-out slice. */
|
|
417
278
|
get elbowRadius(): number {
|
|
418
279
|
return this.outerRadius + this.maxPop + LEADER_LENGTH
|
|
419
280
|
}
|
|
420
281
|
|
|
421
|
-
/** How far slice `index` is currently pushed out, in `[0, 1]`. */
|
|
422
282
|
popOf(index: number): number {
|
|
423
|
-
return
|
|
283
|
+
return clamp(this.s.pops[index] ?? 0, 0, 1)
|
|
424
284
|
}
|
|
425
285
|
|
|
426
|
-
/** Radius of slice `index`'s outer edge, its selection growth included. */
|
|
427
286
|
anchorRadius(index: number): number {
|
|
428
287
|
return this.outerRadius + this.maxPop * this.popOf(index)
|
|
429
288
|
}
|
|
430
289
|
|
|
431
|
-
/**
|
|
432
|
-
* Radius an inside label sits at on slice `index`: halfway across the band on a
|
|
433
|
-
* donut, and further out than halfway on a solid pie, where "halfway" would
|
|
434
|
-
* crowd the point every wedge converges on. Rides the slice's own pop, so an
|
|
435
|
-
* inside label travels out with the wedge it belongs to.
|
|
436
|
-
*/
|
|
290
|
+
/** Halfway across the band on a donut, further out on a solid pie, where halfway would crowd the point the wedges converge on. Rides the slice's pop. */
|
|
437
291
|
bandMidRadius(index: number): number {
|
|
438
292
|
const rim = this.anchorRadius(index)
|
|
439
293
|
const hole = this.holeRadius
|
|
440
294
|
return hole > 0 ? (hole + rim) / 2 : rim * PIE_LABEL_RADIUS
|
|
441
295
|
}
|
|
442
296
|
|
|
443
|
-
/** The point slice `index`'s inside label is centred on. */
|
|
444
297
|
insideAnchor(index: number): Vector2 {
|
|
445
298
|
const arc = this.arcs.find((a) => a.index === index)
|
|
446
299
|
if (arc === undefined) return { x: this.centerX, y: 0 }
|
|
@@ -448,45 +301,24 @@ export class RingGeometry {
|
|
|
448
301
|
return { x: point.x + this.centerX, y: point.y }
|
|
449
302
|
}
|
|
450
303
|
|
|
451
|
-
/**
|
|
452
|
-
* How selected the ring is overall, in `[0, 1]` — the most-grown slice. Rides
|
|
453
|
-
* the same tween the pops do, so the dim below arrives with the growth rather
|
|
454
|
-
* than needing an animation of its own.
|
|
455
|
-
*/
|
|
304
|
+
/** The most-grown slice, so the dim in {@link sliceOpacity} rides the pops' tween. */
|
|
456
305
|
get selectionStrength(): number {
|
|
457
306
|
let strongest = 0
|
|
458
307
|
for (let i = 0; i < this.s.slices.length; i++) strongest = Math.max(strongest, this.popOf(i))
|
|
459
308
|
return strongest
|
|
460
309
|
}
|
|
461
310
|
|
|
462
|
-
/**
|
|
463
|
-
* Opacity slice `index` (and its leader and label) is drawn at: full while
|
|
464
|
-
* nothing is selected, falling toward `dimOpacity` in proportion to how
|
|
465
|
-
* selected the *rest* of the ring is. A slice that's selected itself stays at
|
|
466
|
-
* full — the two factors cancel.
|
|
467
|
-
*/
|
|
311
|
+
/** Opacity of slice `index` with its leader and label. */
|
|
468
312
|
sliceOpacity(index: number): number {
|
|
469
|
-
|
|
470
|
-
if (focus <= 0) return 1
|
|
471
|
-
return 1 - (1 - clampUnit(this.s.dimOpacity)) * focus * (1 - this.popOf(index))
|
|
313
|
+
return selectionFade(this.selectionStrength, this.s.dimOpacity, this.popOf(index))
|
|
472
314
|
}
|
|
473
315
|
|
|
474
|
-
/** Slice `index`'s share of the ring as its label reads it, off the live values. */
|
|
475
316
|
percentText(index: number): string {
|
|
476
317
|
const arc = this.arcs.find((a) => a.index === index)
|
|
477
318
|
return formatPercent(arc?.fraction ?? 0)
|
|
478
319
|
}
|
|
479
320
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* Where every leader label sits and how its leader gets there. Labels are split
|
|
484
|
-
* into a right and a left column by their slice's bisector, each column is
|
|
485
|
-
* spread so nothing overlaps (and nothing leaves the box), and each leader is
|
|
486
|
-
* then built as wedge edge → elbow → label. The elbow rides the leader circle at
|
|
487
|
-
* the label's *settled* y, which keeps the final run horizontal and the whole
|
|
488
|
-
* polyline outside the ring however far the label was nudged.
|
|
489
|
-
*/
|
|
321
|
+
/** Labels split into left and right columns by their slice's bisector, each spread so nothing overlaps or leaves the box, then a leader built to each. */
|
|
490
322
|
placements(): LabelPlacement[] {
|
|
491
323
|
if (!this.hasLeaderLabels) return []
|
|
492
324
|
const arcs = this.arcs
|
|
@@ -518,7 +350,7 @@ export class RingGeometry {
|
|
|
518
350
|
return [...right, ...left].map((slot) => {
|
|
519
351
|
const start = ringPoint(this.anchorRadius(slot.index) + LEADER_LIFTOFF, slot.mid)
|
|
520
352
|
// The elbow sits where the label's y crosses the leader circle. Solving
|
|
521
|
-
// x
|
|
353
|
+
// x^2 + y^2 = radius^2 for x keeps it on that circle (never inside the
|
|
522
354
|
// ring); a label pushed past the circle's top or bottom falls back to the
|
|
523
355
|
// sideways floor so its leader still reads as pointing outward.
|
|
524
356
|
const onCircle = Math.sqrt(Math.max(0, radius * radius - slot.y * slot.y))
|
|
@@ -538,25 +370,18 @@ export class RingGeometry {
|
|
|
538
370
|
})
|
|
539
371
|
}
|
|
540
372
|
|
|
541
|
-
/** This frame's placement for slice `index`, or `undefined` if it isn't leadered. */
|
|
542
373
|
placementFor(index: number): LabelPlacement | undefined {
|
|
543
374
|
return this.placements().find((placement) => placement.index === index)
|
|
544
375
|
}
|
|
545
376
|
}
|
|
546
377
|
|
|
547
|
-
/** Which side a leader off bisector `mid` runs to: +1 right, −1 left. */
|
|
548
378
|
function sideOf(mid: number): 1 | -1 {
|
|
549
379
|
return Math.sin(mid) >= 0 ? 1 : -1
|
|
550
380
|
}
|
|
551
381
|
|
|
552
382
|
const NO_BOX = { width: 0, height: 0 }
|
|
553
383
|
|
|
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
|
-
*/
|
|
384
|
+
/** Fills resolved once per paint, not per frame: a theme tween hands over new paint, but a frame that moves nothing must not re-resolve. Keyed by the paint, so it needs no node. */
|
|
560
385
|
const resolvedFills = new WeakMap<object, FillResolved[]>()
|
|
561
386
|
|
|
562
387
|
function resolvedFill(fill: Fill): FillResolved[] {
|
|
@@ -566,38 +391,23 @@ function resolvedFill(fill: Fill): FillResolved[] {
|
|
|
566
391
|
return resolved
|
|
567
392
|
}
|
|
568
393
|
|
|
569
|
-
/** Each slice's fill, resolved. */
|
|
570
394
|
export function ringFills(slices: readonly PieSlice[]): FillResolved[][] {
|
|
571
395
|
return slices.map((slice) => resolvedFill(slice.fill))
|
|
572
396
|
}
|
|
573
397
|
|
|
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
|
-
*/
|
|
398
|
+
/** One label per drawn slice (a zero-valued slice has no wedge), then the hole's summary. Only the choice of form is settled here; the pinned points stay reactive. */
|
|
583
399
|
function ringLabels(s: RingState & { readonly labelReveal: number; readonly centerReveal: number }) {
|
|
584
400
|
const blocks: Node2D[] = []
|
|
585
|
-
// A label block's parent is the ring
|
|
401
|
+
// A label block's parent is the ring, whose box it was laid out at.
|
|
586
402
|
const geometry = () => new RingGeometry(s, (blocks[0]?.parent as Node2D | null | undefined)?.layoutBounds ?? NO_BOX)
|
|
587
403
|
|
|
588
404
|
const label = (arc: PieArc): Node2D => {
|
|
589
|
-
|
|
590
|
-
// whenever another slice takes the focus.
|
|
591
|
-
const opacity = () => clampUnit(s.labelReveal) * geometry().sliceOpacity(arc.index)
|
|
405
|
+
const opacity = () => clamp(s.labelReveal, 0, 1) * geometry().sliceOpacity(arc.index)
|
|
592
406
|
const wantsCategory = s.labelMode === "both" || s.labelMode === "category"
|
|
593
407
|
const wantsPercent = s.labelMode === "both" || s.labelMode === "percent"
|
|
594
408
|
|
|
595
409
|
if (s.labelPlacement === "inside") {
|
|
596
|
-
// The number leads
|
|
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.
|
|
410
|
+
// The number leads: inside a wedge the reader knows which slice it is. Sizes are bound because the fitted size derives from a radius unknown until layout.
|
|
601
411
|
const inside: Node2D[] = []
|
|
602
412
|
if (wantsPercent) {
|
|
603
413
|
inside.push(new Text({
|
|
@@ -649,7 +459,6 @@ function ringLabels(s: RingState & { readonly labelReveal: number; readonly cent
|
|
|
649
459
|
gap: 2,
|
|
650
460
|
align: side > 0 ? "centerLeft" : "centerRight",
|
|
651
461
|
opacity,
|
|
652
|
-
// Exactly one of these is set; `undefined` anchors are ignored.
|
|
653
462
|
centerLeft: side > 0 ? at : undefined,
|
|
654
463
|
centerRight: side > 0 ? undefined : at,
|
|
655
464
|
children,
|
|
@@ -660,8 +469,7 @@ function ringLabels(s: RingState & { readonly labelReveal: number; readonly cent
|
|
|
660
469
|
for (const arc of pieArcs(s.slices, s.ring.startAngle)) blocks.push(label(arc))
|
|
661
470
|
}
|
|
662
471
|
|
|
663
|
-
//
|
|
664
|
-
// shift a one-sided label column puts on the ring.
|
|
472
|
+
// Pinned to the ring's centre, so it tracks the sideways shift a one-sided label column causes.
|
|
665
473
|
if (s.centerLabel !== "" || s.centerCaption !== "") {
|
|
666
474
|
const children: Node2D[] = []
|
|
667
475
|
if (s.centerLabel !== "") {
|
|
@@ -685,7 +493,7 @@ function ringLabels(s: RingState & { readonly labelReveal: number; readonly cent
|
|
|
685
493
|
height: "hug",
|
|
686
494
|
gap: 2,
|
|
687
495
|
align: "center",
|
|
688
|
-
opacity: () =>
|
|
496
|
+
opacity: () => clamp(s.centerReveal, 0, 1),
|
|
689
497
|
center: () => ({ x: geometry().centerX, y: 0 }),
|
|
690
498
|
children,
|
|
691
499
|
}))
|
|
@@ -694,43 +502,12 @@ function ringLabels(s: RingState & { readonly labelReveal: number; readonly cent
|
|
|
694
502
|
}
|
|
695
503
|
|
|
696
504
|
/**
|
|
697
|
-
* The ring
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
*
|
|
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.
|
|
505
|
+
* The ring: one wedge per {@link PieSlice} swept clockwise from `startAngle`,
|
|
506
|
+
* with a constant-width `gap` (see {@link wedgePath}). A selected slice swells
|
|
507
|
+
* out while its hole edge stays put, and the rest dims, leaders and labels
|
|
508
|
+
* included. The only path-drawing part of the chart family; labels are real
|
|
509
|
+
* {@link Text} nodes, so no glyph-width guesswork places a caption. Only the
|
|
510
|
+
* sides that carry a leader reserve a gutter, and the ring slides into the rest.
|
|
734
511
|
*/
|
|
735
512
|
export const PieRing = component2d({
|
|
736
513
|
key: "pieRing",
|
|
@@ -738,22 +515,28 @@ export const PieRing = component2d({
|
|
|
738
515
|
slices: fresh<PieSlice[]>(() => []),
|
|
739
516
|
ring: Types.custom<RingConfig>(DEFAULT_PIE_CHART_THEME.ring),
|
|
740
517
|
label: Types.custom<LabelConfig>(DEFAULT_PIE_CHART_THEME.label),
|
|
518
|
+
// Not `center`: every node already has one, the anchor its position is measured from.
|
|
741
519
|
centerStyle: Types.custom<CenterConfig>(DEFAULT_PIE_CHART_THEME.center),
|
|
742
520
|
labelMode: Types.custom<PieLabelMode>("percent"),
|
|
743
521
|
labelPlacement: Types.custom<PieLabelPlacement>("leader"),
|
|
522
|
+
/** Sweep `[0, 1]`: the wedges grow clockwise from `startAngle` as it rises. */
|
|
744
523
|
growth: 1,
|
|
524
|
+
/** Fade of leaders and labels, separate from `growth` so they arrive after the sweep. */
|
|
745
525
|
labelReveal: 1,
|
|
526
|
+
/** Per-slice pop `[0, 1]`, owned by the chart so the ring and legend read one signal. */
|
|
746
527
|
pops: fresh<number[]>(() => []),
|
|
528
|
+
/** Opacity everything unselected falls back to while any slice is selected. */
|
|
747
529
|
dimOpacity: DEFAULT_PIE_CHART_THEME.spotlight.dimOpacity,
|
|
530
|
+
/** Empty leaves the hole clear; ignored when there is no hole. */
|
|
748
531
|
centerLabel: "",
|
|
749
532
|
centerCaption: "",
|
|
533
|
+
/** The hole's summary fade, untouched by the entrance: a scene that wants the conclusion on a later beat sets 0 and calls `revealCenter`. */
|
|
750
534
|
centerReveal: 1,
|
|
751
535
|
},
|
|
752
536
|
// A plain node filled its parent; one made of its labels would hug them.
|
|
753
537
|
defaults: { width: "fill", height: "fill" },
|
|
754
538
|
children: (state) => ringLabels(state),
|
|
755
|
-
// The
|
|
756
|
-
// the wedge outline and drop shadow of `ring`, and the leader line's own stroke.
|
|
539
|
+
// The paints sit past the paint entries: a fill per slice, the wedge outline and shadow, and the leader stroke.
|
|
757
540
|
load: (loader, state) =>
|
|
758
541
|
loadPaints(loader, {
|
|
759
542
|
fills: ringFills(state.slices),
|
|
@@ -761,28 +544,20 @@ export const PieRing = component2d({
|
|
|
761
544
|
strokes: [strokeOps.resolve(state.ring.stroke), strokeOps.resolve(state.label.leaderStroke)],
|
|
762
545
|
}),
|
|
763
546
|
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
|
-
*/
|
|
547
|
+
/** Wipe the wedges in around the ring (`growth` 0 to 1). */
|
|
769
548
|
sweep: (timeline) => {
|
|
770
549
|
const ease = frameEase(timeline)
|
|
771
550
|
return timeline.animate((t) => ({ growth: ease(t) }))
|
|
772
551
|
},
|
|
773
|
-
/**
|
|
552
|
+
/** Fade the leaders and captions up. */
|
|
774
553
|
revealLabels: (timeline) => {
|
|
775
554
|
const ease = frameEase(timeline)
|
|
776
555
|
return timeline.animate((t) => ({ labelReveal: ease(t) }))
|
|
777
556
|
},
|
|
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
|
-
*/
|
|
557
|
+
/** Fade the hole's summary up; not part of the entrance, since it is usually the conclusion. */
|
|
783
558
|
revealCenter: (timeline, state) => {
|
|
784
559
|
const ease = frameEase(timeline)
|
|
785
|
-
const from =
|
|
560
|
+
const from = clamp(state.centerReveal, 0, 1)
|
|
786
561
|
return timeline.animate((t) => ({ centerReveal: from + (1 - from) * ease(t) }))
|
|
787
562
|
},
|
|
788
563
|
},
|
|
@@ -791,7 +566,7 @@ export const PieRing = component2d({
|
|
|
791
566
|
const rOuter = g.outerRadius
|
|
792
567
|
const arcs = rOuter > 0 ? g.arcs : []
|
|
793
568
|
if (arcs.length > 0) {
|
|
794
|
-
const sweep =
|
|
569
|
+
const sweep = clamp(state.growth, 0, 1)
|
|
795
570
|
const revealEnd = state.ring.startAngle * (Math.PI / 180) + Math.PI * 2 * sweep
|
|
796
571
|
const hole = g.holeRadius
|
|
797
572
|
const halfGap = Math.max(0, state.ring.gap) / 2
|
|
@@ -799,9 +574,9 @@ export const PieRing = component2d({
|
|
|
799
574
|
// One shared frame (the whole ring square, grown slices included) pinned onto
|
|
800
575
|
// every wedge path, so `Graphics2D.path()` doesn't re-centre each wedge on its
|
|
801
576
|
// own bbox and collapse them all onto the origin. It stays symmetric about the
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
//
|
|
577
|
+
// node's origin, so path coordinates land exactly in node space, and is grown
|
|
578
|
+
// by the ring's sideways offset rather than moved with it, which would undo
|
|
579
|
+
// the very shift it has to contain.
|
|
805
580
|
const cx = g.centerX
|
|
806
581
|
const half = rOuter + g.maxPop + 1 + Math.abs(cx)
|
|
807
582
|
const centerBounds: [number, number, number, number] = [-half, -half, half, half]
|
|
@@ -812,8 +587,7 @@ export const PieRing = component2d({
|
|
|
812
587
|
|
|
813
588
|
const fills = ringFills(state.slices)
|
|
814
589
|
for (const arc of arcs) {
|
|
815
|
-
//
|
|
816
|
-
// reaches it, so the ring draws in as one clockwise wipe.
|
|
590
|
+
// Clipped to the revealed arc, so the ring draws in as one clockwise wipe.
|
|
817
591
|
const a1 = Math.min(arc.a1, revealEnd)
|
|
818
592
|
if (a1 <= arc.a0) continue
|
|
819
593
|
|
|
@@ -825,11 +599,10 @@ export const PieRing = component2d({
|
|
|
825
599
|
ctx.draw(wedge.opacity(g.sliceOpacity(arc.index)))
|
|
826
600
|
}
|
|
827
601
|
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
const reveal = clampUnit(state.labelReveal)
|
|
602
|
+
// One `Graphics2D` per leader: open polylines chained into one command list
|
|
603
|
+
// are combined into a single surface before the stroke, which closes each
|
|
604
|
+
// contour and paints the leaders as thin triangles.
|
|
605
|
+
const reveal = clamp(state.labelReveal, 0, 1)
|
|
833
606
|
if (reveal > 0.001) {
|
|
834
607
|
for (const placement of g.placements()) {
|
|
835
608
|
ctx.draw(
|