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

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 (141) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +30 -16
  3. package/dist/bar-chart/bar-chart.d.ts +93 -246
  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 +24 -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 +36 -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 +8 -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 +23 -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 +79 -67
  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 +36 -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 +25 -51
  70. package/dist/line-chart/chart-line.d.ts.map +1 -1
  71. package/dist/line-chart/chart-line.js +89 -120
  72. package/dist/line-chart/chart-line.js.map +1 -1
  73. package/dist/line-chart/chart-region.d.ts +14 -14
  74. package/dist/line-chart/chart-region.d.ts.map +1 -1
  75. package/dist/line-chart/chart-region.js +26 -64
  76. package/dist/line-chart/chart-region.js.map +1 -1
  77. package/dist/line-chart/horizontal-axis.d.ts +32 -56
  78. package/dist/line-chart/horizontal-axis.d.ts.map +1 -1
  79. package/dist/line-chart/horizontal-axis.js +85 -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 +111 -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 +77 -134
  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 +54 -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 +142 -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/package.json +4 -4
  114. package/src/bar-chart/bar-chart.ts +415 -701
  115. package/src/bar-chart/category-axis.ts +95 -110
  116. package/src/bar-chart/chart-bars.ts +178 -200
  117. package/src/bar-chart/index.ts +2 -2
  118. package/src/bar-chart/shared.ts +3 -3
  119. package/src/engine.ts +8 -6
  120. package/src/kit/cartesian.ts +81 -0
  121. package/src/kit/chart-border.ts +22 -33
  122. package/src/kit/chart-canvas.ts +44 -79
  123. package/src/kit/chart-legend.ts +173 -189
  124. package/src/kit/entries.ts +20 -0
  125. package/src/kit/motion.ts +90 -0
  126. package/src/kit/paints.ts +15 -0
  127. package/src/kit/shared.ts +52 -3
  128. package/src/kit/vertical-axis.ts +110 -140
  129. package/src/line-chart/chart-line.ts +95 -122
  130. package/src/line-chart/chart-region.ts +30 -59
  131. package/src/line-chart/horizontal-axis.ts +99 -135
  132. package/src/line-chart/index.ts +2 -2
  133. package/src/line-chart/line-chart.ts +449 -825
  134. package/src/line-chart/shared.ts +3 -3
  135. package/src/nodes.ts +8 -10
  136. package/src/pie-chart/index.ts +2 -2
  137. package/src/pie-chart/pie-chart.ts +221 -311
  138. package/src/pie-chart/pie-legend.ts +124 -114
  139. package/src/pie-chart/pie-ring.ts +386 -473
  140. package/dist/browser/chunks/chunk-5ZPBV6BU.js +0 -2
  141. 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,20 @@
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,
6
+ type NodeOf,
7
+ // Named so this component's emitted type reaches `Node2D` through the sdk, not core.
8
+ type Node2D as _Node2D,
12
9
  type Node2DProps,
13
- type RenderContext2D,
14
10
  type Stroke,
15
11
  type StrokeResolved,
16
12
  type Vector2,
17
13
  } from "@motionscript/sdk"
18
14
 
15
+ import { fresh } from "../kit/entries"
16
+ import { frameEase } from "../kit/motion"
17
+ import { loadPaints } from "../kit/paints"
19
18
  import { SPOTLIGHT_DIM } from "../kit/shared"
20
19
  import {
21
20
  EMPTY_REGION_BOX,
@@ -23,7 +22,6 @@ import {
23
22
  hasRegion,
24
23
  type RegionBox,
25
24
  } from "./shared"
26
- import { declarePaints } from "@motionscript/sdk/component"
27
25
 
28
26
  export interface ChartLineProps extends Node2DProps {
29
27
  /**
@@ -40,8 +38,8 @@ export interface ChartLineProps extends Node2DProps {
40
38
  * Defaults to **1**, unlike the standalone chart this was ported from: in an
41
39
  * editor a node's resting state is what the paused editor canvas paints, so a
42
40
  * 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.
41
+ * ChartLine.enter} drives it from 0 regardless of where it rests, so the
42
+ * animation is unchanged.
45
43
  */
46
44
  progress: number
47
45
  /**
@@ -59,46 +57,101 @@ export interface ChartLineProps extends Node2DProps {
59
57
  dimOpacity: number
60
58
  }
61
59
 
60
+ /**
61
+ * The prefix of `pts` revealed at fraction `p ∈ [0, 1]`, measuring the reveal
62
+ * by *cumulative arc length* so the line traces at a steady pace regardless of
63
+ * uneven point spacing. Returns the whole-point prefix plus one interpolated
64
+ * point partway along the active segment.
65
+ */
66
+ function tracePoints(pts: Vector2[], p: number): Vector2[] {
67
+ if (p >= 1 || pts.length < 2) return pts
68
+ if (p <= 0) return []
69
+
70
+ const segLen: number[] = []
71
+ let total = 0
72
+ for (let i = 1; i < pts.length; i++) {
73
+ const d = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y)
74
+ segLen.push(d)
75
+ total += d
76
+ }
77
+ if (total === 0) return pts
78
+
79
+ let target = total * p
80
+ const out: Vector2[] = [pts[0]]
81
+ for (let i = 0; i < segLen.length; i++) {
82
+ if (target >= segLen[i]) {
83
+ out.push(pts[i + 1])
84
+ target -= segLen[i]
85
+ } else {
86
+ const t = segLen[i] === 0 ? 0 : target / segLen[i]
87
+ out.push({
88
+ x: pts[i].x + (pts[i + 1].x - pts[i].x) * t,
89
+ y: pts[i].y + (pts[i + 1].y - pts[i].y) * t,
90
+ })
91
+ break
92
+ }
93
+ }
94
+ return out
95
+ }
96
+
62
97
  /**
63
98
  * A single series line of a {@link LineChart}: one rounded polyline traced
64
99
  * through pre-mapped pixel `points`, drawn as one {@link Graphics2D} command list.
65
100
  * Sized and positioned by the parent, which shares the plot's coordinate frame.
66
101
  *
67
102
  * Two signals drive it: {@link ChartLineProps.progress} traces the line in
68
- * (animated by {@link enter}) and `enabled` shows or hides it wholesale.
103
+ * (animated by `enter`) and `enabled` shows or hides it wholesale.
69
104
  */
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
105
+ export const ChartLine = component2d({
106
+ key: "chartLine",
107
+ state: {
108
+ points: fresh<Vector2[]>(() => []),
109
+ stroke: Types.custom<Stroke, StrokeResolved[]>([], { resolve: strokeOps.resolve, lerp: strokeOps.lerp }),
110
+ progress: 1,
111
+ enabled: true,
112
+ spotlight: fresh<RegionBox>(() => EMPTY_REGION_BOX),
113
+ dimOpacity: SPOTLIGHT_DIM,
114
+ },
115
+ load: (loader, state) => loadPaints(loader, { strokes: [state.stroke] }),
116
+ commands: {
117
+ /**
118
+ * Trace the line from `from` to `to` over the command — by default in from
119
+ * its left end to its right. `showSeries` and `hideSeries` are the same
120
+ * motion in opposite directions, so both are this with their ends stated.
121
+ *
122
+ * `enabled` rides along in the same value rather than being set before or
123
+ * after the command: a line is drawable for every `t` the trace covers and
124
+ * undrawn outside it, which is what makes arriving at a time backwards show
125
+ * what arriving forwards does.
126
+ */
127
+ enter: {
128
+ args: { from: Types.number(0, { min: 0, max: 1 }), to: Types.number(1, { min: 0, max: 1 }) },
129
+ run: (timeline) => {
130
+ const { from, to } = timeline.args
131
+ const ease = frameEase(timeline)
132
+ return timeline.animate((t) => {
133
+ const progress = from + (to - from) * ease(t)
134
+ // Hidden only once it has fully retraced, so the last frame of a hide is
135
+ // the empty plot rather than a line that vanished a frame early.
136
+ return { progress, enabled: progress > 0 }
137
+ })
138
+ },
139
+ },
140
+ },
141
+ render: (ctx, state) => {
142
+ if (!state.enabled) return
143
+ const pts = state.points
91
144
  if (pts.length < 2) return
92
145
 
93
- const p = Math.max(0, Math.min(1, this.progress))
146
+ const p = Math.max(0, Math.min(1, state.progress))
94
147
  if (p <= 0) return
95
148
 
96
- const drawn = this.tracePoints(pts, p)
149
+ const drawn = tracePoints(pts, p)
97
150
  if (drawn.length < 2) return
98
- const stroke = this.stroke as StrokeResolved[]
151
+ const stroke = state.stroke
99
152
  const radius = (stroke[0]?.weight ?? 0) * 2 // round the joins
100
153
 
101
- const box = this.spotlight
154
+ const box = state.spotlight
102
155
  if (!hasRegion(box)) {
103
156
  ctx.draw(new Graphics2D().line({ points: drawn, radius }).stroke(stroke))
104
157
  return
@@ -113,7 +166,7 @@ export class ChartLine extends Node2D<ChartLineProps> {
113
166
  new Graphics2D()
114
167
  .line({ points: drawn, radius })
115
168
  .stroke(stroke)
116
- .opacity(Math.max(0, Math.min(1, this.dimOpacity)))
169
+ .opacity(Math.max(0, Math.min(1, state.dimOpacity)))
117
170
  )
118
171
 
119
172
  // One `draw` per run: shapes chained before a `stroke()` are combined into a
@@ -122,87 +175,7 @@ export class ChartLine extends Node2D<ChartLineProps> {
122
175
  for (const run of clipPolylineToBox(drawn, box)) {
123
176
  ctx.draw(new Graphics2D().line({ points: run, radius }).stroke(stroke))
124
177
  }
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 []
178
+ },
179
+ })
136
180
 
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
- }
163
-
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
- }
181
+ export type ChartLine = NodeOf<typeof ChartLine>