@motionscript/charts 0.1.1-alpha.0 → 0.1.1-alpha.2

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.
Files changed (207) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +109 -28
  3. package/dist/bar-chart/bar-chart.d.ts +265 -39
  4. package/dist/bar-chart/bar-chart.d.ts.map +1 -1
  5. package/dist/bar-chart/bar-chart.js +219 -171
  6. package/dist/bar-chart/bar-chart.js.map +1 -1
  7. package/dist/bar-chart/category-axis.d.ts +12 -7
  8. package/dist/bar-chart/category-axis.d.ts.map +1 -1
  9. package/dist/bar-chart/category-axis.js +56 -42
  10. package/dist/bar-chart/category-axis.js.map +1 -1
  11. package/dist/bar-chart/chart-bars.d.ts +76 -52
  12. package/dist/bar-chart/chart-bars.d.ts.map +1 -1
  13. package/dist/bar-chart/chart-bars.js +105 -89
  14. package/dist/bar-chart/chart-bars.js.map +1 -1
  15. package/dist/bar-chart/index.d.ts +4 -4
  16. package/dist/bar-chart/index.d.ts.map +1 -1
  17. package/dist/bar-chart/index.js +2 -2
  18. package/dist/bar-chart/index.js.map +1 -1
  19. package/dist/bar-chart/shared.d.ts +41 -65
  20. package/dist/bar-chart/shared.d.ts.map +1 -1
  21. package/dist/bar-chart/shared.js +73 -117
  22. package/dist/bar-chart/shared.js.map +1 -1
  23. package/dist/browser/chunks/chunk-7XCRX6GE.js +2 -0
  24. package/dist/browser/chunks/chunk-7XCRX6GE.js.map +7 -0
  25. package/dist/browser/index.js +1 -1
  26. package/dist/browser/index.js.map +4 -4
  27. package/dist/browser/kit.js +1 -1
  28. package/dist/browser/manifest.json +1 -1
  29. package/dist/engine.d.ts +5 -2
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +5 -2
  32. package/dist/engine.js.map +1 -1
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/kit/cartesian.d.ts +51 -15
  38. package/dist/kit/cartesian.d.ts.map +1 -1
  39. package/dist/kit/cartesian.js +81 -29
  40. package/dist/kit/cartesian.js.map +1 -1
  41. package/dist/kit/chart-canvas.d.ts +32 -24
  42. package/dist/kit/chart-canvas.d.ts.map +1 -1
  43. package/dist/kit/chart-canvas.js +52 -46
  44. package/dist/kit/chart-canvas.js.map +1 -1
  45. package/dist/kit/chart-legend.d.ts +8 -4
  46. package/dist/kit/chart-legend.d.ts.map +1 -1
  47. package/dist/kit/chart-legend.js +30 -20
  48. package/dist/kit/chart-legend.js.map +1 -1
  49. package/dist/{line-chart → kit}/horizontal-axis.d.ts +31 -6
  50. package/dist/kit/horizontal-axis.d.ts.map +1 -0
  51. package/dist/{line-chart → kit}/horizontal-axis.js +23 -24
  52. package/dist/kit/horizontal-axis.js.map +1 -0
  53. package/dist/kit/motion.d.ts +5 -0
  54. package/dist/kit/motion.d.ts.map +1 -1
  55. package/dist/kit/motion.js +8 -0
  56. package/dist/kit/motion.js.map +1 -1
  57. package/dist/{pie-chart → kit}/pie-legend.d.ts +3 -3
  58. package/dist/kit/pie-legend.d.ts.map +1 -0
  59. package/dist/{pie-chart → kit}/pie-legend.js +11 -10
  60. package/dist/kit/pie-legend.js.map +1 -0
  61. package/dist/kit/polar.d.ts +31 -0
  62. package/dist/kit/polar.d.ts.map +1 -0
  63. package/dist/kit/polar.js +33 -0
  64. package/dist/kit/polar.js.map +1 -0
  65. package/dist/kit/shared.d.ts +11 -50
  66. package/dist/kit/shared.d.ts.map +1 -1
  67. package/dist/kit/shared.js +58 -141
  68. package/dist/kit/shared.js.map +1 -1
  69. package/dist/kit/style.d.ts +517 -0
  70. package/dist/kit/style.d.ts.map +1 -0
  71. package/dist/kit/style.js +292 -0
  72. package/dist/kit/style.js.map +1 -0
  73. package/dist/kit/vertical-axis.d.ts +22 -2
  74. package/dist/kit/vertical-axis.d.ts.map +1 -1
  75. package/dist/kit/vertical-axis.js +56 -43
  76. package/dist/kit/vertical-axis.js.map +1 -1
  77. package/dist/line-chart/chart-line.d.ts +95 -3
  78. package/dist/line-chart/chart-line.d.ts.map +1 -1
  79. package/dist/line-chart/chart-line.js +165 -53
  80. package/dist/line-chart/chart-line.js.map +1 -1
  81. package/dist/line-chart/chart-region.d.ts +1 -1
  82. package/dist/line-chart/chart-region.d.ts.map +1 -1
  83. package/dist/line-chart/chart-region.js +7 -8
  84. package/dist/line-chart/chart-region.js.map +1 -1
  85. package/dist/line-chart/index.d.ts +5 -6
  86. package/dist/line-chart/index.d.ts.map +1 -1
  87. package/dist/line-chart/index.js +3 -4
  88. package/dist/line-chart/index.js.map +1 -1
  89. package/dist/line-chart/line-chart.d.ts +281 -18
  90. package/dist/line-chart/line-chart.d.ts.map +1 -1
  91. package/dist/line-chart/line-chart.js +284 -134
  92. package/dist/line-chart/line-chart.js.map +1 -1
  93. package/dist/line-chart/shared.d.ts +51 -34
  94. package/dist/line-chart/shared.d.ts.map +1 -1
  95. package/dist/line-chart/shared.js +175 -83
  96. package/dist/line-chart/shared.js.map +1 -1
  97. package/dist/line-chart/x-scale.d.ts +2 -0
  98. package/dist/line-chart/x-scale.d.ts.map +1 -1
  99. package/dist/line-chart/x-scale.js +15 -0
  100. package/dist/line-chart/x-scale.js.map +1 -1
  101. package/dist/nodes.d.ts +3 -1
  102. package/dist/nodes.d.ts.map +1 -1
  103. package/dist/nodes.js +9 -1
  104. package/dist/nodes.js.map +1 -1
  105. package/dist/pie-chart/index.d.ts +2 -3
  106. package/dist/pie-chart/index.d.ts.map +1 -1
  107. package/dist/pie-chart/index.js +1 -2
  108. package/dist/pie-chart/index.js.map +1 -1
  109. package/dist/pie-chart/pie-chart.d.ts +114 -18
  110. package/dist/pie-chart/pie-chart.d.ts.map +1 -1
  111. package/dist/pie-chart/pie-chart.js +38 -24
  112. package/dist/pie-chart/pie-chart.js.map +1 -1
  113. package/dist/pie-chart/pie-ring.d.ts +16 -5
  114. package/dist/pie-chart/pie-ring.d.ts.map +1 -1
  115. package/dist/pie-chart/pie-ring.js +89 -55
  116. package/dist/pie-chart/pie-ring.js.map +1 -1
  117. package/dist/pie-chart/shared.d.ts +61 -62
  118. package/dist/pie-chart/shared.d.ts.map +1 -1
  119. package/dist/pie-chart/shared.js +44 -123
  120. package/dist/pie-chart/shared.js.map +1 -1
  121. package/dist/radar-chart/index.d.ts +5 -0
  122. package/dist/radar-chart/index.d.ts.map +1 -0
  123. package/dist/radar-chart/index.js +5 -0
  124. package/dist/radar-chart/index.js.map +1 -0
  125. package/dist/radar-chart/radar-chart.d.ts +212 -0
  126. package/dist/radar-chart/radar-chart.d.ts.map +1 -0
  127. package/dist/radar-chart/radar-chart.js +224 -0
  128. package/dist/radar-chart/radar-chart.js.map +1 -0
  129. package/dist/radar-chart/radar-grid.d.ts +49 -0
  130. package/dist/radar-chart/radar-grid.d.ts.map +1 -0
  131. package/dist/radar-chart/radar-grid.js +63 -0
  132. package/dist/radar-chart/radar-grid.js.map +1 -0
  133. package/dist/radar-chart/radar-series.d.ts +59 -0
  134. package/dist/radar-chart/radar-series.d.ts.map +1 -0
  135. package/dist/radar-chart/radar-series.js +71 -0
  136. package/dist/radar-chart/radar-series.js.map +1 -0
  137. package/dist/radar-chart/shared.d.ts +78 -0
  138. package/dist/radar-chart/shared.d.ts.map +1 -0
  139. package/dist/radar-chart/shared.js +129 -0
  140. package/dist/radar-chart/shared.js.map +1 -0
  141. package/dist/radial-chart/index.d.ts +6 -0
  142. package/dist/radial-chart/index.d.ts.map +1 -0
  143. package/dist/radial-chart/index.js +4 -0
  144. package/dist/radial-chart/index.js.map +1 -0
  145. package/dist/radial-chart/radial-chart.d.ts +210 -0
  146. package/dist/radial-chart/radial-chart.d.ts.map +1 -0
  147. package/dist/radial-chart/radial-chart.js +175 -0
  148. package/dist/radial-chart/radial-chart.js.map +1 -0
  149. package/dist/radial-chart/radial-rings.d.ts +121 -0
  150. package/dist/radial-chart/radial-rings.d.ts.map +1 -0
  151. package/dist/radial-chart/radial-rings.js +273 -0
  152. package/dist/radial-chart/radial-rings.js.map +1 -0
  153. package/dist/radial-chart/shared.d.ts +77 -0
  154. package/dist/radial-chart/shared.d.ts.map +1 -0
  155. package/dist/radial-chart/shared.js +78 -0
  156. package/dist/radial-chart/shared.js.map +1 -0
  157. package/package.json +4 -4
  158. package/registry.json +21 -6
  159. package/src/bar-chart/bar-chart.ts +263 -179
  160. package/src/bar-chart/category-axis.ts +46 -40
  161. package/src/bar-chart/chart-bars.ts +136 -117
  162. package/src/bar-chart/index.ts +7 -10
  163. package/src/bar-chart/shared.ts +88 -233
  164. package/src/engine.ts +5 -2
  165. package/src/index.ts +2 -0
  166. package/src/kit/cartesian.ts +121 -40
  167. package/src/kit/chart-canvas.ts +49 -54
  168. package/src/kit/chart-legend.ts +28 -20
  169. package/src/kit/horizontal-axis.ts +114 -0
  170. package/src/kit/motion.ts +7 -0
  171. package/src/{pie-chart → kit}/pie-legend.ts +12 -11
  172. package/src/kit/polar.ts +54 -0
  173. package/src/kit/shared.ts +55 -277
  174. package/src/kit/style.ts +353 -0
  175. package/src/kit/vertical-axis.ts +67 -48
  176. package/src/line-chart/chart-line.ts +176 -54
  177. package/src/line-chart/chart-region.ts +6 -10
  178. package/src/line-chart/index.ts +14 -10
  179. package/src/line-chart/line-chart.ts +353 -151
  180. package/src/line-chart/shared.ts +193 -174
  181. package/src/line-chart/x-scale.ts +13 -0
  182. package/src/nodes.ts +9 -1
  183. package/src/pie-chart/index.ts +2 -23
  184. package/src/pie-chart/pie-chart.ts +54 -35
  185. package/src/pie-chart/pie-ring.ts +92 -71
  186. package/src/pie-chart/shared.ts +80 -270
  187. package/src/radar-chart/index.ts +4 -0
  188. package/src/radar-chart/radar-chart.ts +290 -0
  189. package/src/radar-chart/radar-grid.ts +83 -0
  190. package/src/radar-chart/radar-series.ts +88 -0
  191. package/src/radar-chart/shared.ts +153 -0
  192. package/src/radial-chart/index.ts +5 -0
  193. package/src/radial-chart/radial-chart.ts +212 -0
  194. package/src/radial-chart/radial-rings.ts +328 -0
  195. package/src/radial-chart/shared.ts +126 -0
  196. package/dist/browser/chunks/chunk-4SMRCU24.js +0 -2
  197. package/dist/browser/chunks/chunk-4SMRCU24.js.map +0 -7
  198. package/dist/kit/chart-border.d.ts +0 -18
  199. package/dist/kit/chart-border.d.ts.map +0 -1
  200. package/dist/kit/chart-border.js +0 -23
  201. package/dist/kit/chart-border.js.map +0 -1
  202. package/dist/line-chart/horizontal-axis.d.ts.map +0 -1
  203. package/dist/line-chart/horizontal-axis.js.map +0 -1
  204. package/dist/pie-chart/pie-legend.d.ts.map +0 -1
  205. package/dist/pie-chart/pie-legend.js.map +0 -1
  206. package/src/kit/chart-border.ts +0 -36
  207. package/src/line-chart/horizontal-axis.ts +0 -122
@@ -0,0 +1,290 @@
1
+ import {
2
+ Rect,
3
+ Text,
4
+ Types,
5
+ component2d,
6
+ easeOut,
7
+ type ChildView,
8
+ type DataRecord,
9
+ type NodeOf,
10
+ // Named so this component's emitted type reaches `Node2D` and its props through the sdk, not core.
11
+ type Node2D,
12
+ type Node2DProps as _Node2DProps,
13
+ type TextStyle as _TextStyle,
14
+ type Fill as _Fill,
15
+ type FillResolved as _FillResolved,
16
+ type Stroke as _Stroke,
17
+ type StrokeResolved as _StrokeResolved,
18
+ type Size2D,
19
+ type Vector2,
20
+ } from "@motionscript/sdk"
21
+
22
+ import { boundText, laidOutSize } from "../kit/cartesian"
23
+ import { ChartLegend } from "../kit/chart-legend"
24
+ import { SERIES_ARG, asParts, fresh } from "../kit/entries"
25
+ import { ENTER_SHARE, LEGEND_SLIDE, beats, type AnyTimeline, type Beat } from "../kit/motion"
26
+ import { SPOTLIGHT_DIM, type ChartSeries } from "../kit/shared"
27
+ import {
28
+ CHART_LEGEND,
29
+ CHART_SERIES,
30
+ CHART_STYLE_FAMILY,
31
+ CHART_STYLE_PRESETS,
32
+ GRID_BACKGROUND,
33
+ legendConfigOf,
34
+ migrateChartStyle,
35
+ placedText,
36
+ seriesFill,
37
+ sizeOf,
38
+ } from "../kit/style"
39
+ import { RadarGrid } from "./radar-grid"
40
+ import { RadarSeries } from "./radar-series"
41
+ import {
42
+ RADAR_GRID,
43
+ RADAR_TEXT,
44
+ RADAR_MARKS,
45
+ categoriesOf,
46
+ fitRadius,
47
+ fractionOf,
48
+ labelAnchor,
49
+ labelPoint,
50
+ scaleOf,
51
+ valuesOf,
52
+ } from "./shared"
53
+
54
+ /** What a radar chart plots, as its entries hold it. */
55
+ export interface RadarValues {
56
+ readonly data: DataRecord[]
57
+ readonly categoryField: string
58
+ readonly series: ChartSeries[]
59
+ readonly min: number
60
+ readonly max: number
61
+ }
62
+
63
+ /** The scale the web is drawn to, whether `max` was given or left to the data. */
64
+ export function radarScale(state: RadarValues): { min: number; max: number } {
65
+ return scaleOf(valuesOf(state.data, state.series), state.min, state.max)
66
+ }
67
+
68
+ /** Series `index`'s values as fractions of the rim, in spoke order. */
69
+ export function radarFractions(state: RadarValues, index: number): number[] {
70
+ const series = state.series[index]
71
+ if (!series) return []
72
+ const scale = radarScale(state)
73
+ return state.data.map((row) => {
74
+ const v = row[series.field]
75
+ return fractionOf(typeof v === "number" && Number.isFinite(v) ? v : 0, scale)
76
+ })
77
+ }
78
+
79
+ /** The grid, series, label layer and legend among the children; the last row holding a grid is the one the chart built. */
80
+ function partsOf(children: readonly ChildView[]) {
81
+ for (let i = children.length - 1; i >= 0; i--) {
82
+ const row = children[i]
83
+ if (!Rect.is(row)) continue
84
+ const plot = row.children.find((part) => Rect.is(part) && part.children.some(RadarGrid.is))
85
+ if (!plot) continue
86
+ return {
87
+ grid: plot.children.find(RadarGrid.is)!,
88
+ series: plot.children.filter(RadarSeries.is),
89
+ labels: plot.children.find(Rect.is) ?? null,
90
+ legend: row.children.find(ChartLegend.is) ?? null,
91
+ }
92
+ }
93
+ return null
94
+ }
95
+
96
+ function toggling(timeline: AnyTimeline, children: readonly ChildView[], index: number, show: boolean) {
97
+ const parts = partsOf(children)
98
+ const series = parts?.series[index]
99
+ if (!parts || !series) return timeline.wait(timeline.duration)
100
+ return timeline.together([
101
+ series.run("enter", show ? { from: 0, to: 1 } : { from: 1, to: 0 }),
102
+ ...(parts.legend ? [parts.legend.run(show ? "showSeries" : "hideSeries", { index })] : []),
103
+ ])
104
+ }
105
+
106
+ /** The share of one series' growth that passes before the next starts. */
107
+ const STAGGER = 0.3
108
+
109
+ /**
110
+ * A radar (spider) chart: one spoke per data row, one closed polygon per
111
+ * series, over a web of rings. Composed of a {@link RadarGrid}, a
112
+ * {@link RadarSeries} per series, the category labels and a {@link ChartLegend}.
113
+ *
114
+ * Resting state is the finished chart; `enter` arms every part on its first
115
+ * frame. `max` of 0 (or one not above `min`) is the largest value rounded up.
116
+ */
117
+ export const RadarChart = component2d({
118
+ key: "radarChart",
119
+ state: {
120
+ data: fresh<DataRecord[]>(() => [], { kind: "dataset" }),
121
+ /** The column that names each spoke. */
122
+ categoryField: "",
123
+ series: fresh<ChartSeries[]>(() => [], { kind: "series" }),
124
+ /** The value at the centre of the web. */
125
+ min: 0,
126
+ /** The value at the rim; 0 rounds the largest value up. */
127
+ max: 0,
128
+ style: Types.style(
129
+ CHART_STYLE_FAMILY,
130
+ {
131
+ series: CHART_SERIES,
132
+ background: GRID_BACKGROUND,
133
+ text: RADAR_TEXT,
134
+ grid: RADAR_GRID,
135
+ marks: RADAR_MARKS,
136
+ legend: CHART_LEGEND,
137
+ },
138
+ { presets: CHART_STYLE_PRESETS }
139
+ ),
140
+ },
141
+ // A node made of parts that fill would otherwise hug them.
142
+ defaults: { width: "fill", height: "fill" },
143
+ migrate: { style: migrateChartStyle("radar") },
144
+ allowedChildren: [],
145
+ children: (state) => {
146
+ const style = () => state.style
147
+ let plot: Node2D | undefined
148
+ const plotSize = (): Size2D => laidOutSize(plot)
149
+ const radius = () => fitRadius(plotSize(), categoriesOf(state.data, state.categoryField), sizeOf(style().spokeText), style().spokeLabels)
150
+ const count = state.data.length
151
+ const enabled = state.series.map((s) => s.enabled ?? true)
152
+
153
+ const grid = new RadarGrid({
154
+ count: () => state.data.length,
155
+ radius,
156
+ levels: () => style().gridLevels,
157
+ fill: () => style().gridBackground,
158
+ shape: () => style().gridShape,
159
+ stroke: () => style().gridStroke,
160
+ spokeStroke: () => style().spokeStroke,
161
+ }) as unknown as Node2D
162
+
163
+ const series = state.series.map(
164
+ (s, i) =>
165
+ new RadarSeries({
166
+ fractions: () => radarFractions(state, i),
167
+ radius,
168
+ fill: () => seriesFill(style(), s, i),
169
+ stroke: () => {
170
+ const weight = style().strokeWidth
171
+ return weight > 0 ? { weight, fill: seriesFill(style(), s, i), align: "center", join: "round" } : []
172
+ },
173
+ areaOpacity: () => style().areaOpacity,
174
+ dots: () => style().dots,
175
+ dotSize: () => style().dotSize,
176
+ enabled: enabled[i],
177
+ }) as unknown as Node2D
178
+ )
179
+
180
+ const parts: Node2D[] = [grid, ...series]
181
+
182
+ if (state.style.spokeLabels) {
183
+ const labels: Node2D[] = []
184
+ for (let i = 0; i < count; i++) {
185
+ const anchor = labelAnchor(i, count)
186
+ const align = anchor === "centerLeft" ? "left" : anchor === "centerRight" ? "right" : "center"
187
+ const at = (): Vector2 => labelPoint(i, count, radius(), sizeOf(style().spokeText))
188
+ labels.push(
189
+ new Text({
190
+ ...boundText(() => placedText(style().spokeText, align)),
191
+ text: () => categoriesOf(state.data, state.categoryField)[i] ?? "",
192
+ [anchor]: at,
193
+ }) as unknown as Node2D
194
+ )
195
+ }
196
+ parts.push(new Rect({ width: "fill", height: "fill", children: labels }) as unknown as Node2D)
197
+ }
198
+
199
+ plot = new Rect({ width: "fill", height: "fill", children: parts }) as unknown as Node2D
200
+
201
+ const row: Node2D[] = [plot]
202
+ const position = state.style.legend
203
+ if (position !== "none") {
204
+ const side = position === "right"
205
+ const config = legendConfigOf(state.style)
206
+ const legend = new ChartLegend({
207
+ series: state.series,
208
+ swatches: () => state.series.map((s, i) => seriesFill(style(), s, i)),
209
+ legend: () => legendConfigOf(style()),
210
+ flow: side ? "vertical" : "horizontal",
211
+ // Spacing is fixed as built; a style tween re-paints the swatches and captions only.
212
+ gap: side ? Math.round(config.itemGap / 2) : config.itemGap,
213
+ padding: config.padding,
214
+ }) as unknown as Node2D
215
+ if (position === "top") row.unshift(legend)
216
+ else row.push(legend)
217
+ }
218
+
219
+ const frame = new Rect({
220
+ flow: position === "right" ? "horizontal" : "vertical",
221
+ width: "fill",
222
+ height: "fill",
223
+ gap: 24,
224
+ align: "center",
225
+ fill: () => style().background,
226
+ children: row,
227
+ })
228
+ return asParts([frame as unknown as Node2D])
229
+ },
230
+ commands: {
231
+ /** The web grows out, the polygons grow from the centre one series after another, then the labels and legend come up. */
232
+ enter: {
233
+ args: {},
234
+ // Held from arrival, so it never shows finished ahead of its draw.
235
+ entrance: true,
236
+ easing: easeOut(),
237
+ run: (timeline, _state, children) => {
238
+ const parts = partsOf(children)
239
+ if (!parts) return timeline.wait(timeline.duration)
240
+ const duration = timeline.duration
241
+ const web = duration * ENTER_SHARE.axes
242
+ const marks = duration * ENTER_SHARE.marks
243
+ const last = duration * ENTER_SHARE.legend
244
+ const n = Math.max(1, parts.series.length)
245
+ const each = marks / (1 + STAGGER * (n - 1))
246
+ const { legend, labels } = parts
247
+ const at0 = (piece: Beat["piece"]): Beat => ({ at: 0, duration: 0, piece })
248
+ // The first frame arms what every later beat moves: a beat ahead of the playhead writes nothing.
249
+ return beats(timeline, [
250
+ at0(parts.grid.to({ reveal: 0 })),
251
+ ...parts.series.map((s) => at0(s.to({ progress: 0 }))),
252
+ ...(labels ? [at0(labels.to({ opacity: 0 }))] : []),
253
+ ...(legend ? [at0(legend.to({ opacity: 0, y: -LEGEND_SLIDE }))] : []),
254
+ { at: 0, duration: web, piece: parts.grid.to({ reveal: 1 }) },
255
+ ...parts.series.map((s, i) => ({ at: web + i * each * STAGGER, duration: each, piece: s.to({ progress: 1 }) })),
256
+ ...(labels ? [{ at: web + marks * 0.6, duration: last + marks * 0.4, piece: labels.to({ opacity: 1 }) }] : []),
257
+ ...(legend ? [{ at: web + marks, duration: last, piece: legend.to({ opacity: 1, y: 0 }) }] : []),
258
+ ])
259
+ },
260
+ },
261
+ /** Grow series `index`'s polygon back out of the centre, with its legend entry growing in. */
262
+ showSeries: {
263
+ args: { seriesIndex: SERIES_ARG },
264
+ easing: easeOut(),
265
+ run: (timeline, _state, children) => toggling(timeline, children, timeline.args.seriesIndex, true),
266
+ },
267
+ /** Shrink series `index`'s polygon into the centre as its legend entry shrinks away. */
268
+ hideSeries: {
269
+ args: { seriesIndex: SERIES_ARG },
270
+ easing: easeOut(),
271
+ run: (timeline, _state, children) => toggling(timeline, children, timeline.args.seriesIndex, false),
272
+ },
273
+ /** Fade every other series, polygon and legend entry, to focus on `index`. The legend dims in place, so the row does not reflow. */
274
+ spotlightSeries: {
275
+ args: { seriesIndex: SERIES_ARG },
276
+ easing: easeOut(),
277
+ run: (timeline, _state, children) => {
278
+ const parts = partsOf(children)
279
+ if (!parts) return timeline.wait(timeline.duration)
280
+ const index = timeline.args.seriesIndex
281
+ return timeline.together([
282
+ ...parts.series.map((s, i) => s.to({ opacity: i === index ? 1 : SPOTLIGHT_DIM })),
283
+ ...(parts.legend ? [parts.legend.run("spotlightSeries", { index })] : []),
284
+ ])
285
+ },
286
+ },
287
+ },
288
+ })
289
+
290
+ export type RadarChart = NodeOf<typeof RadarChart>
@@ -0,0 +1,83 @@
1
+ import {
2
+ Graphics2D,
3
+ Types,
4
+ clamp,
5
+ component2d,
6
+ fillOps,
7
+ strokeOps,
8
+ type Fill,
9
+ type FillResolved,
10
+ type NodeOf,
11
+ // Named so this component's emitted type reaches `Node2D` and its props through the sdk, not core.
12
+ type Node2D as _Node2D,
13
+ type Node2DProps as _Node2DProps,
14
+ type Stroke,
15
+ type StrokeResolved,
16
+ type Vector2,
17
+ } from "@motionscript/sdk"
18
+
19
+ import { loadPaints } from "../kit/paints"
20
+ import { spokeDirection, type GridShape } from "./shared"
21
+
22
+ /**
23
+ * The web of a {@link RadarChart}: `levels` rings and, if asked, a spoke to each
24
+ * of `count` categories, drawn from the node's centre out to `radius`. `reveal`
25
+ * grows the web from nothing.
26
+ */
27
+ export const RadarGrid = component2d({
28
+ key: "radarGrid",
29
+ state: {
30
+ count: 0,
31
+ /** The rim, in px. */
32
+ radius: 0,
33
+ levels: 4,
34
+ /** Inside the outer ring, under the rings and spokes; empty paints nothing. */
35
+ fill: Types.custom<Fill, FillResolved[]>([], { resolve: fillOps.resolve, lerp: fillOps.lerp }),
36
+ shape: Types.custom<GridShape>("polygon"),
37
+ stroke: Types.custom<Stroke, StrokeResolved[]>([], { resolve: strokeOps.resolve, lerp: strokeOps.lerp }),
38
+ /** The line out to each category; empty draws none. */
39
+ spokeStroke: Types.custom<Stroke, StrokeResolved[]>([], { resolve: strokeOps.resolve, lerp: strokeOps.lerp }),
40
+ /** How far the web has grown out, `0`-`1`. Rests at 1 so a paused canvas paints it. */
41
+ reveal: 1,
42
+ },
43
+ defaults: { width: "fill", height: "fill" },
44
+ load: (loader, state) => loadPaints(loader, { fills: [state.fill], strokes: [state.stroke, state.spokeStroke] }),
45
+ render: (ctx, state) => {
46
+ const grow = clamp(state.reveal, 0, 1)
47
+ const rim = state.radius * grow
48
+ if (rim <= 0 || grow <= 0) return
49
+ const count = Math.max(0, Math.round(state.count))
50
+ const levels = Math.max(1, Math.round(state.levels))
51
+ const round = state.shape === "circle" || count < 3
52
+ const outline = (g: Graphics2D, r: number): Graphics2D => {
53
+ if (round) return g.ellipse({ width: r * 2, height: r * 2 })
54
+ const points: Vector2[] = []
55
+ for (let i = 0; i < count; i++) {
56
+ const d = spokeDirection(i, count)
57
+ points.push({ x: d.x * r, y: d.y * r })
58
+ }
59
+ return g.line({ points, closed: true })
60
+ }
61
+
62
+ if (state.fill.length > 0) ctx.draw(outline(new Graphics2D(), rim).fill(state.fill).opacity(grow))
63
+
64
+ // One draw per ring: rings chained into one surface would stroke only their union's outline.
65
+ for (let level = 1; state.stroke.length > 0 && level <= levels; level++) {
66
+ const r = (rim * level) / levels
67
+ ctx.draw(outline(new Graphics2D(), r).stroke(state.stroke).opacity(grow))
68
+ }
69
+
70
+ if (state.spokeStroke.length === 0) return
71
+ for (let i = 0; i < count; i++) {
72
+ const d = spokeDirection(i, count)
73
+ ctx.draw(
74
+ new Graphics2D()
75
+ .line({ points: [{ x: 0, y: 0 }, { x: d.x * rim, y: d.y * rim }] })
76
+ .stroke(state.spokeStroke)
77
+ .opacity(grow)
78
+ )
79
+ }
80
+ },
81
+ })
82
+
83
+ export type RadarGrid = NodeOf<typeof RadarGrid>
@@ -0,0 +1,88 @@
1
+ import {
2
+ Graphics2D,
3
+ Types,
4
+ clamp,
5
+ component2d,
6
+ fillOps,
7
+ strokeOps,
8
+ type Fill,
9
+ type FillResolved,
10
+ type NodeOf,
11
+ // Named so this component's emitted type reaches `Node2D` and its props through the sdk, not core.
12
+ type Node2D as _Node2D,
13
+ type Node2DProps as _Node2DProps,
14
+ type Stroke,
15
+ type StrokeResolved,
16
+ type Vector2,
17
+ } from "@motionscript/sdk"
18
+
19
+ import { fresh } from "../kit/entries"
20
+ import { frameEase } from "../kit/motion"
21
+ import { loadPaints } from "../kit/paints"
22
+ import { spokeDirection } from "./shared"
23
+
24
+ /** The vertices of a series' polygon: spoke `i` at `fractions[i]` of the rim, scaled by `grow` about the centre. */
25
+ export function seriesVertices(fractions: readonly number[], radius: number, grow = 1): Vector2[] {
26
+ return fractions.map((f, i) => {
27
+ const d = spokeDirection(i, fractions.length)
28
+ const r = radius * f * grow
29
+ return { x: d.x * r, y: d.y * r }
30
+ })
31
+ }
32
+
33
+ /** One series of a {@link RadarChart}: a closed polygon through its values, washed in its colour, with a dot on each vertex. `progress` grows it out of the centre. */
34
+ export const RadarSeries = component2d({
35
+ key: "radarSeries",
36
+ state: {
37
+ /** Each spoke's value as a fraction of the rim, `0`-`1`, in spoke order. */
38
+ fractions: fresh<number[]>(() => []),
39
+ /** The rim, in px. */
40
+ radius: 0,
41
+ fill: Types.custom<Fill, FillResolved[]>([], { resolve: fillOps.resolve, lerp: fillOps.lerp }),
42
+ stroke: Types.custom<Stroke, StrokeResolved[]>([], { resolve: strokeOps.resolve, lerp: strokeOps.lerp }),
43
+ areaOpacity: 0.25,
44
+ dots: true,
45
+ dotSize: 10,
46
+ /** Growth out of the centre, `0`-`1`. Rests at 1 so a paused canvas paints it; `enter` drives it from 0. */
47
+ progress: 1,
48
+ /** Whether the series is drawn at all, flipped alongside its legend entry. */
49
+ enabled: true,
50
+ },
51
+ defaults: { width: "fill", height: "fill" },
52
+ load: (loader, state) => loadPaints(loader, { fills: [state.fill], strokes: [state.stroke] }),
53
+ commands: {
54
+ /** Grow the polygon from `from` to `to` (of its full size); `showSeries`/`hideSeries` are this with the ends stated. */
55
+ enter: {
56
+ args: { from: Types.number(0, { min: 0, max: 1 }), to: Types.number(1, { min: 0, max: 1 }) },
57
+ run: (timeline) => {
58
+ const { from, to } = timeline.args
59
+ const ease = frameEase(timeline)
60
+ return timeline.animate((t) => {
61
+ const progress = from + (to - from) * ease(t)
62
+ // Hidden only once fully shrunk, so a hide does not vanish a frame early.
63
+ return { progress, enabled: progress > 0 }
64
+ })
65
+ },
66
+ },
67
+ },
68
+ render: (ctx, state) => {
69
+ if (!state.enabled) return
70
+ const grow = clamp(state.progress, 0, 1)
71
+ if (grow <= 0 || state.radius <= 0 || state.fractions.length === 0) return
72
+ const points = seriesVertices(state.fractions, state.radius, grow)
73
+
74
+ if (points.length >= 2) {
75
+ const fill = new Graphics2D().line({ points, closed: true })
76
+ if (state.areaOpacity > 0 && state.fill.length > 0) ctx.draw(fill.fill(state.fill).opacity(clamp(state.areaOpacity, 0, 1)))
77
+ if (state.stroke.length > 0) ctx.draw(new Graphics2D().line({ points, closed: true }).stroke(state.stroke))
78
+ }
79
+
80
+ if (state.dots && state.dotSize > 0 && state.fill.length > 0) {
81
+ const dots = new Graphics2D()
82
+ for (const p of points) dots.ellipse({ x: p.x, y: p.y, width: state.dotSize, height: state.dotSize })
83
+ ctx.draw(dots.fill(state.fill))
84
+ }
85
+ },
86
+ })
87
+
88
+ export type RadarSeries = NodeOf<typeof RadarSeries>
@@ -0,0 +1,153 @@
1
+ import { Types, type TextStyle, type DataRecord, type Size2D, type StrokeResolved, type Stroke as _Stroke, type Vector2 } from "@motionscript/sdk"
2
+
3
+ import { num, text, type ChartSeries } from "../kit/shared"
4
+ import { CHART_MUTED, LEGEND_TEXT } from "../kit/style"
5
+
6
+ /** The radar chart's text: the category names around the rim, and the legend. */
7
+ export const RADAR_TEXT = {
8
+ spokeText: Types.typography({ fontSize: 28, fontWeight: 500, fill: CHART_MUTED }),
9
+ ...LEGEND_TEXT,
10
+ }
11
+
12
+ export type GridShape = "polygon" | "circle"
13
+
14
+ /** The radar chart's own grid group: the web the polygons are drawn over. */
15
+ export const RADAR_GRID = {
16
+ /** Whether the rings are straight-edged (a polygon through the spokes) or round. */
17
+ gridShape: Types.oneOf(["polygon", "circle"] as const),
18
+ /** How many rings, outermost at the scale's maximum. */
19
+ gridLevels: Types.number(4, { min: 1, max: 10, step: 1 }),
20
+ /** The rings; empty draws none. */
21
+ gridStroke: Types.stroke({ weight: 2, fill: "white/15" }),
22
+ /** The line from the centre out to each category; empty draws none. */
23
+ spokeStroke: Types.stroke({ weight: 2, fill: "white/15" }),
24
+ /** Read as the chart is built: whether the category names stand around the rim. */
25
+ spokeLabels: Types.boolean(true, { animatable: false }),
26
+ }
27
+
28
+ export const RADAR_MARKS = {
29
+ /** Outline weight of each series' polygon. */
30
+ strokeWidth: Types.number(4, { min: 0, max: 24, step: 1, unit: "px" }),
31
+ /** How strongly each polygon is filled in its series colour; 0 leaves an outline. */
32
+ areaOpacity: Types.number(0.25, { min: 0, max: 1, step: 0.05 }),
33
+ /** Whether a dot marks each value on its spoke. */
34
+ dots: Types.boolean(true),
35
+ dotSize: Types.number(10, { min: 2, max: 40, step: 1, unit: "px" }),
36
+ }
37
+
38
+ /** The grid and marks fields the parts read, whichever way the chart's style is spelled. */
39
+ export interface RadarStyle {
40
+ readonly gridShape: GridShape
41
+ readonly gridLevels: number
42
+ readonly gridStroke: StrokeResolved[]
43
+ readonly spokeStroke: StrokeResolved[]
44
+ readonly spokeLabels: boolean
45
+ readonly strokeWidth: number
46
+ readonly areaOpacity: number
47
+ readonly dots: boolean
48
+ readonly dotSize: number
49
+ readonly spokeText: TextStyle
50
+ }
51
+
52
+ /** Fraction of the label's font size between the rim and the label. */
53
+ const LABEL_PAD = 0.55
54
+ /** Glyph width as a fraction of the font size, for fitting labels. */
55
+ const GLYPH = 0.58
56
+ /** Room kept clear around the web when nothing else claims it, in px. */
57
+ const MARGIN = 10
58
+ /** Below this a spoke counts as vertical, and its label centres over it. */
59
+ const SIDE = 0.35
60
+
61
+ /** Spoke `index` of `count`, as a unit direction: 12 o'clock first, clockwise on screen, y up. */
62
+ export function spokeDirection(index: number, count: number): Vector2 {
63
+ const angle = (index / Math.max(1, count)) * Math.PI * 2
64
+ return { x: Math.sin(angle), y: Math.cos(angle) }
65
+ }
66
+
67
+ /** One category per data row. */
68
+ export function categoriesOf(data: readonly DataRecord[], field: string): string[] {
69
+ return data.map((row) => text(row, field))
70
+ }
71
+
72
+ /** One list of values per series, a value per row. */
73
+ export function valuesOf(data: readonly DataRecord[], series: readonly ChartSeries[]): number[][] {
74
+ return series.map((s) => data.map((row) => num(row, s.field)))
75
+ }
76
+
77
+ const MANTISSAS = [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]
78
+
79
+ /** `value` rounded up to a tidy 1, 1.5, 2, 2.5, 3, 4, 5, 6, 8 times a power of ten. */
80
+ export function niceCeil(value: number): number {
81
+ if (!(value > 0)) return 1
82
+ const power = 10 ** Math.floor(Math.log10(value))
83
+ const mantissa = value / power
84
+ const pick = MANTISSAS.find((m) => m >= mantissa - 1e-9) ?? 10
85
+ return pick * power
86
+ }
87
+
88
+ /**
89
+ * The scale the web is drawn to: `min` (default 0) to `max`, which when unset
90
+ * (or not above `min`) is the largest value rounded up tidily.
91
+ */
92
+ export function scaleOf(values: readonly (readonly number[])[], min: number, max: number): { min: number; max: number } {
93
+ if (max > min) return { min, max }
94
+ let top = min
95
+ for (const list of values) for (const v of list) top = Math.max(top, v)
96
+ const span = niceCeil(top - min)
97
+ return { min, max: min + span }
98
+ }
99
+
100
+ /** Where `value` stands between the scale's ends, `0`-`1`. */
101
+ export function fractionOf(value: number, scale: { min: number; max: number }): number {
102
+ const span = scale.max - scale.min
103
+ if (span <= 0) return 0
104
+ return Math.min(1, Math.max(0, (value - scale.min) / span))
105
+ }
106
+
107
+ export type LabelAnchor = "centerLeft" | "centerRight" | "bottomCenter" | "topCenter"
108
+
109
+ /** Which corner of a category's label touches the point beside its spoke's end. */
110
+ export function labelAnchor(index: number, count: number): LabelAnchor {
111
+ const { x, y } = spokeDirection(index, count)
112
+ if (x > SIDE) return "centerLeft"
113
+ if (x < -SIDE) return "centerRight"
114
+ return y >= 0 ? "bottomCenter" : "topCenter"
115
+ }
116
+
117
+ export function labelPad(fontSize: number): number {
118
+ return fontSize * LABEL_PAD
119
+ }
120
+
121
+ /** Rough size of a label, since the web is fitted before any text is measured. */
122
+ export function labelSize(caption: string, fontSize: number): Size2D {
123
+ return { width: caption.length * fontSize * GLYPH, height: fontSize * 1.25 }
124
+ }
125
+
126
+ /**
127
+ * The rim's radius for a box: the largest the web can be with every label
128
+ * (when there are labels) still inside it.
129
+ */
130
+ export function fitRadius(box: Size2D, categories: readonly string[], fontSize: number, labelled: boolean): number {
131
+ let radius = Math.min(box.width, box.height) / 2 - MARGIN
132
+ if (!labelled) return Math.max(0, radius)
133
+ const pad = labelPad(fontSize)
134
+ const count = categories.length
135
+ categories.forEach((caption, i) => {
136
+ const { x, y } = spokeDirection(i, count)
137
+ const size = labelSize(caption, fontSize)
138
+ const side = labelAnchor(i, count)
139
+ // How much of the label hangs past its anchor point on each axis.
140
+ const reachX = side === "centerLeft" || side === "centerRight" ? size.width : size.width / 2
141
+ const reachY = side === "bottomCenter" || side === "topCenter" ? size.height : size.height / 2
142
+ if (Math.abs(x) > 1e-6) radius = Math.min(radius, (box.width / 2 - reachX - MARGIN / 2) / Math.abs(x) - pad)
143
+ if (Math.abs(y) > 1e-6) radius = Math.min(radius, (box.height / 2 - reachY - MARGIN / 2) / Math.abs(y) - pad)
144
+ })
145
+ return Math.max(0, radius)
146
+ }
147
+
148
+ /** Where spoke `index`'s label is pinned: just past the rim, centre-relative, y up. */
149
+ export function labelPoint(index: number, count: number, radius: number, fontSize: number): Vector2 {
150
+ const { x, y } = spokeDirection(index, count)
151
+ const r = radius + labelPad(fontSize)
152
+ return { x: x * r, y: y * r }
153
+ }
@@ -0,0 +1,5 @@
1
+ export { RadialChart } from "./radial-chart"
2
+ export type { RadialChartValues } from "./radial-chart"
3
+ export { RadialRings, RadialGeometry, bandPath } from "./radial-rings"
4
+ export { RADIAL_STYLE_GROUPS, radialFractions, radialMax, resolveRadialRows, rowValue } from "./shared"
5
+ export type { RadialMarks, RadialRow, RadialStyle } from "./shared"