@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
@@ -3,7 +3,6 @@ import {
3
3
  Types,
4
4
  component2d,
5
5
  fillOps,
6
- shadowOps,
7
6
  strokeOps,
8
7
  type Fill,
9
8
  type FillResolved,
@@ -11,86 +10,82 @@ import {
11
10
  // Named so this component's emitted type reaches `Node2D` and its props through the sdk, not core.
12
11
  type Node2D as _Node2D,
13
12
  type Node2DProps as _Node2DProps,
14
- type Shadow,
15
- type ShadowResolved,
13
+ type RenderContext,
16
14
  type Stroke,
17
15
  type StrokeResolved,
16
+ type Vector2,
18
17
  } from "@motionscript/sdk"
19
18
 
20
19
  import { fresh } from "./entries"
21
20
  import { loadPaints } from "./paints"
22
- import {
23
- DEFAULT_PLOT_AREA,
24
- DEFAULT_X_AXIS,
25
- DEFAULT_Y_AXIS,
26
- type Range,
27
- } from "./shared"
21
+ import type { Range } from "./shared"
22
+
23
+ const strokeEntry = () => Types.custom<Stroke, StrokeResolved[]>([], { resolve: strokeOps.resolve, lerp: strokeOps.lerp })
24
+
25
+ /** Where along a scale a fraction lands in a run of `length` px centred on 0. */
26
+ function along(value: number, [min, max]: Range, length: number): number {
27
+ return (max <= min ? 0 : (value - min) / (max - min) - 0.5) * length
28
+ }
28
29
 
29
- const strokeEntry = (initial: Stroke) =>
30
- Types.custom<Stroke, StrokeResolved[]>(initial, { resolve: strokeOps.resolve, lerp: strokeOps.lerp })
30
+ /** One rule per draw: stroking a union of open paths paints nothing, and a dashed rule keeps its own phase. */
31
+ export function rule(ctx: RenderContext, from: Vector2, to: Vector2, stroke: StrokeResolved[], opacity = 1): void {
32
+ ctx.draw(new Graphics2D().line({ points: [from, to] }).stroke(stroke).opacity(opacity))
33
+ }
31
34
 
32
35
  /**
33
- * The plot backdrop: a filled panel and a grid, sized from its box. The frame is
34
- * a separate {@link ChartBorder} drawn over the marks. Grid lines are placed from
35
- * the axis markers' data-unit positions mapped through the extents, so each lands
36
- * under its marker however the markers are spaced.
36
+ * The plot backdrop: the panel fill, the grid and the axis rules, sized from its
37
+ * box. Grid lines are placed from data-unit marker positions mapped through the
38
+ * extents, so each lands under its marker however the markers are spaced; a line
39
+ * on an edge is drawn just inside it.
37
40
  */
38
41
  export const ChartCanvas = component2d({
39
42
  key: "chartCanvas",
40
43
  state: {
41
- fill: Types.custom<Fill, FillResolved[]>(DEFAULT_PLOT_AREA.fill, { resolve: fillOps.resolve, lerp: fillOps.lerp }),
42
- shadow: Types.custom<Shadow, ShadowResolved[]>(DEFAULT_PLOT_AREA.shadow, { resolve: shadowOps.resolve, lerp: shadowOps.lerp }),
43
- xGridStroke: strokeEntry(DEFAULT_X_AXIS.lineStyle),
44
- yGridStroke: strokeEntry(DEFAULT_Y_AXIS.lineStyle),
45
- /**
46
- * Data-unit positions of the vertical grid lines, one per horizontal marker.
47
- * Positions at or past an extent edge coincide with the border and are
48
- * skipped. Empty for a categorical axis, which has no numeric position to rule at.
49
- */
44
+ fill: Types.custom<Fill, FillResolved[]>([], { resolve: fillOps.resolve, lerp: fillOps.lerp }),
45
+ /** The vertical grid lines, one per `xMarkers` entry. */
46
+ xGridStroke: strokeEntry(),
47
+ /** The horizontal grid lines, one per `yMarkers` entry. */
48
+ yGridStroke: strokeEntry(),
50
49
  xMarkers: fresh<number[]>(() => []),
51
50
  yMarkers: fresh<number[]>(() => []),
52
51
  /** `[min, max]` of each scale, mapping the markers into the panel. */
53
52
  xExtent: fresh<Range>(() => [0, 1]),
54
53
  yExtent: fresh<Range>(() => [0, 1]),
54
+ /** The baseline and the value-axis rule. */
55
+ axisStroke: strokeEntry(),
56
+ /** Where the horizontal axis rule sits on the y scale, or `null` for none. */
57
+ xAxisAt: Types.custom<number | null>(null),
58
+ /** Where the vertical axis rule sits on the x scale, or `null` for none. */
59
+ yAxisAt: Types.custom<number | null>(null),
55
60
  },
56
- // The grid strokes sit past the conventional slots, so they are named here.
57
- load: (loader, state) =>
58
- loadPaints(loader, { fills: [state.fill], shadows: [state.shadow], strokes: [state.xGridStroke, state.yGridStroke] }),
61
+ load: (loader, state) => loadPaints(loader, { fills: [state.fill], strokes: [state.xGridStroke, state.yGridStroke, state.axisStroke] }),
59
62
  render: (ctx, state) => {
60
63
  const { width: W, height: H } = ctx.size
61
64
  if (W <= 0 || H <= 0) return
65
+ if (state.fill.length > 0) ctx.draw(new Graphics2D().rect({ x: 0, y: 0, width: W, height: H }).fill(state.fill))
62
66
 
63
- ctx.draw(new Graphics2D().rect({ x: 0, y: 0, width: W, height: H }).shadow(state.shadow).fill(state.fill))
64
-
65
- const [xMin, xMax] = state.xExtent
66
- const [yMin, yMax] = state.yExtent
67
- // A zero-width extent maps to 0.
68
- const xFrac = (v: number) => (xMax <= xMin ? 0 : (v - xMin) / (xMax - xMin))
69
- const yFrac = (v: number) => (yMax <= yMin ? 0 : (v - yMin) / (yMax - yMin))
70
- // Markers on or past the edges would sit under the border.
71
- const interior = (f: number) => f > 1e-6 && f < 1 - 1e-6
72
-
73
- const xStroke = state.xGridStroke[0]
74
- if (xStroke) {
75
- const xGrid = new Graphics2D()
76
- for (const v of state.xMarkers) {
77
- const f = xFrac(v)
78
- if (!interior(f)) continue
79
- xGrid.rect({ x: -W / 2 + f * W, y: 0, width: xStroke.weight, height: H })
67
+ const inside = (at: number, length: number, weight: number) => Math.max(-length / 2 + weight / 2, Math.min(length / 2 - weight / 2, at))
68
+ const verticals = (values: readonly number[], stroke: StrokeResolved[]) => {
69
+ const weight = stroke[0]?.weight ?? 0
70
+ if (weight <= 0) return
71
+ for (const v of values) {
72
+ const x = inside(along(v, state.xExtent, W), W, weight)
73
+ rule(ctx, { x, y: -H / 2 }, { x, y: H / 2 }, stroke)
80
74
  }
81
- ctx.draw(xGrid.fill(xStroke.fill))
82
75
  }
83
-
84
- const yStroke = state.yGridStroke[0]
85
- if (yStroke) {
86
- const yGrid = new Graphics2D()
87
- for (const v of state.yMarkers) {
88
- const f = yFrac(v)
89
- if (!interior(f)) continue
90
- yGrid.rect({ x: 0, y: -H / 2 + f * H, width: W, height: yStroke.weight })
76
+ const horizontals = (values: readonly number[], stroke: StrokeResolved[]) => {
77
+ const weight = stroke[0]?.weight ?? 0
78
+ if (weight <= 0) return
79
+ for (const v of values) {
80
+ const y = inside(along(v, state.yExtent, H), H, weight)
81
+ rule(ctx, { x: -W / 2, y }, { x: W / 2, y }, stroke)
91
82
  }
92
- ctx.draw(yGrid.fill(yStroke.fill))
93
83
  }
84
+ const within = ([min, max]: Range) => (v: number) => v >= Math.min(min, max) - 1e-9 && v <= Math.max(min, max) + 1e-9
85
+ verticals(state.xMarkers.filter(within(state.xExtent)), state.xGridStroke)
86
+ horizontals(state.yMarkers.filter(within(state.yExtent)), state.yGridStroke)
87
+ if (state.xAxisAt !== null) horizontals([state.xAxisAt], state.axisStroke)
88
+ if (state.yAxisAt !== null) verticals([state.yAxisAt], state.axisStroke)
94
89
  },
95
90
  })
96
91
 
@@ -6,7 +6,6 @@ import {
6
6
  Text,
7
7
  Types,
8
8
  component2d,
9
- textOps,
10
9
  type Fill,
11
10
  type NodeOf,
12
11
  // Named so the row's emitted type reaches the Rect surface it spreads through the sdk, not core.
@@ -28,6 +27,7 @@ import {
28
27
  type Vector2 as _Vector2,
29
28
  } from "@motionscript/sdk"
30
29
 
30
+ import { boundText } from "./cartesian"
31
31
  import { fresh } from "./entries"
32
32
  import { frameEase } from "./motion"
33
33
  import {
@@ -40,18 +40,19 @@ import {
40
40
  const row = { ...Paint.state, ...Flow.state, ...Shapes.corners }
41
41
 
42
42
  /**
43
- * One legend entry, hugging its caption and swatch; `shown` scales that width
44
- * from nothing. The width is measured from the content at any reveal, so a frame
45
- * never depends on the entry once having been fully shown (`"hug"` is not a
46
- * number to scale until something measures it).
43
+ * One legend entry, hugging its swatch and caption; `shown` scales its length
44
+ * along the legend (`along`) from nothing. The length is measured from the
45
+ * content at any reveal, so a frame never depends on the entry once having been
46
+ * fully shown.
47
47
  */
48
48
  const LegendEntry = component2d({
49
49
  key: "legendEntry",
50
- state: { ...row, shown: Types.number(1) },
50
+ state: { ...row, shown: Types.number(1), along: Types.oneOf(["horizontal", "vertical"] as const) },
51
51
  commands: { ...Paint.commands },
52
52
  measure: (measurer, state, children) => {
53
53
  const size = Flow.measure(measurer, state, children, Paint.inset(state))
54
- return state.shown >= 1 ? size : { ...size, width: state.shown * size.width }
54
+ if (state.shown >= 1) return size
55
+ return state.along === "vertical" ? { ...size, height: state.shown * size.height } : { ...size, width: state.shown * size.width }
55
56
  },
56
57
  layout: (box, state, children) => Flow.layout(box, state, children, Paint.inset(state)),
57
58
  render: (ctx, state) => Paint.draw(ctx, state, Shapes.rect(ctx.size, state)),
@@ -65,7 +66,11 @@ function revealOf(value: readonly number[] | Readonly<Record<number, number>>, p
65
66
  return next
66
67
  }
67
68
 
68
- /** A chart's legend: a horizontal row with one entry per series, each a caption and a colour swatch. This node is the row, so the parent places it. */
69
+ /**
70
+ * A chart's legend: one entry per series, a colour swatch then its caption, in
71
+ * a row (`flow: "horizontal"`) or a column (`"vertical"`, for a legend beside
72
+ * the plot). This node is the row, so the parent places it.
73
+ */
69
74
  export const ChartLegend = component2d({
70
75
  key: "chartLegend",
71
76
  state: {
@@ -75,7 +80,7 @@ export const ChartLegend = component2d({
75
80
  swatches: fresh<Fill[]>(() => []),
76
81
  legend: Types.custom<LegendConfig>(DEFAULT_LEGEND),
77
82
  /**
78
- * How far each entry is shown, `0`-`1`, driving its width, opacity and gap
83
+ * How far each entry is shown, `0`-`1`, driving its length, opacity and gap
79
84
  * together. Entries are never added or removed: "is this node a child" is not
80
85
  * a function of time, while collapsed-but-present is.
81
86
  */
@@ -86,34 +91,37 @@ export const ChartLegend = component2d({
86
91
  flow: "horizontal",
87
92
  width: "hug",
88
93
  height: "hug",
89
- gap: (state) => state.legend.itemGap,
94
+ gap: (state) => (state.flow === "vertical" ? Math.round(state.legend.itemGap * 0.4) : state.legend.itemGap),
90
95
  padding: (state) => state.legend.padding,
96
+ align: (state) => (state.flow === "vertical" ? "centerLeft" : "center"),
91
97
  reveal: (state) => state.series.map((s) => ((s.enabled ?? true) ? 1 : 0)),
92
98
  },
93
99
  // Every entry stays a child, so one always comes back where it belongs.
94
100
  children: (state) => {
95
- const marker = state.legend.markerStyle
101
+ const marker = () => state.legend.markerStyle
102
+ const along = state.flow === "vertical" ? "vertical" : "horizontal"
96
103
  return state.series.map((series, index) =>
97
104
  LegendEntry({
98
105
  flow: "horizontal",
99
- gap: 12,
106
+ gap: () => Math.round(((state.legend.textStyle.fontSize as number | undefined) ?? 24) * 0.45),
100
107
  align: "center",
101
108
  width: "hug",
102
109
  height: "hug",
103
- // The width follows the reveal in `measure`, and the flanking gap closes with the entry.
110
+ along,
111
+ // The length follows the reveal in `measure`, and the flanking gap closes with the entry.
104
112
  opacity: () => state.reveal[index] ?? 0,
105
113
  gapScale: () => state.reveal[index] ?? 0,
106
114
  shown: () => state.reveal[index] ?? 0,
107
115
  children: [
108
- Text({ ...textOps.styleProps(state.legend.textStyle), text: series.label }),
109
116
  Rect({
110
- width: marker.width,
111
- height: marker.height,
112
- cornerRadius: marker.borderRadius,
113
- fill: state.swatches[index] ?? "white",
114
- stroke: marker.stroke,
115
- shadow: marker.shadow,
117
+ width: () => marker().width,
118
+ height: () => marker().height,
119
+ cornerRadius: () => marker().borderRadius,
120
+ fill: () => state.swatches[index] ?? "white",
121
+ stroke: () => marker().stroke,
122
+ shadow: () => marker().shadow,
116
123
  }),
124
+ Text({ ...boundText(() => state.legend.textStyle), text: series.label || series.field }),
117
125
  ],
118
126
  })
119
127
  )
@@ -0,0 +1,114 @@
1
+ import {
2
+ Rect,
3
+ Text,
4
+ Types,
5
+ component2d,
6
+ type NodeOf,
7
+ // Named so this component's emitted type reaches `Node2D` and its props through the sdk, not core.
8
+ type Node2D,
9
+ type Node2DProps as _Node2DProps,
10
+ } from "@motionscript/sdk"
11
+ import { enterBudget } from "@motionscript/sdk/component"
12
+
13
+ import { boundText } from "./cartesian"
14
+ import { asParts, fresh } from "./entries"
15
+ import { armed, arrival, arrivalOf, arrivalsEntry, beats, frameEase, type Arrival } from "./motion"
16
+ import { DEFAULT_X_AXIS, type AxisConfig, type Range } from "./shared"
17
+ import { axisPads, markerCaption } from "./vertical-axis"
18
+
19
+ const SLIDE = 24
20
+
21
+ /**
22
+ * A row of {@link Text} markers under the plot, the mirror of `VerticalAxis`
23
+ * (bound text and positions, landed resting state). Markers are placed by value
24
+ * across the range, whatever they say: `2026-03` sits three months along, not
25
+ * three slots along, unlike a bar chart's category axis. An end marker may hang
26
+ * past the row by `overhang` px on either side, then is held inside it.
27
+ */
28
+ export const HorizontalAxis = component2d({
29
+ key: "horizontalAxis",
30
+ state: {
31
+ range: fresh<Range>(() => [0, 1]),
32
+ /** Tick values in data units. */
33
+ markers: fresh<number[]>(() => []),
34
+ /** One caption per marker in its order; a missing one reads as the bare numeric value. */
35
+ captions: fresh<string[]>(() => []),
36
+ axis: Types.custom<AxisConfig>(DEFAULT_X_AXIS),
37
+ /** Caption centred beneath the markers; empty hides it. */
38
+ label: "",
39
+ /** How far past the row's left and right ends a marker may reach, in px. */
40
+ overhang: Types.custom<{ left: number; right: number }>({ left: 0, right: 0 }),
41
+ arrivals: arrivalsEntry(),
42
+ },
43
+ // A node made of markers would otherwise hug them.
44
+ defaults: { width: "fill", height: "fill" },
45
+ children: (state) => {
46
+ const pads = () => axisPads(state.axis, "top")
47
+ const sorted = () => [...state.markers].sort((a, b) => a - b)
48
+ const fracAt = (value: number | undefined) => {
49
+ if (value === undefined) return 0
50
+ const [min, max] = state.range
51
+ return max <= min ? 0 : (value - min) / (max - min)
52
+ }
53
+
54
+ const markers = state.markers.map((_, i) => {
55
+ const width = () => (marker.parent as Node2D | null)?.layoutBounds.width ?? 0
56
+ const marker: Node2D = new Text({
57
+ ...boundText(() => state.axis.labelStyle),
58
+ text: () => {
59
+ const value = sorted()[i]
60
+ return value === undefined ? "" : markerCaption(state, value)
61
+ },
62
+ x: () => {
63
+ const w = width()
64
+ const half = marker.layoutBounds.width / 2
65
+ const centred = (fracAt(sorted()[i]) - 0.5) * w
66
+ return Math.max(-w / 2 - state.overhang.left + half, Math.min(w / 2 + state.overhang.right - half, centred))
67
+ },
68
+ opacity: () => arrivalOf(state.arrivals, i).opacity,
69
+ y: () => arrivalOf(state.arrivals, i).shift,
70
+ })
71
+ return marker
72
+ })
73
+
74
+ const children: Node2D[] = [
75
+ new Rect({ width: "fill", height: "hug", padding: () => ({ top: pads().tick }), children: markers }) as unknown as Node2D,
76
+ ]
77
+ if (state.label !== "") {
78
+ children.push(
79
+ new Text({
80
+ ...boundText(() => state.axis.titleStyle),
81
+ text: state.label,
82
+ opacity: () => arrivalOf(state.arrivals, "label").opacity,
83
+ y: () => arrivalOf(state.arrivals, "label").shift,
84
+ }) as unknown as Node2D
85
+ )
86
+ }
87
+ return asParts([new Rect({ flow: "vertical", gap: () => pads().title, height: "hug", width: "fill", children }) as unknown as Node2D])
88
+ },
89
+ commands: {
90
+ /** Stagger markers in left to right, then the caption. The duration is the whole entrance ({@link enterBudget}). */
91
+ enter: {
92
+ args: {
93
+ stagger: Types.custom<number | undefined>(undefined),
94
+ slide: Types.number(SLIDE),
95
+ },
96
+ run: (timeline, state) => {
97
+ const { stagger, slide } = timeline.args
98
+ const count = state.markers.length
99
+ const captioned = state.label !== ""
100
+ const from: Arrival = { opacity: 0, shift: slide }
101
+ const keys = [...state.markers.keys(), ...(captioned ? ["label"] : [])]
102
+ const arm = timeline.animate(() => ({ arrivals: armed(keys, from) }))
103
+ if (count === 0) return arm
104
+ const { step, per, caption } = enterBudget(timeline.duration, count, captioned, stagger)
105
+ const ease = frameEase(timeline)
106
+ const markers = state.markers.map((_, i) => ({ at: i * step, duration: per, piece: arrival(timeline, i, from, ease) }))
107
+ const label = captioned ? [{ at: (count - 1) * step + per, duration: caption, piece: arrival(timeline, "label", from, ease) }] : []
108
+ return timeline.together([arm, beats(timeline, [...markers, ...label])])
109
+ },
110
+ },
111
+ },
112
+ })
113
+
114
+ export type HorizontalAxis = NodeOf<typeof HorizontalAxis>
package/src/kit/motion.ts CHANGED
@@ -8,6 +8,13 @@ export type AnyTimeline = Timeline<any, any>
8
8
  /** How far a legend starts below its slot when `enter` brings it in, in px. */
9
9
  export const LEGEND_SLIDE = 20
10
10
 
11
+ /** Where a legend waits before it comes up: below its place, above it, or to its right. */
12
+ export function legendAway(at: string): { x?: number; y?: number } {
13
+ if (at === "top") return { y: LEGEND_SLIDE }
14
+ if (at === "right") return { x: LEGEND_SLIDE }
15
+ return { y: -LEGEND_SLIDE }
16
+ }
17
+
11
18
  /** Marker count when an axis states no ticks. */
12
19
  export const DEFAULT_MARKERS = 5
13
20
 
@@ -29,10 +29,10 @@ import {
29
29
  type Vector2 as _Vector2,
30
30
  } from "@motionscript/sdk"
31
31
 
32
- import { fresh } from "../kit/entries"
33
- import { armed, arrival, arrivalOf, arrivalsEntry, beats, frameEase, type Arrival } from "../kit/motion"
34
- import { DEFAULT_LEGEND, type LegendConfig } from "../kit/shared"
35
- import type { PieSlice } from "./shared"
32
+ import { fresh } from "./entries"
33
+ import { armed, arrival, arrivalOf, arrivalsEntry, beats, frameEase, type Arrival } from "./motion"
34
+ import { DEFAULT_LEGEND, type LegendConfig } from "./shared"
35
+ import type { PieSlice } from "./polar"
36
36
 
37
37
  const SLIDE = 16
38
38
 
@@ -66,7 +66,7 @@ export const PieLegend = component2d({
66
66
  },
67
67
  // The selection dim rides a wrapper, so the entrance animates the entry's own opacity without the two fighting over one value.
68
68
  children: (state) => {
69
- const marker = state.legend.markerStyle
69
+ const marker = () => state.legend.markerStyle
70
70
  return state.slices.map((_, index) =>
71
71
  Rect({
72
72
  width: "hug",
@@ -76,7 +76,7 @@ export const PieLegend = component2d({
76
76
  children: [
77
77
  Rect({
78
78
  flow: "horizontal",
79
- gap: 12,
79
+ gap: () => Math.round(state.legend.textStyle.fontSize === undefined ? 12 : Number(state.legend.textStyle.fontSize) * 0.4),
80
80
  align: "center",
81
81
  width: "hug",
82
82
  height: "hug",
@@ -84,15 +84,16 @@ export const PieLegend = component2d({
84
84
  x: () => arrivalOf(state.arrivals, index).shift,
85
85
  children: [
86
86
  Rect({
87
- width: marker.height,
88
- height: marker.height,
89
- cornerRadius: marker.borderRadius,
87
+ width: () => marker().width,
88
+ height: () => marker().height,
89
+ cornerRadius: () => marker().borderRadius,
90
90
  fill: () => state.slices[index]?.fill ?? "white",
91
- stroke: marker.stroke,
92
- shadow: marker.shadow,
93
91
  }),
94
92
  Text({
95
93
  ...textOps.styleProps(state.legend.textStyle),
94
+ fill: () => state.legend.textStyle.fill ?? "white",
95
+ fontSize: () => state.legend.textStyle.fontSize ?? 28,
96
+ fontWeight: () => state.legend.textStyle.fontWeight ?? 500,
96
97
  text: () => state.slices[index]?.category ?? "",
97
98
  }),
98
99
  ],
@@ -0,0 +1,54 @@
1
+ /** What the polar charts (pie, radial) share: slices, ring geometry, the hole's summary and the selection fade. */
2
+
3
+ import { clamp, fillOps, type Fill, type FillResolved, type TextStyle, type Vector2 } from "@motionscript/sdk"
4
+
5
+ /** One resolved slice, which the ring and legend consume; the chart resolves it from `data` and two field names. */
6
+ export interface PieSlice {
7
+ /** Label and legend caption. */
8
+ category: string
9
+ /** The wedge covers `value / (sum of all values)`. */
10
+ value: number
11
+ /** Wedge and swatch paint. */
12
+ fill: Fill
13
+ }
14
+
15
+ export const DEG = Math.PI / 180
16
+
17
+ /**
18
+ * Opacity of a slice under the selection: 1 while nothing is out (`focus` is the
19
+ * most-grown pop) and for the selected slice itself, falling toward `dimOpacity`
20
+ * for the rest as the selection grows.
21
+ */
22
+ export function selectionFade(focus: number, dimOpacity: number, pop: number): number {
23
+ if (focus <= 0) return 1
24
+ return 1 - (1 - clamp(dimOpacity, 0, 1)) * focus * (1 - pop)
25
+ }
26
+
27
+ /** The point at radius `r`, angle `a` on a ring about the origin in centred y-up space. Angles run clockwise from 12 o'clock. */
28
+ export function ringPoint(r: number, a: number): Vector2 {
29
+ return { x: r * Math.sin(a), y: r * Math.cos(a) }
30
+ }
31
+
32
+ /** The summary set in the ring's hole. */
33
+ export interface CenterConfig {
34
+ labelStyle: TextStyle
35
+ captionStyle: TextStyle
36
+ }
37
+
38
+ /** How far everything outside the selection fades while any slice is selected; fainter than a gapless chart's, as a ring's slices touch. */
39
+ export const PIE_SPOTLIGHT_DIM = 0.15
40
+
41
+ const resolvedFills = new WeakMap<object, FillResolved[]>()
42
+
43
+ export function resolvedFill(fill: Fill): FillResolved[] {
44
+ if (typeof fill !== "object" || fill === null) return fillOps.resolve(fill)
45
+ let resolved = resolvedFills.get(fill)
46
+ if (resolved === undefined) resolvedFills.set(fill, resolved = fillOps.resolve(fill))
47
+ return resolved
48
+ }
49
+
50
+ /** Each slice's paint, resolved once per fill object. */
51
+ export function ringFills(slices: readonly PieSlice[]): FillResolved[][] {
52
+ return slices.map((slice) => resolvedFill(slice.fill))
53
+ }
54
+