@motionscript/charts 0.1.0-alpha.0 → 0.1.0-alpha.10

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 (152) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/NOTICE +49 -0
  3. package/README.md +99 -0
  4. package/dist/bar-chart/bar-chart.d.ts +133 -228
  5. package/dist/bar-chart/bar-chart.d.ts.map +1 -1
  6. package/dist/bar-chart/bar-chart.js +386 -565
  7. package/dist/bar-chart/bar-chart.js.map +1 -1
  8. package/dist/bar-chart/category-axis.d.ts +35 -41
  9. package/dist/bar-chart/category-axis.d.ts.map +1 -1
  10. package/dist/bar-chart/category-axis.js +93 -115
  11. package/dist/bar-chart/category-axis.js.map +1 -1
  12. package/dist/bar-chart/chart-bars.d.ts +50 -70
  13. package/dist/bar-chart/chart-bars.d.ts.map +1 -1
  14. package/dist/bar-chart/chart-bars.js +155 -210
  15. package/dist/bar-chart/chart-bars.js.map +1 -1
  16. package/dist/bar-chart/index.d.ts +2 -2
  17. package/dist/bar-chart/index.d.ts.map +1 -1
  18. package/dist/bar-chart/index.js +1 -1
  19. package/dist/bar-chart/index.js.map +1 -1
  20. package/dist/bar-chart/shared.d.ts +7 -7
  21. package/dist/bar-chart/shared.d.ts.map +1 -1
  22. package/dist/bar-chart/shared.js +7 -7
  23. package/dist/bar-chart/shared.js.map +1 -1
  24. package/dist/browser/chunks/chunk-TJNHLQGV.js +2 -0
  25. package/dist/browser/chunks/chunk-TJNHLQGV.js.map +7 -0
  26. package/dist/browser/index.js +1 -1
  27. package/dist/browser/index.js.map +3 -3
  28. package/dist/browser/kit.js +1 -1
  29. package/dist/browser/manifest.json +4 -3
  30. package/dist/engine.d.ts +10 -8
  31. package/dist/engine.d.ts.map +1 -1
  32. package/dist/engine.js +10 -8
  33. package/dist/engine.js.map +1 -1
  34. package/dist/kit/cartesian.d.ts +44 -0
  35. package/dist/kit/cartesian.d.ts.map +1 -0
  36. package/dist/kit/cartesian.js +59 -0
  37. package/dist/kit/cartesian.js.map +1 -0
  38. package/dist/kit/chart-border.d.ts +10 -11
  39. package/dist/kit/chart-border.d.ts.map +1 -1
  40. package/dist/kit/chart-border.js +15 -32
  41. package/dist/kit/chart-border.js.map +1 -1
  42. package/dist/kit/chart-canvas.d.ts +32 -21
  43. package/dist/kit/chart-canvas.d.ts.map +1 -1
  44. package/dist/kit/chart-canvas.js +33 -90
  45. package/dist/kit/chart-canvas.js.map +1 -1
  46. package/dist/kit/chart-legend.d.ts +123 -85
  47. package/dist/kit/chart-legend.d.ts.map +1 -1
  48. package/dist/kit/chart-legend.js +137 -186
  49. package/dist/kit/chart-legend.js.map +1 -1
  50. package/dist/kit/entries.d.ts +17 -0
  51. package/dist/kit/entries.d.ts.map +1 -0
  52. package/dist/kit/entries.js +19 -0
  53. package/dist/kit/entries.js.map +1 -0
  54. package/dist/kit/motion.d.ts +48 -0
  55. package/dist/kit/motion.d.ts.map +1 -0
  56. package/dist/kit/motion.js +63 -0
  57. package/dist/kit/motion.js.map +1 -0
  58. package/dist/kit/paints.d.ts +10 -0
  59. package/dist/kit/paints.d.ts.map +1 -0
  60. package/dist/kit/paints.js +12 -0
  61. package/dist/kit/paints.js.map +1 -0
  62. package/dist/kit/shared.d.ts +61 -10
  63. package/dist/kit/shared.d.ts.map +1 -1
  64. package/dist/kit/shared.js +108 -13
  65. package/dist/kit/shared.js.map +1 -1
  66. package/dist/kit/vertical-axis.d.ts +48 -61
  67. package/dist/kit/vertical-axis.d.ts.map +1 -1
  68. package/dist/kit/vertical-axis.js +97 -132
  69. package/dist/kit/vertical-axis.js.map +1 -1
  70. package/dist/line-chart/chart-line.d.ts +60 -77
  71. package/dist/line-chart/chart-line.d.ts.map +1 -1
  72. package/dist/line-chart/chart-line.js +100 -121
  73. package/dist/line-chart/chart-line.js.map +1 -1
  74. package/dist/line-chart/chart-region.d.ts +32 -33
  75. package/dist/line-chart/chart-region.d.ts.map +1 -1
  76. package/dist/line-chart/chart-region.js +31 -65
  77. package/dist/line-chart/chart-region.js.map +1 -1
  78. package/dist/line-chart/horizontal-axis.d.ts +58 -64
  79. package/dist/line-chart/horizontal-axis.d.ts.map +1 -1
  80. package/dist/line-chart/horizontal-axis.js +94 -123
  81. package/dist/line-chart/horizontal-axis.js.map +1 -1
  82. package/dist/line-chart/index.d.ts +3 -3
  83. package/dist/line-chart/index.d.ts.map +1 -1
  84. package/dist/line-chart/index.js +2 -2
  85. package/dist/line-chart/index.js.map +1 -1
  86. package/dist/line-chart/line-chart.d.ts +156 -303
  87. package/dist/line-chart/line-chart.d.ts.map +1 -1
  88. package/dist/line-chart/line-chart.js +410 -638
  89. package/dist/line-chart/line-chart.js.map +1 -1
  90. package/dist/line-chart/shared.d.ts +7 -7
  91. package/dist/line-chart/shared.d.ts.map +1 -1
  92. package/dist/line-chart/shared.js +23 -8
  93. package/dist/line-chart/shared.js.map +1 -1
  94. package/dist/line-chart/x-scale.d.ts +4 -11
  95. package/dist/line-chart/x-scale.d.ts.map +1 -1
  96. package/dist/line-chart/x-scale.js +16 -5
  97. package/dist/line-chart/x-scale.js.map +1 -1
  98. package/dist/nodes.d.ts +6 -9
  99. package/dist/nodes.d.ts.map +1 -1
  100. package/dist/nodes.js +7 -9
  101. package/dist/nodes.js.map +1 -1
  102. package/dist/pie-chart/index.d.ts +2 -2
  103. package/dist/pie-chart/index.d.ts.map +1 -1
  104. package/dist/pie-chart/index.js +1 -1
  105. package/dist/pie-chart/index.js.map +1 -1
  106. package/dist/pie-chart/pie-chart.d.ts +108 -126
  107. package/dist/pie-chart/pie-chart.d.ts.map +1 -1
  108. package/dist/pie-chart/pie-chart.js +202 -271
  109. package/dist/pie-chart/pie-chart.js.map +1 -1
  110. package/dist/pie-chart/pie-legend.d.ts +84 -31
  111. package/dist/pie-chart/pie-legend.d.ts.map +1 -1
  112. package/dist/pie-chart/pie-legend.js +93 -107
  113. package/dist/pie-chart/pie-legend.js.map +1 -1
  114. package/dist/pie-chart/pie-ring.d.ts +160 -122
  115. package/dist/pie-chart/pie-ring.d.ts.map +1 -1
  116. package/dist/pie-chart/pie-ring.js +333 -361
  117. package/dist/pie-chart/pie-ring.js.map +1 -1
  118. package/dist/pie-chart/shared.d.ts +12 -6
  119. package/dist/pie-chart/shared.d.ts.map +1 -1
  120. package/dist/pie-chart/shared.js +14 -8
  121. package/dist/pie-chart/shared.js.map +1 -1
  122. package/package.json +7 -4
  123. package/src/bar-chart/bar-chart.ts +424 -686
  124. package/src/bar-chart/category-axis.ts +96 -111
  125. package/src/bar-chart/chart-bars.ts +178 -201
  126. package/src/bar-chart/index.ts +2 -2
  127. package/src/bar-chart/shared.ts +7 -7
  128. package/src/engine.ts +10 -8
  129. package/src/kit/cartesian.ts +81 -0
  130. package/src/kit/chart-border.ts +23 -34
  131. package/src/kit/chart-canvas.ts +45 -80
  132. package/src/kit/chart-legend.ts +185 -206
  133. package/src/kit/entries.ts +20 -0
  134. package/src/kit/motion.ts +90 -0
  135. package/src/kit/paints.ts +15 -0
  136. package/src/kit/shared.ts +120 -13
  137. package/src/kit/vertical-axis.ts +111 -141
  138. package/src/line-chart/chart-line.ts +103 -155
  139. package/src/line-chart/chart-region.ts +36 -81
  140. package/src/line-chart/horizontal-axis.ts +102 -153
  141. package/src/line-chart/index.ts +3 -3
  142. package/src/line-chart/line-chart.ts +463 -759
  143. package/src/line-chart/shared.ts +22 -8
  144. package/src/line-chart/x-scale.ts +20 -5
  145. package/src/nodes.ts +8 -10
  146. package/src/pie-chart/index.ts +2 -2
  147. package/src/pie-chart/pie-chart.ts +225 -303
  148. package/src/pie-chart/pie-legend.ts +125 -115
  149. package/src/pie-chart/pie-ring.ts +388 -443
  150. package/src/pie-chart/shared.ts +14 -8
  151. package/dist/browser/chunks/chunk-ZMBOBHYL.js +0 -2
  152. package/dist/browser/chunks/chunk-ZMBOBHYL.js.map +0 -7
package/src/kit/shared.ts CHANGED
@@ -12,9 +12,8 @@
12
12
  * that three times would mean three answers to "how does a chart round its axis"
13
13
  * within one app.
14
14
  *
15
- * Kept renderer-agnostic (no `Node` imports) so the studio's own inspector
16
- * mappings can build a partial theme without pulling the node classes in, the
17
- * same way `line-chart-props.ts` always has.
15
+ * Kept renderer-agnostic (no `Node` imports) so a host's own inspector
16
+ * mappings can build a partial theme without pulling the components in.
18
17
  *
19
18
  * **Two conventions run through every resolver and lerp below**, inherited from
20
19
  * the shape system's `resolveStroke`/`resolveShadow`:
@@ -46,7 +45,7 @@ import {
46
45
  type Stroke,
47
46
  type StrokeResolved,
48
47
  type TextStyle,
49
- } from "@motionscript/core"
48
+ } from "@motionscript/sdk"
50
49
 
51
50
  /**
52
51
  * One series of a chart: a column of the table, captioned and painted.
@@ -102,7 +101,7 @@ export interface AxisDomainConfig {
102
101
  }
103
102
 
104
103
  /**
105
- * An axis domain's @property mapper: merges the author's partial input onto
104
+ * An axis domain entry's resolve: merges the author's partial input onto
106
105
  * `previous`, so a later partial `.set({ yAxis: {...} })` patches
107
106
  * `label`/`range`/`ticks` independently. `range`/`ticks` staying `undefined`
108
107
  * after the merge is the auto-derive signal the scale getters read.
@@ -120,7 +119,7 @@ export function resolveAxisDomain(
120
119
  }
121
120
 
122
121
  /**
123
- * An axis domain's @property tween: interpolates `range`/`ticks` numerically
122
+ * An axis domain entry's lerp: interpolates `range`/`ticks` numerically
124
123
  * (pairwise by index for `ticks`), so a `to` command that restates a range
125
124
  * rescales the plot smoothly. `label`, and either field when one side is in
126
125
  * auto-derive mode, step at the midpoint.
@@ -163,6 +162,81 @@ function lerpOptionalTicks(
163
162
  return out
164
163
  }
165
164
 
165
+ /**
166
+ * Where a generic `to()` of an axis domain should land, given the domain as it
167
+ * is *drawn* (`drawn`, with `range` and `ticks` resolved off the data), or
168
+ * `undefined` when the request states neither a `range` nor `ticks` and the
169
+ * plain property tween will do.
170
+ *
171
+ * The stored domain of an auto axis has no `range` and no `ticks`, and
172
+ * {@link lerpAxisDomain} steps an unset side at the midpoint, so tweening the
173
+ * stored value from an auto axis is a cut. The charts therefore tween between
174
+ * resolved domains, as their zoom does; this builds the destination. A `range`
175
+ * given without `ticks` keeps the tick *count* the axis already draws, so each
176
+ * marker slides from its old value to its new one instead of the row popping.
177
+ */
178
+ export function axisTweenTarget(
179
+ drawn: AxisDomainConfig,
180
+ requested: AxisDomainConfig | undefined
181
+ ): AxisDomainConfig | undefined {
182
+ if (requested?.range === undefined && requested?.ticks === undefined) return undefined
183
+ const target = resolveAxisDomain(requested, drawn)
184
+ if (requested.ticks === undefined && requested.range !== undefined) {
185
+ const count = Math.max(2, drawn.ticks?.length ?? 2)
186
+ target.ticks = markerValues(requested.range, count - 1)
187
+ }
188
+ return target
189
+ }
190
+
191
+ /**
192
+ * An axis domain part-way through a tween that leaves or reaches a `range` or
193
+ * `ticks` left to the data: both ends as stored, and how far along. Only the
194
+ * chart knows what an auto side draws as, so {@link settleAxisDomain} resolves
195
+ * it there. Its own fields are the plain {@link lerpAxisDomain} of its ends, so
196
+ * a mapper merging onto one still reads a domain.
197
+ */
198
+ export interface AxisDomainBlend extends AxisDomainConfig {
199
+ readonly blend: { readonly from: AxisDomainConfig; readonly to: AxisDomainConfig; readonly t: number }
200
+ }
201
+
202
+ function blendOf(domain: AxisDomainConfig): AxisDomainBlend["blend"] | undefined {
203
+ return (domain as Partial<AxisDomainBlend>).blend
204
+ }
205
+
206
+ /**
207
+ * A chart's axis entry tween: what `to({ yAxis: { range } })` moves through.
208
+ * Between two explicit domains it is {@link lerpAxisDomain}, and a restated
209
+ * `range` without `ticks` spreads explicit ticks across it, keeping their count.
210
+ * When either end leaves `range` or `ticks` to the data, the value between is an
211
+ * {@link AxisDomainBlend}, which the chart draws as the blend of what each end
212
+ * draws: a rescale from an auto axis is continuous, not a cut at the midpoint.
213
+ */
214
+ export function lerpAxisEntry(from: AxisDomainConfig, to: AxisDomainConfig, t: number): AxisDomainConfig {
215
+ // The mapper merged the request onto `from`, so a field it left unstated is `from`'s own, by identity.
216
+ const rangeMoved = to.range !== from.range
217
+ const target = rangeMoved && to.range && to.ticks && to.ticks === from.ticks
218
+ ? { ...to, ticks: markerValues(to.range, Math.max(2, to.ticks.length) - 1) }
219
+ : to
220
+ const blended = blendOf(from) !== undefined
221
+ const moved = blended || rangeMoved || target.ticks !== from.ticks
222
+ const auto = blended || from.range === undefined || from.ticks === undefined || target.range === undefined || target.ticks === undefined
223
+ if (!moved || !auto) return lerpAxisDomain(from, target, t)
224
+ if (t === 0) return from
225
+ if (t === 1) return target
226
+ const blend: AxisDomainBlend = { ...lerpAxisDomain(from, target, t), blend: { from, to: target, t } }
227
+ return blend
228
+ }
229
+
230
+ /**
231
+ * `domain` as the chart draws it: `drawn` resolves a plain domain's auto `range`
232
+ * and `ticks` off the data, and a blend is the lerp of what its two ends draw.
233
+ */
234
+ export function settleAxisDomain(domain: AxisDomainConfig, drawn: (domain: AxisDomainConfig) => AxisDomainConfig): AxisDomainConfig {
235
+ const blend = blendOf(domain)
236
+ if (!blend) return drawn(domain)
237
+ return lerpAxisDomain(settleAxisDomain(blend.from, drawn), settleAxisDomain(blend.to, drawn), blend.t)
238
+ }
239
+
166
240
  /**
167
241
  * The low-level colour tokens the default themes are built from — a dark plot
168
242
  * panel with warm-grey axis furniture.
@@ -203,7 +277,8 @@ export function clampUnit(value: number): number {
203
277
  // ---- Row readers ---------------------------------------------------------
204
278
 
205
279
  /**
206
- * Read a numeric field off a row, defaulting missing/non-numeric values to 0.
280
+ * Read a numeric field off a row, defaulting missing, non-numeric and non-finite
281
+ * values to 0.
207
282
  *
208
283
  * A column left as strings silently plots as 0, which is why the CSV parser's
209
284
  * numeric auto-coercion matters — a quoted or unit-suffixed column won't plot
@@ -212,7 +287,9 @@ export function clampUnit(value: number): number {
212
287
  export function num(row: DataRecord, field: string | undefined): number {
213
288
  if (field === undefined) return 0
214
289
  const v = row[field]
215
- return typeof v === "number" ? v : 0
290
+ // A `NaN` or infinite cell is no more a measurement than a missing one: read
291
+ // as 0, it cannot poison the scale and turn every point into `NaN`.
292
+ return typeof v === "number" && Number.isFinite(v) ? v : 0
216
293
  }
217
294
 
218
295
  /**
@@ -650,10 +727,40 @@ export function resolvePalettePaint(
650
727
  // ---- Scales and formatting -----------------------------------------------
651
728
 
652
729
 
653
- /** Format a marker value: integers plain, otherwise trimmed to one decimal. */
654
- export function formatMarker(value: number): string {
730
+ /**
731
+ * Format a marker value: integers plain, otherwise trimmed to one decimal, or to
732
+ * as many as the axis's tick `step` needs to tell neighbouring ticks apart.
733
+ *
734
+ * One decimal is the floor, so a step of 0.1 or more reads exactly as it always
735
+ * has; a finer step (a unit axis ticked every 0.05) gets two, instead of five
736
+ * ticks captioned `0.1 0.1 0.2 0.2 0.3`. Trailing zeros are dropped either way.
737
+ */
738
+ export function formatMarker(value: number, step?: number): string {
655
739
  if (Number.isInteger(value)) return String(value)
656
- return value.toFixed(1).replace(/\.0$/, "")
740
+ const decimals = Math.max(1, step === undefined ? 1 : decimalsFor(step))
741
+ return value.toFixed(decimals).replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "")
742
+ }
743
+
744
+ /** Decimal places needed to write `step` exactly, capped at six. */
745
+ function decimalsFor(step: number): number {
746
+ if (!(step > 0) || !Number.isFinite(step)) return 1
747
+ for (let d = 0; d <= 6; d++) {
748
+ const scaled = step * 10 ** d
749
+ if (Math.abs(scaled - Math.round(scaled)) < 1e-6 * Math.max(1, scaled)) return d
750
+ }
751
+ // An irrational-looking step (a third): as many places as separate the ticks.
752
+ return Math.min(6, Math.max(1, Math.ceil(-Math.log10(step)) + 1))
753
+ }
754
+
755
+ /** The gap between neighbouring tick values, or `undefined` for fewer than two distinct ones. */
756
+ export function markerStep(markers: readonly number[]): number | undefined {
757
+ const sorted = [...markers].filter(Number.isFinite).sort((a, b) => a - b)
758
+ let step: number | undefined
759
+ for (let i = 1; i < sorted.length; i++) {
760
+ const gap = sorted[i] - sorted[i - 1]
761
+ if (gap > 1e-12 && (step === undefined || gap < step)) step = gap
762
+ }
763
+ return step
657
764
  }
658
765
 
659
766
  /** Format a fraction in `[0, 1]` as a percentage: integers plain, else one decimal. */
@@ -670,6 +777,6 @@ export function markerValues([min, max]: Range, markerCount: number): number[] {
670
777
  }
671
778
 
672
779
  // Lifted into the engine, because a graph and a diagram want them too — see
673
- // `@motionscript/core/component`. Re-exported so a chart, and anything that
780
+ // `@motionscript/sdk/component`. Re-exported so a chart, and anything that
674
781
  // copied one, still reaches them where it always did.
675
- export { niceStep, enterBudget } from "@motionscript/core/component"
782
+ export { niceStep, enterBudget } from "@motionscript/sdk/component"
@@ -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
- } from "@motionscript/core"
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/core/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>