@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
@@ -1,41 +1,6 @@
1
- import {
2
- Fills,
3
- fillOps,
4
- lerpNumber,
5
- strokeOps,
6
- type DataRecord,
7
- type Fill,
8
- type FillResolved,
9
- type Stroke,
10
- type StrokeResolved,
11
- type Vector2,
12
- } from "@motionscript/sdk"
13
- import { niceStep } from "@motionscript/sdk/component"
14
-
15
- import {
16
- CHART_PALETTE,
17
- DEFAULT_LEGEND,
18
- DEFAULT_PLOT_AREA,
19
- DEFAULT_SERIES_STROKES,
20
- DEFAULT_X_AXIS,
21
- DEFAULT_Y_AXIS,
22
- SPOTLIGHT_DIM,
23
- lerpAxisConfig,
24
- lerpLegendConfig,
25
- lerpPlotArea,
26
- lerpSeriesStrokes,
27
- num,
28
- resolveAxisConfig,
29
- resolveLegendConfig,
30
- resolvePlotArea,
31
- resolveSeriesStrokes,
32
- type AxisConfig,
33
- type ChartSeries,
34
- type LegendConfig,
35
- type LegendTheme,
36
- type PlotAreaConfig,
37
- type Range,
38
- } from "../kit/shared"
1
+ import { Types, type DataRecord, type Vector2 } from "@motionscript/sdk"
2
+
3
+ import { niceRange, num, type ChartSeries, type Range } from "../kit/shared"
39
4
  import type { XScale } from "./x-scale"
40
5
 
41
6
  /** One polyline of a {@link LineChart}: {@link ChartSeries} under the name for what it draws here. */
@@ -198,129 +163,6 @@ function clipSegment(
198
163
  ]
199
164
  }
200
165
 
201
- /** How a spotlit {@link PlotRegion} is drawn: rules on its bounded edges, a wash, and how far series fade outside it. */
202
- export interface RegionConfig {
203
- /** Marks each bounded edge. */
204
- stroke: Stroke
205
- /** Drawn under the series lines. */
206
- fill: Fill
207
- /** Opacity the stretch of every series outside the region fades to. */
208
- dimOpacity: number
209
- }
210
-
211
- /** A {@link LineChart}'s full, resolved visual theme. */
212
- export interface ChartConfig {
213
- /** Cycled by series index; a series' own `fill` overrides just the colour. */
214
- seriesStyles: Stroke[]
215
- xAxis: AxisConfig
216
- yAxis: AxisConfig
217
- plotArea: PlotAreaConfig
218
- legend: LegendConfig
219
- region: RegionConfig
220
- }
221
-
222
- /** The author's partial theme, deep-merged onto {@link DEFAULT_CHART_THEME}. */
223
- export type ChartTheme = {
224
- seriesStyles?: Stroke[]
225
- xAxis?: Partial<AxisConfig>
226
- yAxis?: Partial<AxisConfig>
227
- plotArea?: Partial<PlotAreaConfig>
228
- legend?: LegendTheme
229
- region?: Partial<RegionConfig>
230
- }
231
-
232
- /** The shared legend with a small square swatch: a line's swatch stands for a colour, where a bar's or wedge's stands for an area of paint. */
233
- const LINE_CHART_LEGEND: LegendConfig = {
234
- ...DEFAULT_LEGEND,
235
- markerStyle: {
236
- ...DEFAULT_LEGEND.markerStyle,
237
- width: 20,
238
- height: 20,
239
- borderRadius: 6,
240
- },
241
- }
242
-
243
- export const DEFAULT_CHART_THEME: ChartConfig = {
244
- seriesStyles: DEFAULT_SERIES_STROKES,
245
- xAxis: DEFAULT_X_AXIS,
246
- yAxis: DEFAULT_Y_AXIS,
247
- plotArea: DEFAULT_PLOT_AREA,
248
- legend: LINE_CHART_LEGEND,
249
- region: {
250
- // Brighter and dashed so it reads as a call-out, not an axis line; `butt` caps keep dash lengths exact.
251
- stroke: {
252
- weight: 3,
253
- fill: CHART_PALETTE.regionColor,
254
- dash: [14, 12],
255
- cap: "butt",
256
- },
257
- fill: Fills.color(CHART_PALETTE.regionColor, { opacity: 0.08 }),
258
- dimOpacity: SPOTLIGHT_DIM,
259
- },
260
- }
261
-
262
- function resolveRegionConfig(
263
- region: Partial<RegionConfig> | undefined,
264
- previous: RegionConfig
265
- ): RegionConfig {
266
- return {
267
- stroke: strokeOps.resolve(region?.stroke ?? previous.stroke),
268
- fill: fillOps.resolve(region?.fill ?? previous.fill),
269
- dimOpacity: region?.dimOpacity ?? previous.dimOpacity,
270
- }
271
- }
272
-
273
- function lerpRegionConfig(
274
- from: RegionConfig,
275
- to: RegionConfig,
276
- t: number
277
- ): RegionConfig {
278
- return {
279
- stroke: strokeOps.lerp(
280
- from.stroke as StrokeResolved[],
281
- to.stroke as StrokeResolved[],
282
- t
283
- ),
284
- fill: fillOps.lerp(
285
- from.fill as FillResolved[],
286
- to.fill as FillResolved[],
287
- t
288
- ),
289
- dimOpacity: lerpNumber(from.dimOpacity, to.dimOpacity, t),
290
- }
291
- }
292
-
293
- /** The `theme` entry's resolve: merges a partial {@link ChartTheme} onto `previous` (first resolution: the default). */
294
- export function resolveChartTheme(
295
- theme: ChartTheme | undefined,
296
- previous?: ChartConfig
297
- ): ChartConfig {
298
- const base = previous ?? DEFAULT_CHART_THEME
299
- return {
300
- seriesStyles: resolveSeriesStrokes(theme?.seriesStyles, base.seriesStyles),
301
- xAxis: resolveAxisConfig(theme?.xAxis, base.xAxis),
302
- yAxis: resolveAxisConfig(theme?.yAxis, base.yAxis),
303
- plotArea: resolvePlotArea(theme?.plotArea, base.plotArea),
304
- legend: resolveLegendConfig(theme?.legend, base.legend),
305
- region: resolveRegionConfig(theme?.region, base.region),
306
- }
307
- }
308
-
309
- export function lerpChartTheme(
310
- from: ChartConfig,
311
- to: ChartConfig,
312
- t: number
313
- ): ChartConfig {
314
- return {
315
- seriesStyles: lerpSeriesStrokes(from.seriesStyles, to.seriesStyles, t),
316
- xAxis: lerpAxisConfig(from.xAxis, to.xAxis, t),
317
- yAxis: lerpAxisConfig(from.yAxis, to.yAxis, t),
318
- plotArea: lerpPlotArea(from.plotArea, to.plotArea, t),
319
- legend: lerpLegendConfig(from.legend, to.legend, t),
320
- region: lerpRegionConfig(from.region, to.region, t),
321
- }
322
- }
323
-
324
166
  /**
325
167
  * One series' `[x, y]` points, sorted along x so the polyline traces left to
326
168
  * right. A row missing its y contributes 0 rather than a gap, since dropping it
@@ -359,21 +201,198 @@ export function resolveYScale(
359
201
  range: Range | undefined,
360
202
  divisions: number
361
203
  ): Range {
362
- if (range) return range
363
- const [lo, hi] = yExtent(data, series)
364
- const segments = Math.max(1, Math.round(divisions))
365
- if (hi === lo) return constantScale(lo, segments)
366
- const step = niceStep((hi - lo) / segments)
367
- return [Math.floor(lo / step) * step, Math.ceil(hi / step) * step]
204
+ return range ?? niceRange(yExtent(data, series), divisions)
205
+ }
206
+
207
+ /** How the series sit on one another: apart, each on the sum of those before it, or as shares of the row's total. */
208
+ export type LineStack = "none" | "stacked" | "percent"
209
+
210
+ /** One series as drawn: its `[x, y]` points along x, and the line its area fills down to (`null`: the baseline). */
211
+ export interface StackedSeries {
212
+ readonly points: [x: number, y: number][]
213
+ readonly base: [x: number, y: number][] | null
214
+ }
215
+
216
+ /**
217
+ * Every series' points under `stack`, rows in x order. A series contributes its
218
+ * values times its weight (`1` when missing), so a series sinking out of a stack
219
+ * lowers the ones above it. `percent` reads each row as shares of its total
220
+ * magnitude, so the top of a full stack is 100.
221
+ */
222
+ export function stackSeries(
223
+ data: DataRecord[],
224
+ x: XScale,
225
+ series: readonly LineSeries[],
226
+ stack: LineStack,
227
+ weights: readonly number[] = []
228
+ ): StackedSeries[] {
229
+ const order = data.map((_, i) => i).sort((a, b) => x.at(a) - x.at(b))
230
+ if (stack !== "stacked" && stack !== "percent") {
231
+ return series.map((s) => ({ points: order.map((r): [number, number] => [x.at(r), num(data[r], s.field)]), base: null }))
232
+ }
233
+ const weight = (i: number) => Math.max(0, weights[i] ?? 1)
234
+ const totals = order.map((r) => series.reduce((sum, s, i) => sum + Math.abs(num(data[r], s.field) * weight(i)), 0))
235
+ const running = order.map(() => 0)
236
+ return series.map((s, i) => {
237
+ const base = order.map((r, k): [number, number] => [x.at(r), running[k]])
238
+ const points = order.map((r, k): [number, number] => {
239
+ const value = num(data[r], s.field) * weight(i)
240
+ running[k] += stack === "percent" ? (totals[k] > 0 ? (value / totals[k]) * 100 : 0) : value
241
+ return [x.at(r), running[k]]
242
+ })
243
+ return { points, base }
244
+ })
245
+ }
246
+
247
+ /** The lowest and highest y anything in `stacks` reaches, its bases included. */
248
+ export function stackExtent(stacks: readonly StackedSeries[]): Range {
249
+ let lo = Infinity
250
+ let hi = -Infinity
251
+ for (const { points, base } of stacks)
252
+ for (const [, y] of [...points, ...(base ?? [])]) {
253
+ if (y < lo) lo = y
254
+ if (y > hi) hi = y
255
+ }
256
+ return lo <= hi ? [lo, hi] : [0, 1]
368
257
  }
369
258
 
259
+ /** How a series' line runs between its points. */
260
+ export type LineCurve = "smooth" | "linear" | "step"
261
+
262
+ /** Points along a cubic Bezier segment, its start left out. */
263
+ const SAMPLES = 16
264
+
370
265
  /**
371
- * The scale for constant data. A zero-width scale would put every tick on one
372
- * value and the line on the floor, so it widens by a step sized to the
373
- * magnitude (`5` gets `[3, 7]`), or `[0, 1]` for zero.
266
+ * The drawn path through `points` (ordered along x) as a polyline: the points
267
+ * themselves for `linear`; for `step`, level runs that change at the midpoint
268
+ * between neighbours; for `smooth`, a monotone cubic (Fritsch-Carlson, as d3's
269
+ * `curveMonotoneX`), which never overshoots the data between two points.
374
270
  */
375
- function constantScale(value: number, segments: number): Range {
376
- if (value === 0) return [0, 1]
377
- const step = niceStep(Math.abs(value) / segments)
378
- return [value - step, value + step]
271
+ export function curvePoints(points: readonly Vector2[], curve: LineCurve): Vector2[] {
272
+ if (curve === "step") {
273
+ const out: Vector2[] = points.length > 0 ? [points[0]] : []
274
+ for (let i = 1; i < points.length; i++) {
275
+ const mid = (points[i - 1].x + points[i].x) / 2
276
+ out.push({ x: mid, y: points[i - 1].y }, { x: mid, y: points[i].y }, points[i])
277
+ }
278
+ return out
279
+ }
280
+ if (curve !== "smooth" || points.length < 3) return [...points]
281
+ const tangents = monotoneTangents(points)
282
+ const out: Vector2[] = [points[0]]
283
+ for (let i = 1; i < points.length; i++) {
284
+ const a = points[i - 1]
285
+ const b = points[i]
286
+ const dx = (b.x - a.x) / 3
287
+ const c1 = { x: a.x + dx, y: a.y + dx * tangents[i - 1] }
288
+ const c2 = { x: b.x - dx, y: b.y - dx * tangents[i] }
289
+ for (let k = 1; k <= SAMPLES; k++) {
290
+ const t = k / SAMPLES
291
+ const u = 1 - t
292
+ out.push({
293
+ x: u * u * u * a.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * b.x,
294
+ y: u * u * u * a.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * b.y,
295
+ })
296
+ }
297
+ }
298
+ return out
299
+ }
300
+
301
+ function monotoneTangents(points: readonly Vector2[]): number[] {
302
+ const n = points.length
303
+ const slope = (i: number) => {
304
+ const h = points[i + 1].x - points[i].x
305
+ return h === 0 ? 0 : (points[i + 1].y - points[i].y) / h
306
+ }
307
+ const m = new Array<number>(n).fill(0)
308
+ for (let i = 1; i < n - 1; i++) {
309
+ const h0 = points[i].x - points[i - 1].x
310
+ const h1 = points[i + 1].x - points[i].x
311
+ const s0 = slope(i - 1)
312
+ const s1 = slope(i)
313
+ const p = h0 + h1 === 0 ? 0 : (s0 * h1 + s1 * h0) / (h0 + h1)
314
+ m[i] = (Math.sign(s0) + Math.sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), 0.5 * Math.abs(p)) || 0
315
+ }
316
+ m[0] = endTangent(slope(0), m[1])
317
+ m[n - 1] = endTangent(slope(n - 2), m[n - 2])
318
+ return m
319
+ }
320
+
321
+ /** d3's one-sided end tangent, held to the end segment's own direction and to three times its slope, past which it would overshoot. */
322
+ function endTangent(secant: number, inner: number): number {
323
+ const t = (3 * secant - inner) / 2
324
+ if (secant === 0 || Math.sign(t) !== Math.sign(secant)) return 0
325
+ return Math.abs(t) > 3 * Math.abs(secant) ? 3 * secant : t
326
+ }
327
+
328
+ /** The part of `points` (ordered along x) left of `x`, ending on the point where it crosses `x`. */
329
+ export function cutAt(points: readonly Vector2[], x: number): Vector2[] {
330
+ if (points.length === 0 || x < points[0].x) return []
331
+ const out: Vector2[] = []
332
+ for (let i = 0; i < points.length; i++) {
333
+ const p = points[i]
334
+ if (p.x <= x) {
335
+ out.push(p)
336
+ continue
337
+ }
338
+ const a = points[i - 1]
339
+ if (a && p.x > a.x) out.push({ x, y: a.y + ((p.y - a.y) * (x - a.x)) / (p.x - a.x) })
340
+ break
341
+ }
342
+ return out
343
+ }
344
+
345
+ /**
346
+ * `polygon` clipped to `box` (Sutherland-Hodgman against each edge in turn).
347
+ * The box is convex, so the result is the part inside, possibly with seams of
348
+ * no width where a concave outline leaves and re-enters, which fill as nothing.
349
+ */
350
+ export function clipPolygonToBox(polygon: readonly Vector2[], box: RegionBox): Vector2[] {
351
+ if (polygon.length < 3 || !hasRegion(box)) return []
352
+ const edges: ((p: Vector2) => number)[] = [
353
+ (p) => p.x - box.left,
354
+ (p) => box.right - p.x,
355
+ (p) => p.y - box.bottom,
356
+ (p) => box.top - p.y,
357
+ ]
358
+ let out: Vector2[] = [...polygon]
359
+ for (const inside of edges) {
360
+ const input = out
361
+ out = []
362
+ for (let i = 0; i < input.length; i++) {
363
+ const cur = input[i]
364
+ const prev = input[(i + input.length - 1) % input.length]
365
+ const dc = inside(cur)
366
+ const dp = inside(prev)
367
+ if (dc >= 0) {
368
+ if (dp < 0) out.push(crossing(prev, cur, dp, dc))
369
+ out.push(cur)
370
+ } else if (dp >= 0) out.push(crossing(prev, cur, dp, dc))
371
+ }
372
+ if (out.length === 0) return []
373
+ }
374
+ return out
375
+ }
376
+
377
+ function crossing(a: Vector2, b: Vector2, da: number, db: number): Vector2 {
378
+ const t = da / (da - db)
379
+ return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }
380
+ }
381
+
382
+ /** A line chart's own look: how each series is stroked, dotted and filled. */
383
+ export const LINE_MARKS = {
384
+ strokeWidth: Types.number(4, { min: 0, max: 24, step: 0.5, unit: "px" }),
385
+ curve: Types.oneOf(["smooth", "linear", "step"] as const),
386
+ dots: Types.boolean(false),
387
+ /** Each dot's diameter. */
388
+ dotSize: Types.number(12, { min: 0, max: 48, step: 1, unit: "px" }),
389
+ /** Fill under each line: none, the series colour, or the colour fading to nothing at the baseline. */
390
+ area: Types.oneOf(["none", "solid", "gradient"] as const),
391
+ /** The fill's opacity at the line. */
392
+ areaOpacity: Types.number(0.4, { min: 0, max: 1, step: 0.05 }),
393
+ }
394
+
395
+ export const LINE_LABELS = {
396
+ /** Read as the chart is built: each point's value printed above or below it, or none. */
397
+ values: Types.oneOf(["none", "above", "below"] as const, { animatable: false }),
379
398
  }
@@ -185,6 +185,9 @@ function extentOf(values: number[]): Range {
185
185
  return lo <= hi ? [lo, hi] : [0, 1]
186
186
  }
187
187
 
188
+ /** Up to this many samples in view, every one is a tick. */
189
+ export const EVERY_SAMPLE = 12
190
+
188
191
  /**
189
192
  * Ticks spanning `range`, each a sample, nearest to where an evenly spaced tick
190
193
  * would go. They must be samples (a caption between two has nothing to say) and
@@ -201,6 +204,8 @@ function sampledTicks(
201
204
  const inside = positions.filter((v) => v >= min && v <= max)
202
205
  const unique = [...new Set(inside)].sort((a, b) => a - b)
203
206
  if (unique.length === 0) return markerValues([min, max], divisions)
207
+ // Few and even enough to caption every one: a month left out reads as missing data.
208
+ if (unique.length <= EVERY_SAMPLE && evenlySpaced(unique)) return unique
204
209
 
205
210
  const nearest = (target: number) =>
206
211
  unique.reduce((best, v) =>
@@ -215,6 +220,14 @@ function sampledTicks(
215
220
  return out
216
221
  }
217
222
 
223
+ /** Whether no two neighbouring samples sit much closer than the average gap: months do, a burst of days between weeks of nothing does not. */
224
+ function evenlySpaced(sorted: readonly number[]): boolean {
225
+ if (sorted.length < 3) return true
226
+ const mean = (sorted[sorted.length - 1] - sorted[0]) / (sorted.length - 1)
227
+ for (let i = 1; i < sorted.length; i++) if (sorted[i] - sorted[i - 1] < mean * 0.75) return false
228
+ return true
229
+ }
230
+
218
231
  /** The cell of the row a tick came from, or `fallback` when it is not a sample. */
219
232
  function labelBySample(
220
233
  positions: number[],
package/src/nodes.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { LineChart } from "./line-chart";
2
2
  import { BarChart } from "./bar-chart";
3
3
  import { PieChart } from "./pie-chart";
4
+ import { RadarChart } from "./radar-chart";
5
+ import { RadialChart } from "./radial-chart";
4
6
 
5
7
  // Typed by name, so the declaration names each component rather than spelling out its type through core.
6
- export const NODES: (typeof LineChart | typeof BarChart | typeof PieChart)[] = [LineChart, BarChart, PieChart];
8
+ export const NODES: (typeof LineChart | typeof BarChart | typeof PieChart | typeof RadarChart | typeof RadialChart)[] = [
9
+ LineChart,
10
+ BarChart,
11
+ PieChart,
12
+ RadarChart,
13
+ RadialChart,
14
+ ];
@@ -1,26 +1,5 @@
1
1
  export { PieChart, isSliceSelected } from "./pie-chart"
2
2
  export type { PieChartValues } from "./pie-chart"
3
3
  export { PieRing } from "./pie-ring"
4
- export { PieLegend } from "./pie-legend"
5
- export {
6
- DEFAULT_PIE_CHART_THEME,
7
- lerpPieChartTheme,
8
- pieArcs,
9
- relaxLabels,
10
- resolvePieChartTheme,
11
- resolvePieSlices,
12
- ringPoint,
13
- sliceTotal,
14
- sliceValue,
15
- } from "./shared"
16
- export type {
17
- CenterConfig,
18
- LabelConfig,
19
- PieArc,
20
- PieChartConfig,
21
- PieChartTheme,
22
- PieLabelMode,
23
- PieLabelPlacement,
24
- PieSlice,
25
- RingConfig,
26
- } from "./shared"
4
+ export { PIE_STYLE_GROUPS, pieArcs, relaxLabels, resolvePieSlices, sliceTotal, sliceValue } from "./shared"
5
+ export type { LabelConfig, PieArc, PieLabelMode, PieLabelPlacement, PieStyle, RingConfig } from "./shared"
@@ -10,30 +10,35 @@ import {
10
10
  // Named so this component's emitted type reaches `Node2D` and its props through the sdk, not core.
11
11
  type Node2D,
12
12
  type Node2DProps as _Node2DProps,
13
+ type TextStyle as _TextStyle,
14
+ type Fill as _Fill,
15
+ type FillResolved as _FillResolved,
13
16
  } from "@motionscript/sdk"
14
17
 
15
18
  import { asParts, fresh } from "../kit/entries"
16
19
  import { beats, type AnyTimeline, type Beat } from "../kit/motion"
17
- import { PieLegend } from "./pie-legend"
20
+ import { PieLegend } from "../kit/pie-legend"
18
21
  import { PieRing } from "./pie-ring"
22
+ import { CHART_STYLE_FAMILY, CHART_STYLE_PRESETS, migrateChartStyle, paletteFill } from "../kit/style"
19
23
  import {
20
- DEFAULT_PIE_CHART_THEME,
21
- lerpPieChartTheme,
22
- resolvePieChartTheme,
24
+ PIE_SPOTLIGHT_DIM,
25
+ PIE_STYLE_GROUPS,
26
+ centerConfigOf,
27
+ labelConfigOf,
28
+ legendOf,
23
29
  resolvePieSlices,
30
+ ringConfigOf,
24
31
  selectionFade,
25
- type PieChartConfig,
26
- type PieChartTheme,
27
32
  type PieLabelMode,
28
- type PieLabelPlacement,
29
33
  type PieSlice,
34
+ type PieStyle,
30
35
  } from "./shared"
31
36
 
32
37
  export interface PieChartValues {
33
38
  readonly data: DataRecord[]
34
39
  readonly categoryField: string
35
40
  readonly valueField: string
36
- readonly theme: PieChartConfig
41
+ readonly style: PieStyle
37
42
  readonly pops: number[]
38
43
  }
39
44
 
@@ -44,7 +49,12 @@ const LABEL_SHARE = 0.35
44
49
  const SLICE_ARG = Types.number(0, { min: 0, step: 1 })
45
50
 
46
51
  function slicesOf(s: PieChartValues): PieSlice[] {
47
- return resolvePieSlices(s.data, s.categoryField, s.valueField, s.theme.sliceFills)
52
+ return resolvePieSlices(
53
+ s.data,
54
+ s.categoryField,
55
+ s.valueField,
56
+ s.data.map((_, i) => paletteFill(s.style, i))
57
+ )
48
58
  }
49
59
 
50
60
  function popAt(s: PieChartValues, index: number): number {
@@ -60,8 +70,7 @@ export function isSliceSelected(s: PieChartValues, index: number): boolean {
60
70
  function sliceDims(s: PieChartValues): number[] {
61
71
  let focus = 0
62
72
  for (let i = 0; i < s.data.length; i++) focus = Math.max(focus, popAt(s, i))
63
- const dim = s.theme.spotlight.dimOpacity
64
- return s.data.map((_, i) => selectionFade(focus, dim, popAt(s, i)))
73
+ return s.data.map((_, i) => selectionFade(focus, PIE_SPOTLIGHT_DIM, popAt(s, i)))
65
74
  }
66
75
 
67
76
  /** Every slice's pop eased to what `pop` asks for. A move that changes nothing still takes its slot. */
@@ -94,16 +103,14 @@ function partsOf(children: readonly ChildView[]) {
94
103
  export const PieChart = component2d({
95
104
  key: "pieChart",
96
105
  state: {
97
- data: fresh<DataRecord[]>(() => []),
106
+ data: fresh<DataRecord[]>(() => [], { kind: "dataset" }),
98
107
  /** Slice caption, used on the labels and the legend. */
99
108
  categoryField: "",
100
109
  /** Slice magnitude: the wedge's share of the ring. */
101
110
  valueField: "",
102
- theme: Types.custom<PieChartTheme, PieChartConfig>(DEFAULT_PIE_CHART_THEME, { resolve: resolvePieChartTheme, lerp: lerpPieChartTheme }),
111
+ style: Types.style(CHART_STYLE_FAMILY, PIE_STYLE_GROUPS, { presets: CHART_STYLE_PRESETS }),
103
112
  /** What the ring's labels say. */
104
113
  labelMode: Types.custom<PieLabelMode>("percent"),
105
- /** Where they sit: inside their wedge, outside on a leader, or nowhere. */
106
- labelPlacement: Types.custom<PieLabelPlacement>("leader"),
107
114
  /** Gap between the ring and the legend, in px. */
108
115
  gap: 40,
109
116
  /** Text set in the ring's hole. */
@@ -112,8 +119,6 @@ export const PieChart = component2d({
112
119
  centerCaption: "",
113
120
  /** How far each slice is pushed out, `0`-`1` per index. Here, not on the ring, because the wedges and the legend both read it. */
114
121
  pops: fresh<number[]>(() => []),
115
- /** Read as the chart is built: the legend is a part the chart builds or doesn't. */
116
- showLegend: Types.boolean(true, { animatable: false }),
117
122
  /** Read as the chart is built: whether the hole's summary is drawn at rest. A scene that wants it to land later sets `false` and runs `revealCenter`. */
118
123
  showCenter: Types.boolean(true, { animatable: false }),
119
124
  },
@@ -123,41 +128,55 @@ export const PieChart = component2d({
123
128
  height: "fill",
124
129
  pops: (state) => slicesOf(state).map(() => 0),
125
130
  },
131
+ migrate: { style: migrateChartStyle("pie") },
126
132
  allowedChildren: [],
127
133
  // The structure (a label and legend row per row) is read once; slices, paint and selection are bound, so a theme tween re-paints and a `set` of `data` re-cuts.
128
134
  children: (state) => {
129
- const theme = () => state.theme
135
+ const style = () => state.style
130
136
  const slices = () => slicesOf(state)
131
137
  const parts: Node2D[] = [
132
138
  new PieRing({
133
139
  width: "fill",
134
140
  height: "fill",
135
141
  slices,
136
- ring: () => theme().ring,
137
- label: () => theme().label,
138
- centerStyle: () => theme().center,
142
+ ring: () => ringConfigOf(style()),
143
+ label: () => labelConfigOf(style()),
144
+ centerStyle: () => centerConfigOf(style()),
139
145
  labelMode: state.labelMode,
140
- labelPlacement: state.labelPlacement,
146
+ labelPlacement: state.style.labelPlacement,
141
147
  pops: () => state.pops,
142
- dimOpacity: () => theme().spotlight.dimOpacity,
148
+ dimOpacity: PIE_SPOTLIGHT_DIM,
143
149
  centerLabel: state.centerLabel,
144
150
  centerCaption: state.centerCaption,
145
151
  centerReveal: state.showCenter === false ? 0 : 1,
146
152
  }) as unknown as Node2D,
147
153
  ]
148
- if (state.showLegend !== false) {
149
- parts.push(
150
- new PieLegend({
151
- slices,
152
- legend: () => theme().legend,
153
- dims: () => sliceDims(state),
154
- // Spacing is fixed as built.
155
- gap: state.theme.legend.itemGap / 3,
156
- padding: state.theme.legend.padding,
157
- }) as unknown as Node2D
158
- )
154
+ const position = state.style.legend
155
+ if (position !== "none") {
156
+ const side = position === "right"
157
+ const legend = new PieLegend({
158
+ slices,
159
+ legend: () => legendOf(style()),
160
+ dims: () => sliceDims(state),
161
+ flow: side ? "vertical" : "horizontal",
162
+ align: "center",
163
+ // Spacing is fixed as built.
164
+ gap: side ? legendOf(state.style).itemGap / 3 : legendOf(state.style).itemGap,
165
+ padding: 0,
166
+ }) as unknown as Node2D
167
+ if (position === "top") parts.unshift(legend)
168
+ else parts.push(legend)
159
169
  }
160
- const row = new Rect({ flow: "horizontal", width: "fill", height: "fill", gap: state.gap, align: "center", children: parts })
170
+ const row = new Rect({
171
+ flow: position === "right" || position === "none" ? "horizontal" : "vertical",
172
+ width: "fill",
173
+ height: "fill",
174
+ gap: state.gap,
175
+ padding: 24,
176
+ align: "center",
177
+ fill: () => state.style.background,
178
+ children: parts,
179
+ })
161
180
  return asParts([row as unknown as Node2D])
162
181
  },
163
182
  commands: {