@motionscript/charts 0.1.0-alpha.5 → 0.1.0-alpha.7

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 (146) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +30 -16
  3. package/dist/bar-chart/bar-chart.d.ts +133 -242
  4. package/dist/bar-chart/bar-chart.d.ts.map +1 -1
  5. package/dist/bar-chart/bar-chart.js +378 -569
  6. package/dist/bar-chart/bar-chart.js.map +1 -1
  7. package/dist/bar-chart/category-axis.d.ts +35 -41
  8. package/dist/bar-chart/category-axis.d.ts.map +1 -1
  9. package/dist/bar-chart/category-axis.js +93 -115
  10. package/dist/bar-chart/category-axis.js.map +1 -1
  11. package/dist/bar-chart/chart-bars.d.ts +50 -70
  12. package/dist/bar-chart/chart-bars.d.ts.map +1 -1
  13. package/dist/bar-chart/chart-bars.js +156 -210
  14. package/dist/bar-chart/chart-bars.js.map +1 -1
  15. package/dist/bar-chart/index.d.ts +2 -2
  16. package/dist/bar-chart/index.d.ts.map +1 -1
  17. package/dist/bar-chart/index.js +1 -1
  18. package/dist/bar-chart/index.js.map +1 -1
  19. package/dist/bar-chart/shared.d.ts +3 -3
  20. package/dist/bar-chart/shared.d.ts.map +1 -1
  21. package/dist/bar-chart/shared.js +3 -3
  22. package/dist/bar-chart/shared.js.map +1 -1
  23. package/dist/browser/chunks/chunk-XROBIRQK.js +2 -0
  24. package/dist/browser/chunks/chunk-XROBIRQK.js.map +7 -0
  25. package/dist/browser/index.js +1 -1
  26. package/dist/browser/index.js.map +3 -3
  27. package/dist/browser/kit.js +1 -1
  28. package/dist/browser/manifest.json +1 -1
  29. package/dist/engine.d.ts +8 -6
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +8 -6
  32. package/dist/engine.js.map +1 -1
  33. package/dist/kit/cartesian.d.ts +44 -0
  34. package/dist/kit/cartesian.d.ts.map +1 -0
  35. package/dist/kit/cartesian.js +59 -0
  36. package/dist/kit/cartesian.js.map +1 -0
  37. package/dist/kit/chart-border.d.ts +10 -11
  38. package/dist/kit/chart-border.d.ts.map +1 -1
  39. package/dist/kit/chart-border.js +15 -32
  40. package/dist/kit/chart-border.js.map +1 -1
  41. package/dist/kit/chart-canvas.d.ts +32 -21
  42. package/dist/kit/chart-canvas.d.ts.map +1 -1
  43. package/dist/kit/chart-canvas.js +33 -90
  44. package/dist/kit/chart-canvas.js.map +1 -1
  45. package/dist/kit/chart-legend.d.ts +123 -64
  46. package/dist/kit/chart-legend.d.ts.map +1 -1
  47. package/dist/kit/chart-legend.js +129 -154
  48. package/dist/kit/chart-legend.js.map +1 -1
  49. package/dist/kit/entries.d.ts +17 -0
  50. package/dist/kit/entries.d.ts.map +1 -0
  51. package/dist/kit/entries.js +19 -0
  52. package/dist/kit/entries.js.map +1 -0
  53. package/dist/kit/motion.d.ts +48 -0
  54. package/dist/kit/motion.d.ts.map +1 -0
  55. package/dist/kit/motion.js +63 -0
  56. package/dist/kit/motion.js.map +1 -0
  57. package/dist/kit/paints.d.ts +10 -0
  58. package/dist/kit/paints.d.ts.map +1 -0
  59. package/dist/kit/paints.js +12 -0
  60. package/dist/kit/paints.js.map +1 -0
  61. package/dist/kit/shared.d.ts +31 -3
  62. package/dist/kit/shared.d.ts.map +1 -1
  63. package/dist/kit/shared.js +42 -3
  64. package/dist/kit/shared.js.map +1 -1
  65. package/dist/kit/vertical-axis.d.ts +48 -61
  66. package/dist/kit/vertical-axis.d.ts.map +1 -1
  67. package/dist/kit/vertical-axis.js +97 -132
  68. package/dist/kit/vertical-axis.js.map +1 -1
  69. package/dist/line-chart/chart-line.d.ts +60 -77
  70. package/dist/line-chart/chart-line.d.ts.map +1 -1
  71. package/dist/line-chart/chart-line.js +100 -121
  72. package/dist/line-chart/chart-line.js.map +1 -1
  73. package/dist/line-chart/chart-region.d.ts +32 -33
  74. package/dist/line-chart/chart-region.d.ts.map +1 -1
  75. package/dist/line-chart/chart-region.js +31 -65
  76. package/dist/line-chart/chart-region.js.map +1 -1
  77. package/dist/line-chart/horizontal-axis.d.ts +58 -64
  78. package/dist/line-chart/horizontal-axis.d.ts.map +1 -1
  79. package/dist/line-chart/horizontal-axis.js +94 -123
  80. package/dist/line-chart/horizontal-axis.js.map +1 -1
  81. package/dist/line-chart/index.d.ts +2 -2
  82. package/dist/line-chart/index.d.ts.map +1 -1
  83. package/dist/line-chart/index.js +1 -1
  84. package/dist/line-chart/index.js.map +1 -1
  85. package/dist/line-chart/line-chart.d.ts +156 -319
  86. package/dist/line-chart/line-chart.d.ts.map +1 -1
  87. package/dist/line-chart/line-chart.js +403 -663
  88. package/dist/line-chart/line-chart.js.map +1 -1
  89. package/dist/line-chart/shared.d.ts +3 -3
  90. package/dist/line-chart/shared.d.ts.map +1 -1
  91. package/dist/line-chart/shared.js +3 -3
  92. package/dist/line-chart/shared.js.map +1 -1
  93. package/dist/nodes.d.ts +6 -9
  94. package/dist/nodes.d.ts.map +1 -1
  95. package/dist/nodes.js +7 -9
  96. package/dist/nodes.js.map +1 -1
  97. package/dist/pie-chart/index.d.ts +2 -2
  98. package/dist/pie-chart/index.d.ts.map +1 -1
  99. package/dist/pie-chart/index.js +1 -1
  100. package/dist/pie-chart/index.js.map +1 -1
  101. package/dist/pie-chart/pie-chart.d.ts +108 -132
  102. package/dist/pie-chart/pie-chart.d.ts.map +1 -1
  103. package/dist/pie-chart/pie-chart.js +200 -271
  104. package/dist/pie-chart/pie-chart.js.map +1 -1
  105. package/dist/pie-chart/pie-legend.d.ts +84 -31
  106. package/dist/pie-chart/pie-legend.d.ts.map +1 -1
  107. package/dist/pie-chart/pie-legend.js +93 -107
  108. package/dist/pie-chart/pie-legend.js.map +1 -1
  109. package/dist/pie-chart/pie-ring.d.ts +160 -131
  110. package/dist/pie-chart/pie-ring.d.ts.map +1 -1
  111. package/dist/pie-chart/pie-ring.js +333 -391
  112. package/dist/pie-chart/pie-ring.js.map +1 -1
  113. package/dist/pie-chart/shared.d.ts +2 -2
  114. package/dist/pie-chart/shared.d.ts.map +1 -1
  115. package/dist/pie-chart/shared.js +2 -2
  116. package/dist/pie-chart/shared.js.map +1 -1
  117. package/package.json +4 -4
  118. package/src/bar-chart/bar-chart.ts +415 -701
  119. package/src/bar-chart/category-axis.ts +95 -110
  120. package/src/bar-chart/chart-bars.ts +178 -200
  121. package/src/bar-chart/index.ts +2 -2
  122. package/src/bar-chart/shared.ts +3 -3
  123. package/src/engine.ts +8 -6
  124. package/src/kit/cartesian.ts +81 -0
  125. package/src/kit/chart-border.ts +22 -33
  126. package/src/kit/chart-canvas.ts +44 -79
  127. package/src/kit/chart-legend.ts +173 -189
  128. package/src/kit/entries.ts +20 -0
  129. package/src/kit/motion.ts +90 -0
  130. package/src/kit/paints.ts +15 -0
  131. package/src/kit/shared.ts +52 -3
  132. package/src/kit/vertical-axis.ts +110 -140
  133. package/src/line-chart/chart-line.ts +102 -154
  134. package/src/line-chart/chart-region.ts +35 -80
  135. package/src/line-chart/horizontal-axis.ts +101 -152
  136. package/src/line-chart/index.ts +2 -2
  137. package/src/line-chart/line-chart.ts +449 -825
  138. package/src/line-chart/shared.ts +3 -3
  139. package/src/nodes.ts +8 -10
  140. package/src/pie-chart/index.ts +2 -2
  141. package/src/pie-chart/pie-chart.ts +221 -311
  142. package/src/pie-chart/pie-legend.ts +124 -114
  143. package/src/pie-chart/pie-ring.ts +386 -473
  144. package/src/pie-chart/shared.ts +2 -2
  145. package/dist/browser/chunks/chunk-5ZPBV6BU.js +0 -2
  146. package/dist/browser/chunks/chunk-5ZPBV6BU.js.map +0 -7
@@ -1,18 +1,18 @@
1
1
  import {
2
- command,
3
- Node2D,
4
2
  Rect,
5
3
  RotatedBox,
6
4
  Text,
7
- easeOut,
8
- property,
5
+ Types,
6
+ component2d,
9
7
  insetsOps,
10
8
  textOps,
11
- type CommandArgs,
12
- type NodeConfig,
9
+ type NodeOf,
10
+ type Node2D,
13
11
  type Node2DProps,
14
12
  } from "@motionscript/sdk"
15
13
 
14
+ import { asParts, fresh } from "./entries"
15
+ import { armed, arrival, arrivalOf, arrivalsEntry, beats, frameEase, type Arrival } from "./motion"
16
16
  import {
17
17
  DEFAULT_Y_AXIS,
18
18
  enterBudget,
@@ -20,7 +20,6 @@ import {
20
20
  type AxisConfig,
21
21
  type Range,
22
22
  } from "./shared"
23
- import { at, compose, type Seekable } from "@motionscript/sdk/component"
24
23
 
25
24
  export interface VerticalAxisProps extends Node2DProps {
26
25
  /** `[min, max]` of the vertical scale the markers are positioned within. */
@@ -31,6 +30,12 @@ export interface VerticalAxisProps extends Node2DProps {
31
30
  * the ends.
32
31
  */
33
32
  markers: number[]
33
+ /**
34
+ * How each marker is captioned, one per entry of `markers` and in its order.
35
+ * Empty (the default), or short, captions a marker with its bare numeric
36
+ * reading; a chart with a number format on this axis writes them through it.
37
+ */
38
+ captions: string[]
34
39
  /** Marker/caption text style, grid stroke, and gutters — see {@link AxisConfig}. */
35
40
  axis: AxisConfig
36
41
  /**
@@ -44,9 +49,15 @@ export interface VerticalAxisProps extends Node2DProps {
44
49
  /** How far a marker starts to the right of its slot when it enters, in px. */
45
50
  const SLIDE = 24
46
51
 
47
- /** How a tick value is captioned — see {@link VerticalAxis.labelFor}. */
52
+ /** How a tick value is captioned — see {@link VerticalAxisProps.captions}. */
48
53
  export type MarkerLabel = (value: number) => string
49
54
 
55
+ /** What a marker's caption reads: the one listed for its value, else its bare reading. */
56
+ export function markerCaption(s: { readonly markers: readonly number[]; readonly captions: readonly string[] }, value: number): string {
57
+ const index = s.markers.indexOf(value)
58
+ return (index >= 0 ? s.captions[index] : undefined) ?? formatMarker(value)
59
+ }
60
+
50
61
  /**
51
62
  * The numeric vertical axis shared by the cartesian charts: a **column of
52
63
  * {@link Text} nodes**, one per marker value, running top-to-bottom (highest
@@ -60,73 +71,54 @@ export type MarkerLabel = (value: number) => string
60
71
  * tweens `range`/`markers` live. Only the marker **count** is fixed at
61
72
  * construction.
62
73
  *
63
- * {@link enter} staggers them in **bottom-to-top**: each marker starts pushed a
74
+ * `enter` staggers them in **bottom-to-top**: each marker starts pushed a
64
75
  * little to the right and transparent, then fades and slides left into place.
65
76
  * The resting state is the landed one, so a chart that is never told to enter
66
77
  * still reads as a finished chart.
67
78
  */
68
- export class VerticalAxis extends Node2D<VerticalAxisProps> {
69
- @property({ default: () => [0, 1] as Range }) declare range: Range
70
- @property({ default: () => [] as number[] }) declare markers: number[]
71
- @property({ default: DEFAULT_Y_AXIS }) declare axis: AxisConfig
72
- @property({ default: "" }) declare label: string
73
-
74
- /**
75
- * How a tick value is captioned. Defaults to the bare numeric reading; a
76
- * chart with a number format on this axis replaces it (see `number-format`).
77
- *
78
- * A plain field rather than a `@property`, for the reason the horizontal
79
- * axis's twin spells out: it is a policy, not a value, and there is nothing
80
- * to tween between two formatters.
81
- */
82
- labelFor: MarkerLabel = formatMarker
83
-
84
- /** One text node per marker, ordered top → bottom, for the staggered enter. */
85
- private markerNodes: Text[] = []
86
- /** The caption node, or null when `label` is empty. Entered after the markers. */
87
- private labelNode: Text | null = null
88
-
89
- /** The live tick values, top → bottom (highest first) — re-read every frame. */
90
- private get sortedMarkers(): number[] {
91
- return [...this.markers].sort((a, b) => b - a)
92
- }
93
-
94
- /**
95
- * Tick `i`'s value, or `undefined` once the live `markers` list is shorter
96
- * than the column of text nodes built for it — a slot with no value renders as
97
- * empty text rather than repeating its neighbour.
98
- */
99
- private valueAt(index: number): number | undefined {
100
- return this.sortedMarkers[index]
101
- }
102
-
103
- /** Tick `i`'s fraction across the live `range`, 0 at the bottom edge. */
104
- private fracAt(index: number): number {
105
- const v = this.valueAt(index)
106
- if (v === undefined) return 0
107
- const [min, max] = this.range
108
- return max <= min ? 0 : (v - min) / (max - min)
109
- }
110
-
111
- constructor(props?: NodeConfig<VerticalAxis, VerticalAxisProps>) {
112
- super(props)
113
-
114
- // Composed from plain props (range/markers/label) — no context needed, so
115
- // it's built here. super() applied the props, so the reads below resolve.
116
- const tickPad = insetsOps.resolve(this.axis.tickPadding).right
117
- const titlePad = insetsOps.resolve(this.axis.titlePadding).right
79
+ export const VerticalAxis = component2d({
80
+ key: "verticalAxis",
81
+ state: {
82
+ range: fresh<Range>(() => [0, 1]),
83
+ markers: fresh<number[]>(() => []),
84
+ captions: fresh<string[]>(() => []),
85
+ axis: Types.custom<AxisConfig>(DEFAULT_Y_AXIS),
86
+ label: "",
87
+ arrivals: arrivalsEntry(),
88
+ },
89
+ // A plain node filled its parent; one made of its markers would hug them.
90
+ defaults: { width: "fill", height: "fill" },
91
+ children: (state) => {
92
+ // Composed from plain entries (range/markers/label), so it's built once here.
93
+ const tickPad = insetsOps.resolve(state.axis.tickPadding).right
94
+ const titlePad = insetsOps.resolve(state.axis.titlePadding).right
95
+
96
+ const row: Node2D[] = []
97
+ // The row's parent is the axis: its box is what the markers are placed in.
98
+ const box = () => (row[0]?.parent as Node2D | null | undefined)?.layoutBounds ?? { width: 0, height: 0 }
99
+ /** The live tick values, top → bottom (highest first) — re-read every frame. */
100
+ const sorted = () => [...state.markers].sort((a, b) => b - a)
101
+ /** Tick `i`'s fraction across the live `range`, 0 at the bottom edge. */
102
+ const fracAt = (value: number | undefined) => {
103
+ if (value === undefined) return 0
104
+ const [min, max] = state.range
105
+ return max <= min ? 0 : (value - min) / (max - min)
106
+ }
118
107
 
119
- this.markerNodes = this.markers.map((_, i) => {
120
- const marker: Text = new Text({
121
- ...textOps.styleProps(this.axis.labelStyle),
108
+ // One text node per marker, ordered top → bottom. A slot with no value (the
109
+ // live list is shorter than the column built for it) renders as empty text
110
+ // rather than repeating its neighbour.
111
+ const markers = state.markers.map((_, i) => {
112
+ const marker: Node2D = new Text({
113
+ ...textOps.styleProps(state.axis.labelStyle),
122
114
  text: () => {
123
- const v = this.valueAt(i)
124
- return v === undefined ? "" : this.labelFor(v)
115
+ const value = sorted()[i]
116
+ return value === undefined ? "" : markerCaption(state, value)
125
117
  },
126
118
  y: () => {
127
119
  // Node2D space is y-up, so the fraction runs from the bottom.
128
- const height = this.layoutBounds.height
129
- const centred = (this.fracAt(i) - 0.5) * height
120
+ const height = box().height
121
+ const centred = (fracAt(sorted()[i]) - 0.5) * height
130
122
  // Held inside the axis's top edge — the backstop for a chart whose
131
123
  // headroom is smaller than its marker text is tall, which a theme with
132
124
  // much larger markers can arrange. Only the top, because what lies
@@ -134,91 +126,69 @@ export class VerticalAxis extends Node2D<VerticalAxisProps> {
134
126
  const limit = height / 2 - marker.layoutBounds.height / 2
135
127
  return Math.min(centred, limit)
136
128
  },
129
+ opacity: () => arrivalOf(state.arrivals, i).opacity,
130
+ x: () => arrivalOf(state.arrivals, i).shift,
137
131
  })
138
132
  return marker
139
133
  })
140
134
 
141
135
  const children: Node2D[] = []
142
- if (this.label !== "") {
143
- this.labelNode = new Text({
144
- ...textOps.styleProps(this.axis.titleStyle),
145
- text: this.label,
146
- })
136
+ if (state.label !== "") {
147
137
  // Node2D space is y-up and rotation is clockwise, so -90° reads bottom-to-top.
148
138
  children.push(
149
139
  new RotatedBox({
150
140
  angle: -90,
151
141
  width: "hug",
152
142
  height: "hug",
153
- children: this.labelNode,
143
+ children: new Text({
144
+ ...textOps.styleProps(state.axis.titleStyle),
145
+ text: state.label,
146
+ opacity: () => arrivalOf(state.arrivals, "label").opacity,
147
+ x: () => arrivalOf(state.arrivals, "label").shift,
148
+ }),
154
149
  })
155
150
  )
156
151
  }
157
- children.push(
158
- new Rect({
159
- height: "fill",
160
- width: "hug",
161
- padding: { right: tickPad },
162
- children: this.markerNodes,
163
- })
164
- )
165
-
166
- this.add(new Rect({ flow: "horizontal", gap: titlePad, children }))
167
- }
168
-
169
- /**
170
- * Primes every marker (and the caption) at its pre-entrance state: shifted
171
- * right and transparent. Idempotent, and separate from {@link enter} so the
172
- * chart can arm its whole composition before the first frame is drawn.
173
- */
174
- arm(slide = SLIDE): void {
175
- for (const marker of this.markerNodes) marker.set({ opacity: 0, x: slide })
176
- this.labelNode?.set({ opacity: 0, x: slide })
177
- }
178
-
179
- /**
180
- * Stagger the markers in bottom to top, then slide the caption in from the
181
- * right after them.
182
- *
183
- * A {@link Seekable} rather than a generator — see the note on the horizontal
184
- * axis's `enter`. The offsets were always static; expressing them as `wait`
185
- * inside a generator is what made them reachable only by running it.
186
- *
187
- * `args.duration` is the **whole** entrance, not one marker's slide: the
188
- * stagger and the caption are carved out of it rather than added on top. See
189
- * {@link enterBudget} for why that distinction matters here.
190
- *
191
- * `args.data.stagger` is seconds between successive markers (derived when
192
- * omitted); `args.data.slide` is the starting rightward offset in px.
193
- */
194
- @command()
195
- enter(args: CommandArgs<{ stagger?: number; slide?: number }> & { duration: number }): Seekable {
196
- const { stagger, slide = SLIDE } = args.data ?? {}
197
- const easing = args.easing ?? easeOut()
198
- const nodes = this.markerNodes
199
- if (nodes.length === 0) return compose([])
200
- const { step, per, caption } = enterBudget(
201
- args.duration,
202
- nodes.length,
203
- this.labelNode !== null,
204
- stagger
205
- )
206
-
207
- this.arm(slide)
208
-
209
- // Bottom marker leads: markerNodes is top → bottom, so walk it in reverse.
210
- const markers = nodes.map((node, i) => {
211
- const order = nodes.length - 1 - i // bottom-most = 0
212
- return at(order * step, node.to({ data: { opacity: 1, x: 0 }, duration: per, easing }))
213
- })
214
- const lastMarkerEnds = (nodes.length - 1) * step + per
215
-
216
- // Once every marker has landed, the caption slides in from the right.
217
- return compose([
218
- ...markers,
219
- ...(this.labelNode
220
- ? [at(lastMarkerEnds, this.labelNode.to({ data: { opacity: 1, x: 0 }, duration: caption, easing }))]
221
- : []),
222
- ])
223
- }
224
- }
152
+ children.push(new Rect({ height: "fill", width: "hug", padding: { right: tickPad }, children: markers }))
153
+ row.push(new Rect({ flow: "horizontal", gap: titlePad, children }))
154
+ return asParts(row)
155
+ },
156
+ commands: {
157
+ /**
158
+ * Stagger the markers in bottom to top, then slide the caption in from the
159
+ * right after them. Its first frame arms them all: shifted right by `slide`
160
+ * and transparent.
161
+ *
162
+ * The timeline's duration is the **whole** entrance, not one marker's slide:
163
+ * the stagger and the caption are carved out of it rather than added on top.
164
+ * See {@link enterBudget} for why that distinction matters here.
165
+ *
166
+ * `stagger` is seconds between successive markers (derived when omitted);
167
+ * `slide` is the starting rightward offset in px.
168
+ */
169
+ enter: {
170
+ args: {
171
+ stagger: Types.custom<number | undefined>(undefined),
172
+ slide: Types.number(SLIDE),
173
+ },
174
+ run: (timeline, state) => {
175
+ const { stagger, slide } = timeline.args
176
+ const count = state.markers.length
177
+ const captioned = state.label !== ""
178
+ const from: Arrival = { opacity: 0, shift: slide }
179
+ const keys = [...state.markers.keys(), ...(captioned ? ["label"] : [])]
180
+ const arm = timeline.animate(() => ({ arrivals: armed(keys, from) }))
181
+ if (count === 0) return arm
182
+ const { step, per, caption } = enterBudget(timeline.duration, count, captioned, stagger)
183
+ const ease = frameEase(timeline)
184
+ // Bottom marker leads: the column is top → bottom, so walk it in reverse.
185
+ const markers = state.markers.map((_, i) => ({ at: (count - 1 - i) * step, duration: per, piece: arrival(timeline, i, from, ease) }))
186
+ // Once every marker has landed, the caption slides in from the right.
187
+ const label = captioned ? [{ at: (count - 1) * step + per, duration: caption, piece: arrival(timeline, "label", from, ease) }] : []
188
+ return timeline.together([arm, beats(timeline, [...markers, ...label])])
189
+ },
190
+ },
191
+ },
192
+ })
193
+
194
+ export type VerticalAxis = NodeOf<typeof VerticalAxis>
@@ -1,21 +1,19 @@
1
1
  import {
2
- type AssetScope,
3
2
  Graphics2D,
4
- Node2D,
5
- command,
6
- easeOut,
3
+ Types,
4
+ component2d,
7
5
  strokeOps,
8
- property,
9
- type Command,
10
- type CommandArgs,
11
- type EasingFunction,
12
- type Node2DProps,
13
- type RenderContext2D,
6
+ type NodeOf,
7
+ // Named so this component's emitted type reaches `Node2D` through the sdk, not core.
8
+ type Node2D as _Node2D,
14
9
  type Stroke,
15
10
  type StrokeResolved,
16
11
  type Vector2,
17
12
  } from "@motionscript/sdk"
18
13
 
14
+ import { fresh } from "../kit/entries"
15
+ import { frameEase } from "../kit/motion"
16
+ import { loadPaints } from "../kit/paints"
19
17
  import { SPOTLIGHT_DIM } from "../kit/shared"
20
18
  import {
21
19
  EMPTY_REGION_BOX,
@@ -23,40 +21,42 @@ import {
23
21
  hasRegion,
24
22
  type RegionBox,
25
23
  } from "./shared"
26
- import { declarePaints } from "@motionscript/sdk/component"
27
24
 
28
- export interface ChartLineProps extends Node2DProps {
29
- /**
30
- * The series polyline, already mapped into this node's local pixel space
31
- * (origin at centre, y-up) by the parent {@link LineChart}. Ordered along x.
32
- */
33
- points: Vector2[]
34
- /** Line colour + weight (+ optional dash/cap/join) — the per-series stroke. */
35
- stroke: Stroke
36
- /**
37
- * Reveal of the line in `[0, 1]`: 0 hides it, 1 traces it fully from its left
38
- * end to its right.
39
- *
40
- * Defaults to **1**, unlike the standalone chart this was ported from: in an
41
- * editor a node's resting state is what the paused editor canvas paints, so a
42
- * chart that only appeared once a command had run would edit blind. {@link
43
- * enter} drives it from 0 regardless of where it rests, so the animation is
44
- * unchanged.
45
- */
46
- progress: number
47
- /**
48
- * Whether this line is drawn at all. `false` hides it outright — the switch
49
- * the chart flips alongside its legend's per-series toggle.
50
- */
51
- enabled: boolean
52
- /**
53
- * A slice of the plot to spotlight, in this node's own pixel space. When set
54
- * to a real box, the stretches of the line *outside* it drop to
55
- * {@link ChartLineProps.dimOpacity} while the stretches inside stay full.
56
- */
57
- spotlight: RegionBox
58
- /** Opacity the out-of-region stretches fade to. */
59
- dimOpacity: number
25
+ /**
26
+ * The prefix of `pts` revealed at fraction `p ∈ [0, 1]`, measuring the reveal
27
+ * by *cumulative arc length* so the line traces at a steady pace regardless of
28
+ * uneven point spacing. Returns the whole-point prefix plus one interpolated
29
+ * point partway along the active segment.
30
+ */
31
+ function tracePoints(pts: Vector2[], p: number): Vector2[] {
32
+ if (p >= 1 || pts.length < 2) return pts
33
+ if (p <= 0) return []
34
+
35
+ const segLen: number[] = []
36
+ let total = 0
37
+ for (let i = 1; i < pts.length; i++) {
38
+ const d = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y)
39
+ segLen.push(d)
40
+ total += d
41
+ }
42
+ if (total === 0) return pts
43
+
44
+ let target = total * p
45
+ const out: Vector2[] = [pts[0]]
46
+ for (let i = 0; i < segLen.length; i++) {
47
+ if (target >= segLen[i]) {
48
+ out.push(pts[i + 1])
49
+ target -= segLen[i]
50
+ } else {
51
+ const t = segLen[i] === 0 ? 0 : target / segLen[i]
52
+ out.push({
53
+ x: pts[i].x + (pts[i + 1].x - pts[i].x) * t,
54
+ y: pts[i].y + (pts[i + 1].y - pts[i].y) * t,
55
+ })
56
+ break
57
+ }
58
+ }
59
+ return out
60
60
  }
61
61
 
62
62
  /**
@@ -64,41 +64,69 @@ export interface ChartLineProps extends Node2DProps {
64
64
  * through pre-mapped pixel `points`, drawn as one {@link Graphics2D} command list.
65
65
  * Sized and positioned by the parent, which shares the plot's coordinate frame.
66
66
  *
67
- * Two signals drive it: {@link ChartLineProps.progress} traces the line in
68
- * (animated by {@link enter}) and `enabled` shows or hides it wholesale.
67
+ * Two signals drive it: `progress` traces the line in
68
+ * (animated by `enter`) and `enabled` shows or hides it wholesale.
69
69
  */
70
- export class ChartLine extends Node2D<ChartLineProps> {
71
- @property({ default: () => [] as Vector2[] }) declare points: Vector2[]
72
- @property({ default: [], mapper: strokeOps.resolve, tween: strokeOps.lerp })
73
- declare stroke: Stroke
74
- @property({ default: 1 }) declare progress: number
75
- @property({ default: true }) declare enabled: boolean
76
- @property({ default: () => EMPTY_REGION_BOX }) declare spotlight: RegionBox
77
- @property({ default: SPOTLIGHT_DIM }) declare dimOpacity: number
78
-
79
- /**
80
- * This node extends `Node2D` rather than `ShapeNode`, so nothing declares the
81
- * paint props it carries — see {@link declarePaints}.
82
- */
83
- override declareAssets(assets: AssetScope): void {
84
- super.declareAssets(assets)
85
- declarePaints(this, assets)
86
- }
87
-
88
- protected renderSelf(ctx: RenderContext2D): void {
89
- if (!this.enabled) return
90
- const pts = this.points
70
+ export const ChartLine = component2d({
71
+ key: "chartLine",
72
+ state: {
73
+ /** The series polyline, already mapped into this node's local pixel space (origin at centre, y-up) by the parent {@link LineChart}. Ordered along x. */
74
+ points: fresh<Vector2[]>(() => []),
75
+ /** Line colour + weight (+ optional dash/cap/join) — the per-series stroke. */
76
+ stroke: Types.custom<Stroke, StrokeResolved[]>([], { resolve: strokeOps.resolve, lerp: strokeOps.lerp }),
77
+ /**
78
+ * Reveal of the line in `[0, 1]`: 0 hides it, 1 traces it fully from its left
79
+ * end to its right. Defaults to 1, so the paused editor canvas paints the line;
80
+ * `enter` drives it from 0 regardless of where it rests.
81
+ */
82
+ progress: 1,
83
+ /** Whether this line is drawn at all: the switch the chart flips alongside its legend's per-series toggle. */
84
+ enabled: true,
85
+ /** A slice of the plot to spotlight, in this node's own pixel space: outside it the line drops to `dimOpacity`. */
86
+ spotlight: fresh<RegionBox>(() => EMPTY_REGION_BOX),
87
+ /** Opacity the out-of-region stretches fade to. */
88
+ dimOpacity: SPOTLIGHT_DIM,
89
+ },
90
+ load: (loader, state) => loadPaints(loader, { strokes: [state.stroke] }),
91
+ commands: {
92
+ /**
93
+ * Trace the line from `from` to `to` over the command — by default in from
94
+ * its left end to its right. `showSeries` and `hideSeries` are the same
95
+ * motion in opposite directions, so both are this with their ends stated.
96
+ *
97
+ * `enabled` rides along in the same value rather than being set before or
98
+ * after the command: a line is drawable for every `t` the trace covers and
99
+ * undrawn outside it, which is what makes arriving at a time backwards show
100
+ * what arriving forwards does.
101
+ */
102
+ enter: {
103
+ args: { from: Types.number(0, { min: 0, max: 1 }), to: Types.number(1, { min: 0, max: 1 }) },
104
+ run: (timeline) => {
105
+ const { from, to } = timeline.args
106
+ const ease = frameEase(timeline)
107
+ return timeline.animate((t) => {
108
+ const progress = from + (to - from) * ease(t)
109
+ // Hidden only once it has fully retraced, so the last frame of a hide is
110
+ // the empty plot rather than a line that vanished a frame early.
111
+ return { progress, enabled: progress > 0 }
112
+ })
113
+ },
114
+ },
115
+ },
116
+ render: (ctx, state) => {
117
+ if (!state.enabled) return
118
+ const pts = state.points
91
119
  if (pts.length < 2) return
92
120
 
93
- const p = Math.max(0, Math.min(1, this.progress))
121
+ const p = Math.max(0, Math.min(1, state.progress))
94
122
  if (p <= 0) return
95
123
 
96
- const drawn = this.tracePoints(pts, p)
124
+ const drawn = tracePoints(pts, p)
97
125
  if (drawn.length < 2) return
98
- const stroke = this.stroke as StrokeResolved[]
126
+ const stroke = state.stroke
99
127
  const radius = (stroke[0]?.weight ?? 0) * 2 // round the joins
100
128
 
101
- const box = this.spotlight
129
+ const box = state.spotlight
102
130
  if (!hasRegion(box)) {
103
131
  ctx.draw(new Graphics2D().line({ points: drawn, radius }).stroke(stroke))
104
132
  return
@@ -113,7 +141,7 @@ export class ChartLine extends Node2D<ChartLineProps> {
113
141
  new Graphics2D()
114
142
  .line({ points: drawn, radius })
115
143
  .stroke(stroke)
116
- .opacity(Math.max(0, Math.min(1, this.dimOpacity)))
144
+ .opacity(Math.max(0, Math.min(1, state.dimOpacity)))
117
145
  )
118
146
 
119
147
  // One `draw` per run: shapes chained before a `stroke()` are combined into a
@@ -122,87 +150,7 @@ export class ChartLine extends Node2D<ChartLineProps> {
122
150
  for (const run of clipPolylineToBox(drawn, box)) {
123
151
  ctx.draw(new Graphics2D().line({ points: run, radius }).stroke(stroke))
124
152
  }
125
- }
126
-
127
- /**
128
- * The prefix of `pts` revealed at fraction `p ∈ [0, 1]`, measuring the reveal
129
- * by *cumulative arc length* so the line traces at a steady pace regardless of
130
- * uneven point spacing. Returns the whole-point prefix plus one interpolated
131
- * point partway along the active segment.
132
- */
133
- private tracePoints(pts: Vector2[], p: number): Vector2[] {
134
- if (p >= 1 || pts.length < 2) return pts
135
- if (p <= 0) return []
136
-
137
- const segLen: number[] = []
138
- let total = 0
139
- for (let i = 1; i < pts.length; i++) {
140
- const d = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y)
141
- segLen.push(d)
142
- total += d
143
- }
144
- if (total === 0) return pts
145
-
146
- let target = total * p
147
- const out: Vector2[] = [pts[0]]
148
- for (let i = 0; i < segLen.length; i++) {
149
- if (target >= segLen[i]) {
150
- out.push(pts[i + 1])
151
- target -= segLen[i]
152
- } else {
153
- const t = segLen[i] === 0 ? 0 : target / segLen[i]
154
- out.push({
155
- x: pts[i].x + (pts[i + 1].x - pts[i].x) * t,
156
- y: pts[i].y + (pts[i + 1].y - pts[i].y) * t,
157
- })
158
- break
159
- }
160
- }
161
- return out
162
- }
153
+ },
154
+ })
163
155
 
164
- /**
165
- * Trace the line in from its left end to its right over `duration` seconds.
166
- * Drives `progress` `0 → 1`.
167
- *
168
- * A `Command` rather than a generator, so the trace can be *evaluated* at a
169
- * time instead of run to one — which is what lets a scene containing this
170
- * chart be scrubbed in constant time. It no longer needs to prime `progress`
171
- * to 0 first: the value at `t` is a function of `t`, so a repeat call re-traces
172
- * by definition rather than by having reset something.
173
- */
174
- @command()
175
- enter(args: CommandArgs<Record<string, never>> & { duration: number }): Command<ChartLineProps> {
176
- return this.enterFrom(0, args.duration, args.easing ?? easeOut())
177
- }
178
-
179
- /**
180
- * The trace, with both ends stated: `progress` runs `from` → `to`.
181
- *
182
- * What {@link LineChart.showSeries} and `hideSeries` are both made of — they
183
- * are the same motion in opposite directions, and expressing them as one thing
184
- * with endpoints is what stops the pair drifting apart.
185
- *
186
- * `enabled` rides along in the same value rather than being set before or after
187
- * the command. A line is drawable for every `t` the trace covers and undrawn
188
- * outside it, which is what makes arriving at a time backwards show what
189
- * arriving forwards does — a flag flipped by a side effect would not.
190
- */
191
- enterFrom(
192
- from: number,
193
- duration: number,
194
- easing: EasingFunction = easeOut(),
195
- to = 1
196
- ): Command<ChartLineProps> {
197
- return this.command<ChartLineProps>(
198
- (t) => {
199
- const progress = from + (to - from) * t
200
- // Hidden only once it has fully retraced, so the last frame of a hide is
201
- // the empty plot rather than a line that vanished a frame early.
202
- return { progress, enabled: progress > 0 }
203
- },
204
- duration,
205
- easing
206
- )
207
- }
208
- }
156
+ export type ChartLine = NodeOf<typeof ChartLine>