@motionscript/charts 0.1.0 → 0.1.1-alpha.1

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