@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
@@ -1,81 +1,144 @@
1
- import { Rect, type ChildView, type Node2D, type Size2D } from "@motionscript/sdk"
1
+ import {
2
+ Rect,
3
+ RotatedBox,
4
+ Text,
5
+ textOps,
6
+ type ChildView,
7
+ type Fill,
8
+ type FillResolved,
9
+ type Insets,
10
+ type Node2D,
11
+ type Size2D,
12
+ type TextStyle,
13
+ } from "@motionscript/sdk"
2
14
 
3
- import { VerticalAxis } from "./vertical-axis"
15
+ import type { CartesianStyle, LegendPosition } from "./style"
4
16
 
5
- /**
6
- * Headroom above the plot, in px.
7
- *
8
- * Two jobs, and the second is why it is real padding on the composition rather
9
- * than a number the point-mapping math imagines: a series peaking at the top of
10
- * the scale isn't flush to the top edge, **and** the top marker, which is
11
- * centred on that top grid line, has somewhere to be. Without it the marker's
12
- * upper half is drawn above the chart's own box.
13
- */
14
- export const TOP_HEADROOM = 24
17
+ /** Space between the frame's rows and columns: the plot and the legend, the axes and the legend. */
18
+ export function frameGap(style: Pick<CartesianStyle, "fontSize">): number {
19
+ return Math.round(style.fontSize * 0.9)
20
+ }
21
+
22
+ /** Room above the plot for the top value marker, which is centred on the top grid line. */
23
+ export function headroom(style: Pick<CartesianStyle, "fontSize">): number {
24
+ return Math.ceil(style.fontSize * 0.7)
25
+ }
26
+
27
+ /** How far an end marker of a horizontal value axis may hang past the plot: half a short caption. */
28
+ export function overhang(style: Pick<CartesianStyle, "fontSize">): number {
29
+ return Math.ceil(style.fontSize * 1.1)
30
+ }
15
31
 
16
- /** The parts of a cartesian chart's frame, as {@link cartesianFrame} arranges them. */
17
- export interface CartesianParts {
18
- /** The value axis, left of the plot. */
19
- readonly axis: Node2D
20
- /** The plot panel: the box the grid and the marks share. */
32
+ interface CartesianParts {
33
+ /** The axis beside the plot, on its left. */
34
+ readonly left?: Node2D
35
+ /** An invisible twin of `left` under the plot, so the row there starts at the plot's left edge. */
36
+ readonly ghost?: Node2D
37
+ /** The box the grid and the marks share. */
21
38
  readonly plot: Node2D
22
- /** An invisible twin of the value axis, reserving its gutter under the plot. */
23
- readonly gutter: Node2D
24
- /** What sits under the plot, top to bottom: the other axis, then the legend. */
25
- readonly below: readonly Node2D[]
39
+ /** The axis under the plot. */
40
+ readonly bottom?: Node2D
41
+ readonly legend?: Node2D
42
+ readonly legendAt: LegendPosition
43
+ readonly gap: () => number
44
+ readonly padding: () => Insets
26
45
  }
27
46
 
28
47
  /**
29
- * The frame the line and bar charts share: the value axis beside the plot, and
30
- * under them the gutter beside the rows below the plot, so those rows start at
31
- * the plot's left edge rather than the chart's.
48
+ * The frame the line and bar charts share. A legend above or below is centred
49
+ * on the whole chart; one on the right is a column beside the plot. An axis the
50
+ * style turns off is simply not here, and the plot takes its room.
32
51
  */
33
52
  export function cartesianFrame(parts: CartesianParts): Node2D {
53
+ const top = new Rect({ flow: "horizontal", width: "fill", height: "fill", children: [...(parts.left ? [parts.left] : []), parts.plot] })
54
+ const rows: Node2D[] = [top as unknown as Node2D]
55
+ if (parts.bottom) {
56
+ rows.push(new Rect({ flow: "horizontal", width: "fill", height: "hug", children: [...(parts.ghost ? [parts.ghost] : []), parts.bottom] }) as unknown as Node2D)
57
+ }
58
+ const body = new Rect({ flow: "vertical", width: "fill", height: "fill", children: rows })
59
+ const legend = parts.legend && parts.legendAt !== "none" ? parts.legend : undefined
60
+ const main = new Rect({
61
+ flow: "horizontal",
62
+ width: "fill",
63
+ height: "fill",
64
+ align: "center",
65
+ gap: parts.gap,
66
+ children: [body, ...(legend && parts.legendAt === "right" ? [legend] : [])],
67
+ })
68
+ const across = (node: Node2D) => new Rect({ flow: "horizontal", width: "fill", height: "hug", align: "center", children: [node] })
34
69
  return new Rect({
35
70
  flow: "vertical",
36
71
  width: "fill",
37
72
  height: "fill",
38
- padding: { top: TOP_HEADROOM },
73
+ gap: parts.gap,
74
+ padding: parts.padding,
39
75
  children: [
40
- new Rect({ flow: "horizontal", height: "fill", width: "fill", children: [parts.axis, parts.plot] }),
41
- new Rect({
42
- flow: "horizontal",
43
- width: "fill",
44
- children: [parts.gutter, new Rect({ flow: "vertical", width: "fill", gap: 20, children: [...parts.below] })],
45
- }),
76
+ ...(legend && parts.legendAt === "top" ? [across(legend)] : []),
77
+ main,
78
+ ...(legend && parts.legendAt === "bottom" ? [across(legend)] : []),
46
79
  ],
47
80
  }) as unknown as Node2D
48
81
  }
49
82
 
50
- /** A cartesian frame's parts, as a command reaches them through its chart's children. */
51
- export interface CartesianViews {
52
- readonly axis: ChildView
53
- /** The plot panel's own children: the grid, the marks, the border. */
54
- readonly plot: readonly ChildView[]
55
- readonly below: readonly ChildView[]
83
+ /** Every view under `children`, depth first in build order: the axis beside the plot comes before its twin, which holds no parts. */
84
+ export function chartViews(children: readonly ChildView[]): ChildView[] {
85
+ const out: ChildView[] = []
86
+ const walk = (views: readonly ChildView[]) => {
87
+ for (const view of views) {
88
+ out.push(view)
89
+ walk(view.children)
90
+ }
91
+ }
92
+ walk(children)
93
+ return out
94
+ }
95
+
96
+ /** A part's laid-out size; zero before it is built or laid out. */
97
+ export function laidOutSize(node: Node2D | undefined): Size2D {
98
+ const bounds = node?.layoutBounds
99
+ return { width: bounds?.width ?? 0, height: bounds?.height ?? 0 }
100
+ }
101
+
102
+ /**
103
+ * A text style as props bound to `read`, so a style tween reaches text built
104
+ * once. Only the keys set at build are bound: an unset family stays unset, so
105
+ * the host's default face still applies.
106
+ */
107
+ export function boundText(read: () => TextStyle): Record<string, unknown> {
108
+ const keys = Object.keys(textOps.styleProps(read())) as (keyof TextStyle)[]
109
+ const props: Record<string, unknown> = {}
110
+ for (const key of keys) props[key] = () => read()[key]
111
+ return props
112
+ }
113
+
114
+ interface GhostSpec {
115
+ readonly captions: () => readonly string[]
116
+ /** How many captions the real axis built. */
117
+ readonly count: number
118
+ readonly text: () => TextStyle
119
+ readonly title: string
120
+ readonly titleText: () => TextStyle
121
+ readonly tickPad: () => number
122
+ readonly titlePad: () => number
56
123
  }
57
124
 
58
125
  /**
59
- * The frame among a chart's children: the last child shaped as
60
- * {@link cartesianFrame} builds it. The chart builds it after the children it
61
- * was given, and a document adds its own later still, so it is found by shape.
126
+ * An invisible column as wide as a vertical axis built from the same captions:
127
+ * the widest caption plus the tick padding, and the rotated title before it. A
128
+ * plain tree, so a command looking for the axis never finds this instead.
62
129
  */
63
- export function cartesianViews(children: readonly ChildView[]): CartesianViews | null {
64
- for (let i = children.length - 1; i >= 0; i--) {
65
- const frame = children[i]
66
- if (!Rect.is(frame)) continue
67
- const [top, bottom] = frame.children
68
- const axis = top?.children[0]
69
- const plot = top?.children[1]
70
- const below = bottom?.children[1]
71
- if (!axis || !VerticalAxis.is(axis) || !plot || !below) continue
72
- return { axis, plot: plot.children, below: below.children }
130
+ export function columnGhost(spec: GhostSpec): Node2D {
131
+ const captions = Array.from({ length: spec.count }, (_, i) => new Text({ ...boundText(spec.text), text: () => spec.captions()[i] ?? "" }))
132
+ const children: Node2D[] = []
133
+ if (spec.title !== "") {
134
+ children.push(new RotatedBox({ angle: -90, width: "hug", height: "hug", children: new Text({ ...boundText(spec.titleText), text: spec.title }) }) as unknown as Node2D)
73
135
  }
74
- return null
136
+ children.push(new Rect({ flow: "vertical", width: "hug", height: "hug", padding: () => ({ right: spec.tickPad() }), children: captions }) as unknown as Node2D)
137
+ return new Rect({ opacity: 0, flow: "horizontal", gap: spec.titlePad, width: "hug", height: 0, children }) as unknown as Node2D
75
138
  }
76
139
 
77
- /** A part's laid-out size, or nothing before it is built or laid out. */
78
- export function laidOutSize(node: Node2D | undefined): Size2D {
79
- const bounds = node?.layoutBounds
80
- return { width: bounds?.width ?? 0, height: bounds?.height ?? 0 }
140
+ /** `fill` with every layer's opacity scaled by `k`. */
141
+ export function fade(fill: Fill, k: number): Fill {
142
+ const layers = (Array.isArray(fill) ? fill : [fill]) as FillResolved[]
143
+ return layers.map((layer) => (typeof layer === "object" && layer !== null ? { ...layer, opacity: ((layer as { opacity?: number }).opacity ?? 1) * k } : layer)) as Fill
81
144
  }
@@ -3,119 +3,89 @@ import {
3
3
  Types,
4
4
  component2d,
5
5
  fillOps,
6
- shadowOps,
7
6
  strokeOps,
8
7
  type Fill,
9
8
  type FillResolved,
10
9
  type NodeOf,
11
- // Named so this component's emitted type reaches `Node2D` through the sdk, not core.
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
- type Node2DProps,
14
- type Shadow,
15
- type ShadowResolved,
12
+ type Node2DProps as _Node2DProps,
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 })
28
24
 
29
- export interface ChartCanvasProps extends Node2DProps {
30
- /** Fill of the plot panel behind the marks. */
31
- fill: Fill
32
- /** Drop shadow of the panel. Default none. */
33
- shadow: Shadow
34
- /** Stroke of the vertical grid lines (one per {@link xMarkers}). */
35
- xGridStroke: Stroke
36
- /** Stroke of the horizontal grid lines (one per {@link yMarkers}). */
37
- yGridStroke: Stroke
38
- /**
39
- * Data-unit x positions to draw a vertical grid line at — one per horizontal
40
- * axis marker, so a line lands under every marker regardless of spacing.
41
- * Positions at (or past) an {@link xExtent} edge coincide with the border and
42
- * are skipped. Pass an empty list for a chart whose horizontal axis is
43
- * categorical and so has no numeric position to rule at.
44
- */
45
- xMarkers: number[]
46
- /** Data-unit y positions to draw a horizontal grid line at. */
47
- yMarkers: number[]
48
- /** `[min, max]` of the horizontal scale — maps {@link xMarkers} into the panel. */
49
- xExtent: Range
50
- /** `[min, max]` of the vertical scale — maps {@link yMarkers} into the panel. */
51
- yExtent: Range
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
52
28
  }
53
29
 
54
- const strokeEntry = (initial: Stroke) =>
55
- 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
+ }
56
34
 
57
35
  /**
58
- * The plot **backdrop** of a cartesian chart: a filled panel with a grid of
59
- * faint lines spanning both directions across it, sized from its box and centred
60
- * on its origin.
61
- *
62
- * The thick frame is a **separate** {@link ChartBorder} node so it can be drawn
63
- * *over* the marks while this panel sits under them. Grid lines are placed from
64
- * the axis marker positions (in data units) mapped into the panel through
65
- * {@link ChartCanvasProps.xExtent}/`yExtent` — so each line lands under its
66
- * marker whether or not the markers are evenly 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.
67
40
  */
68
41
  export const ChartCanvas = component2d({
69
42
  key: "chartCanvas",
70
43
  state: {
71
- fill: Types.custom<Fill, FillResolved[]>(DEFAULT_PLOT_AREA.fill, { resolve: fillOps.resolve, lerp: fillOps.lerp }),
72
- shadow: Types.custom<Shadow, ShadowResolved[]>(DEFAULT_PLOT_AREA.shadow, { resolve: shadowOps.resolve, lerp: shadowOps.lerp }),
73
- xGridStroke: strokeEntry(DEFAULT_X_AXIS.lineStyle),
74
- yGridStroke: strokeEntry(DEFAULT_Y_AXIS.lineStyle),
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(),
75
49
  xMarkers: fresh<number[]>(() => []),
76
50
  yMarkers: fresh<number[]>(() => []),
51
+ /** `[min, max]` of each scale, mapping the markers into the panel. */
77
52
  xExtent: fresh<Range>(() => [0, 1]),
78
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),
79
60
  },
80
- // The grid strokes are named past the conventional slots, so they are named here.
81
- load: (loader, state) =>
82
- 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] }),
83
62
  render: (ctx, state) => {
84
63
  const { width: W, height: H } = ctx.size
85
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))
86
66
 
87
- // Plot panel, centred on the origin (node space is y-up, origin centre).
88
- ctx.draw(new Graphics2D().rect({ x: 0, y: 0, width: W, height: H }).shadow(state.shadow).fill(state.fill))
89
-
90
- const [xMin, xMax] = state.xExtent
91
- const [yMin, yMax] = state.yExtent
92
- // Fraction of a marker across its extent; guard a zero-width extent.
93
- const xFrac = (v: number) => (xMax <= xMin ? 0 : (v - xMin) / (xMax - xMin))
94
- const yFrac = (v: number) => (yMax <= yMin ? 0 : (v - yMin) / (yMax - yMin))
95
- // Skip markers landing on (or outside) the edges — they'd sit under the border.
96
- const interior = (f: number) => f > 1e-6 && f < 1 - 1e-6
97
-
98
- const xStroke = state.xGridStroke[0]
99
- if (xStroke) {
100
- const xGrid = new Graphics2D()
101
- for (const v of state.xMarkers) {
102
- const f = xFrac(v)
103
- if (!interior(f)) continue
104
- 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)
105
74
  }
106
- ctx.draw(xGrid.fill(xStroke.fill))
107
75
  }
108
-
109
- const yStroke = state.yGridStroke[0]
110
- if (yStroke) {
111
- const yGrid = new Graphics2D()
112
- for (const v of state.yMarkers) {
113
- const f = yFrac(v)
114
- if (!interior(f)) continue
115
- 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)
116
82
  }
117
- ctx.draw(yGrid.fill(yStroke.fill))
118
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)
119
89
  },
120
90
  })
121
91
 
@@ -6,10 +6,8 @@ import {
6
6
  Text,
7
7
  Types,
8
8
  component2d,
9
- textOps,
10
9
  type Fill,
11
10
  type NodeOf,
12
- type RectProps,
13
11
  // Named so the row's emitted type reaches the Rect surface it spreads through the sdk, not core.
14
12
  type Alignment as _Alignment,
15
13
  type CornerRadiusResolved as _CornerRadiusResolved,
@@ -29,6 +27,7 @@ import {
29
27
  type Vector2 as _Vector2,
30
28
  } from "@motionscript/sdk"
31
29
 
30
+ import { boundText } from "./cartesian"
32
31
  import { fresh } from "./entries"
33
32
  import { frameEase } from "./motion"
34
33
  import {
@@ -38,54 +37,28 @@ import {
38
37
  type LegendConfig,
39
38
  } from "./shared"
40
39
 
41
- export interface ChartLegendProps extends RectProps {
42
- /**
43
- * One entry per series: legend `label`, its `enabled` default, and — through
44
- * {@link ChartLegendProps.swatches} — the colour its chip is painted. A series
45
- * that starts enabled (the default) is added to the row up front; one that
46
- * starts `false` is hidden until a later `showSeries`.
47
- */
48
- series: ChartSeries[]
49
- /**
50
- * The swatch colour for each series, by index — resolved by the chart, whose
51
- * business it is whether a series' colour comes out of a stroke palette (a
52
- * line) or a fill palette (a bar). Shorter than the series list means the
53
- * missing entries fall back to white.
54
- */
55
- swatches: Fill[]
56
- /** Padding, caption style, gaps, and swatch geometry — see {@link LegendConfig}. */
57
- legend: LegendConfig
58
- }
59
-
60
- /** A row's entries are a `Rect`'s: paint, the flow of its children, its corners. */
61
40
  const row = { ...Paint.state, ...Flow.state, ...Shapes.corners }
62
41
 
63
42
  /**
64
- * One legend entry. It hugs its caption and swatch; `shown` scales that width
65
- * from nothing to the whole of it.
66
- *
67
- * The width is taken from the content on every measure, at any reveal, so a frame
68
- * never depends on whether the entry was once seen fully shown. A collapse needs a
69
- * number to scale, and `"hug"` is not one until something measures it — this is
70
- * the something.
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.
71
47
  */
72
48
  const LegendEntry = component2d({
73
49
  key: "legendEntry",
74
- state: { ...row, shown: Types.number(1) },
50
+ state: { ...row, shown: Types.number(1), along: Types.oneOf(["horizontal", "vertical"] as const) },
75
51
  commands: { ...Paint.commands },
76
52
  measure: (measurer, state, children) => {
77
53
  const size = Flow.measure(measurer, state, children, Paint.inset(state))
78
- 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 }
79
56
  },
80
57
  layout: (box, state, children) => Flow.layout(box, state, children, Paint.inset(state)),
81
58
  render: (ctx, state) => Paint.draw(ctx, state, Shapes.rect(ctx.size, state)),
82
59
  })
83
60
 
84
- /**
85
- * How far each entry is shown, written whole (a document, a `set`) or one
86
- * entry at a time (a command, as `{ [index]: reveal }`), which leaves the others
87
- * as the frame left them — so two series moving at once each move their own.
88
- */
61
+ /** Written whole, or one entry at a time as `{ [index]: reveal }`, which leaves the others as the frame left them so two series can move at once. */
89
62
  function revealOf(value: readonly number[] | Readonly<Record<number, number>>, previous?: number[]): number[] {
90
63
  if (Array.isArray(value)) return value as number[]
91
64
  const next = [...(previous ?? [])]
@@ -94,88 +67,68 @@ function revealOf(value: readonly number[] | Readonly<Record<number, number>>, p
94
67
  }
95
68
 
96
69
  /**
97
- * The legend of a chart: a **horizontal-flow row of entries**, one per series, laid out
98
- * left-to-right with the theme's `itemGap`. Each entry pairs the series label
99
- * with a rounded colour swatch; only the swatch carries the series colour, the
100
- * caption reads in the legend's neutral ink.
101
- *
102
- * `showSeries`/`hideSeries` flip a series on or off — showing one grows its
103
- * entry back in at its stable series position, hiding one shrinks it away. So
104
- * the row only ever shows the currently-enabled series, in series order, and
105
- * closes the gap when one leaves.
106
- *
107
- * This node *is* the row, so the parent places it (centred below the chart) and
108
- * it hugs its entries.
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.
109
73
  */
110
74
  export const ChartLegend = component2d({
111
75
  key: "chartLegend",
112
76
  state: {
113
77
  ...row,
114
78
  series: fresh<ChartSeries[]>(() => []),
79
+ /** Swatch colour by series index, resolved by the chart; a missing entry is white. */
115
80
  swatches: fresh<Fill[]>(() => []),
116
81
  legend: Types.custom<LegendConfig>(DEFAULT_LEGEND),
117
82
  /**
118
- * How far each entry is shown, `0`–`1`. Drives its width, opacity and gap
119
- * together, so `0` is an entry that takes no room and `1` is one at its
120
- * natural size.
121
- *
122
- * This replaces adding and removing the entry from the row: a command is
123
- * asked what it looks like at a time, and "is this node a child" is not a
124
- * value a time can produce. Collapsed-but-present is the same picture reached
125
- * by a route that *is* a function of `t`.
83
+ * How far each entry is shown, `0`-`1`, driving its length, opacity and gap
84
+ * together. Entries are never added or removed: "is this node a child" is not
85
+ * a function of time, while collapsed-but-present is.
126
86
  */
127
87
  reveal: fresh<number[], number[] | Readonly<Record<number, number>>>(() => [], { resolve: revealOf }),
128
88
  },
129
- // This node *is* the row, so the direction is its own answer. It has to be
130
- // stated — a `Rect` given no flow lays its children out `freeform`, which
131
- // stacks every entry on the same spot. The gap and padding are the theme's.
89
+ // The flow must be stated: a `Rect` with none lays children out `freeform`, stacking every entry on one spot.
132
90
  defaults: {
133
91
  flow: "horizontal",
134
92
  width: "hug",
135
93
  height: "hug",
136
- gap: (state) => state.legend.itemGap,
94
+ gap: (state) => (state.flow === "vertical" ? Math.round(state.legend.itemGap * 0.4) : state.legend.itemGap),
137
95
  padding: (state) => state.legend.padding,
96
+ align: (state) => (state.flow === "vertical" ? "centerLeft" : "center"),
138
97
  reveal: (state) => state.series.map((s) => ((s.enabled ?? true) ? 1 : 0)),
139
98
  },
140
- // One entry per series, in series order, every one a child for good: order is
141
- // fixed rather than depending on which siblings happen to be shown, so an entry
142
- // always comes back where it belongs.
99
+ // Every entry stays a child, so one always comes back where it belongs.
143
100
  children: (state) => {
144
- const marker = state.legend.markerStyle
101
+ const marker = () => state.legend.markerStyle
102
+ const along = state.flow === "vertical" ? "vertical" : "horizontal"
145
103
  return state.series.map((series, index) =>
146
104
  LegendEntry({
147
105
  flow: "horizontal",
148
- gap: 12,
106
+ gap: () => Math.round(((state.legend.textStyle.fontSize as number | undefined) ?? 24) * 0.45),
149
107
  align: "center",
150
108
  width: "hug",
151
109
  height: "hug",
152
- // Bound to the reveal, so showing and hiding is a number moving rather
153
- // than a child arriving and leaving. The width follows it in `measure`,
154
- // and the flanking gap closes with the entry rather than snapping.
110
+ along,
111
+ // The length follows the reveal in `measure`, and the flanking gap closes with the entry.
155
112
  opacity: () => state.reveal[index] ?? 0,
156
113
  gapScale: () => state.reveal[index] ?? 0,
157
114
  shown: () => state.reveal[index] ?? 0,
158
115
  children: [
159
- Text({ ...textOps.styleProps(state.legend.textStyle), text: series.label }),
160
116
  Rect({
161
- width: marker.width,
162
- height: marker.height,
163
- cornerRadius: marker.borderRadius,
164
- fill: state.swatches[index] ?? "white",
165
- stroke: marker.stroke,
166
- 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,
167
123
  }),
124
+ Text({ ...boundText(() => state.legend.textStyle), text: series.label || series.field }),
168
125
  ],
169
126
  })
170
127
  )
171
128
  },
172
129
  commands: {
173
130
  ...Paint.commands,
174
- /**
175
- * Grow series `index`'s entry back in at its series-order position, the
176
- * siblings sliding over to make room. A reveal tween rather than an animated
177
- * `addChildAt`: the entry never left, so the whole thing is a function of `t`.
178
- */
131
+ /** Grow series `index`'s entry back in at its position, the siblings sliding over. */
179
132
  showSeries: {
180
133
  args: { index: Types.number(0, { min: 0, step: 1 }) },
181
134
  // An index that is not a series takes its slot and moves nothing.
@@ -183,20 +136,14 @@ export const ChartLegend = component2d({
183
136
  ? timeline.animate(revealFrame(state, timeline.args.index, 1, frameEase(timeline)))
184
137
  : timeline.wait(timeline.duration)),
185
138
  },
186
- /** Shrink series `index`'s entry away, the gap closing behind it — the mirror of `showSeries`. */
139
+ /** The mirror of `showSeries`. */
187
140
  hideSeries: {
188
141
  args: { index: Types.number(0, { min: 0, step: 1 }) },
189
142
  run: (timeline, state) => (timeline.args.index in state.series
190
143
  ? timeline.animate(revealFrame(state, timeline.args.index, 0, frameEase(timeline)))
191
144
  : timeline.wait(timeline.duration)),
192
145
  },
193
- /**
194
- * Dim every entry but series `index` to {@link SPOTLIGHT_DIM} and bring that
195
- * one back to full — the legend half of a chart's `spotlightSeries`. Entries
196
- * stay in place; only their opacity moves, so the row doesn't reflow. Only
197
- * the entries shown when it is built are touched: a hidden series' entry is
198
- * left as `hideSeries` left it.
199
- */
146
+ /** Dim every entry but `index` to {@link SPOTLIGHT_DIM}, in place. A hidden series' entry is left as `hideSeries` left it. */
200
147
  spotlightSeries: {
201
148
  args: { index: Types.number(0, { min: 0, step: 1 }) },
202
149
  run: (timeline, state, children) =>
@@ -212,18 +159,10 @@ export const ChartLegend = component2d({
212
159
  render: (ctx, state) => Paint.draw(ctx, state, Shapes.rect(ctx.size, state)),
213
160
  })
214
161
 
215
- /**
216
- * One entry's reveal ramping to `target` from where the command finds it — so a
217
- * hide that follows a show starts where the show left it.
218
- */
162
+ /** One entry's reveal ramping to `target` from where the command finds it, so a hide after a show starts where the show left it. */
219
163
  function revealFrame(state: { readonly reveal: readonly number[] }, index: number, target: number, ease: (t: number) => number) {
220
164
  const from = state.reveal[index] ?? 0
221
165
  return (t: number) => ({ reveal: { [index]: from + (target - from) * ease(t) } })
222
166
  }
223
167
 
224
- /** Whether series `index` is showing at all on `legend`. */
225
- export function legendShows(legend: { readonly reveal: readonly number[] }, index: number): boolean {
226
- return (legend.reveal[index] ?? 0) > 0
227
- }
228
-
229
168
  export type ChartLegend = NodeOf<typeof ChartLegend>
@@ -1,4 +1,4 @@
1
- import { Types, type Element, type Node2D, type Type } from "@motionscript/sdk"
1
+ import { Types, type Element, type EntryOptions, type Node2D, type Type } from "@motionscript/sdk"
2
2
 
3
3
  /**
4
4
  * An entry made fresh for every node: each node gets its own array or box
@@ -18,3 +18,6 @@ export function fresh<T, In = T>(make: () => T, options?: { readonly resolve?: (
18
18
  export function asParts(nodes: readonly Node2D[]): readonly Element[] {
19
19
  return nodes as unknown as readonly Element[]
20
20
  }
21
+
22
+ /** The argument every per-series command takes: an index into `series`, offered by name. */
23
+ export const SERIES_ARG = Types.custom<number, number, EntryOptions & { readonly min: number; readonly step: number }>(0, { kind: "series", min: 0, step: 1 })