@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
@@ -1,24 +1,23 @@
1
1
  import {
2
- command,
3
- Node2D,
4
2
  Rect,
5
3
  Text,
6
- easeOut,
7
- property,
4
+ Types,
5
+ component2d,
8
6
  insetsOps,
9
7
  textOps,
10
- type CommandArgs,
11
- type NodeConfig,
8
+ type NodeOf,
9
+ type Node2D,
12
10
  type Node2DProps,
13
- } from "@motionscript/core"
11
+ } from "@motionscript/sdk"
14
12
 
13
+ import { asParts, fresh } from "../kit/entries"
14
+ import { armed, arrival, arrivalOf, arrivalsEntry, beats, frameEase, type Arrival } from "../kit/motion"
15
15
  import {
16
16
  DEFAULT_X_AXIS,
17
17
  clampUnit,
18
18
  enterBudget,
19
19
  type AxisConfig,
20
20
  } from "../kit/shared"
21
- import { at, compose, hold, type Seekable } from "@motionscript/core/component"
22
21
 
23
22
  export interface CategoryAxisProps extends Node2DProps {
24
23
  /** One caption per category slot, left to right — the chart's rows, in order. */
@@ -57,121 +56,107 @@ const SLIDE = 24
57
56
  * invisible twin of the value axis, as the line chart does), so a caption lands
58
57
  * under the columns it names without either side being told about the other.
59
58
  *
60
- * {@link enter} staggers the captions in left-to-right, each lifted a little
61
- * above its slot and transparent; the resting state is the landed one, so a
62
- * chart that is never told to enter still reads as finished.
59
+ * `enter` staggers the captions in left-to-right, each lifted a little above
60
+ * its slot and transparent; the resting state is the landed one, so a chart
61
+ * that is never told to enter still reads as finished.
63
62
  */
64
- export class CategoryAxis extends Node2D<CategoryAxisProps> {
65
- @property({ default: () => [] as string[] }) declare categories: string[]
66
- @property({ default: DEFAULT_X_AXIS }) declare axis: AxisConfig
67
- @property({ default: "" }) declare label: string
68
- @property({ default: () => [] as number[] }) declare dims: number[]
63
+ export const CategoryAxis = component2d({
64
+ key: "categoryAxis",
65
+ state: {
66
+ categories: fresh<string[]>(() => []),
67
+ axis: Types.custom<AxisConfig>(DEFAULT_X_AXIS),
68
+ label: "",
69
+ dims: fresh<number[]>(() => []),
70
+ arrivals: arrivalsEntry(),
71
+ },
72
+ // A plain node filled its parent; one made of its captions would hug them.
73
+ defaults: { width: "fill", height: "fill" },
74
+ children: (state) => {
75
+ const tickPad = insetsOps.resolve(state.axis.tickPadding).top
76
+ const titlePad = insetsOps.resolve(state.axis.titlePadding).top
69
77
 
70
- /** One caption per category, ordered left → right, for the staggered enter. */
71
- private captionNodes: Text[] = []
72
- /** The axis title, or null when `label` is empty. */
73
- private labelNode: Text | null = null
74
-
75
- /** Centre x of slot `i`, in the node's centred space. */
76
- private slotCenter(index: number): number {
77
- const n = this.categories.length
78
- if (n <= 0) return 0
79
- const width = this.layoutBounds.width
80
- return -width / 2 + (width / n) * (index + 0.5)
81
- }
82
-
83
- constructor(props?: NodeConfig<CategoryAxis, CategoryAxisProps>) {
84
- super(props)
85
-
86
- const tickPad = insetsOps.resolve(this.axis.tickPadding).top
87
- const titlePad = insetsOps.resolve(this.axis.titlePadding).top
78
+ const column: Node2D[] = []
79
+ // The column's parent is the axis: its box is what the slots divide.
80
+ const width = () => (column[0]?.parent as Node2D | null | undefined)?.layoutBounds.width ?? 0
81
+ /** Centre x of slot `i`, in the node's centred space. */
82
+ const slotCenter = (index: number) => {
83
+ const n = state.categories.length
84
+ if (n <= 0) return 0
85
+ return -width() / 2 + (width() / n) * (index + 0.5)
86
+ }
88
87
 
89
88
  // Each caption sits in a hug box carrying the spotlight dim, so the
90
89
  // entrance can animate the text's own opacity without the two fighting over
91
90
  // one value — opacity multiplies down the tree, which is exactly the
92
91
  // composition wanted here.
93
- const slots = this.categories.map((caption, i) => {
94
- const text = new Text({
95
- ...textOps.styleProps(this.axis.labelStyle),
96
- text: caption,
97
- })
98
- this.captionNodes.push(text)
99
- return new Rect({
92
+ const slots = state.categories.map((caption, i) =>
93
+ new Rect({
100
94
  width: "hug",
101
95
  height: "hug",
102
96
  flow: "freeform",
103
- opacity: () => clampUnit(this.dims[i] ?? 1),
104
- x: () => this.slotCenter(i),
105
- children: [text],
97
+ opacity: () => clampUnit(state.dims[i] ?? 1),
98
+ x: () => slotCenter(i),
99
+ children: [
100
+ new Text({
101
+ ...textOps.styleProps(state.axis.labelStyle),
102
+ text: caption,
103
+ opacity: () => arrivalOf(state.arrivals, i).opacity,
104
+ y: () => arrivalOf(state.arrivals, i).shift,
105
+ }),
106
+ ],
106
107
  })
107
- })
108
+ )
108
109
 
109
- const children: Node2D[] = [
110
- new Rect({
111
- width: "fill",
112
- height: "hug",
113
- padding: { top: tickPad },
114
- children: slots,
115
- }),
116
- ]
117
- if (this.label !== "") {
118
- this.labelNode = new Text({
119
- ...textOps.styleProps(this.axis.titleStyle),
120
- text: this.label,
121
- })
122
- children.push(this.labelNode)
110
+ const children: Node2D[] = [new Rect({ width: "fill", height: "hug", padding: { top: tickPad }, children: slots })]
111
+ if (state.label !== "") {
112
+ children.push(
113
+ new Text({
114
+ ...textOps.styleProps(state.axis.titleStyle),
115
+ text: state.label,
116
+ opacity: () => arrivalOf(state.arrivals, "label").opacity,
117
+ y: () => arrivalOf(state.arrivals, "label").shift,
118
+ })
119
+ )
123
120
  }
124
121
 
125
- this.add(new Rect({ flow: "vertical", gap: titlePad, height: "hug", width: "fill", children }))
126
- }
122
+ column.push(new Rect({ flow: "vertical", gap: titlePad, height: "hug", width: "fill", children }))
123
+ return asParts(column)
124
+ },
125
+ commands: {
126
+ /**
127
+ * Slide + fade every caption in from above, staggered left-to-right, then
128
+ * bring the title down behind them. Its first frame arms them all: lifted
129
+ * by `slide` and transparent.
130
+ *
131
+ * The timeline's duration is the **whole** entrance — the stagger and the
132
+ * title are carved out of it rather than added on top. See {@link enterBudget}.
133
+ *
134
+ * `stagger` is seconds between successive captions (derived when omitted);
135
+ * `slide` is the starting upward offset in px (node space is y-up, so a
136
+ * positive start slides *down* into place).
137
+ */
138
+ enter: {
139
+ args: {
140
+ stagger: Types.custom<number | undefined>(undefined),
141
+ slide: Types.number(SLIDE),
142
+ },
143
+ run: (timeline, state) => {
144
+ const { stagger, slide } = timeline.args
145
+ const count = state.categories.length
146
+ const captioned = state.label !== ""
147
+ const from: Arrival = { opacity: 0, shift: slide }
148
+ const keys = [...state.categories.keys(), ...(captioned ? ["label"] : [])]
149
+ const arm = timeline.animate(() => ({ arrivals: armed(keys, from) }))
150
+ if (count === 0) return arm
151
+ const { step, per, caption } = enterBudget(timeline.duration, count, captioned, stagger)
152
+ const ease = frameEase(timeline)
153
+ const lastCaptionEnds = (count - 1) * step + per
154
+ const captions = state.categories.map((_, i) => ({ at: i * step, duration: per, piece: arrival(timeline, i, from, ease) }))
155
+ const label = captioned ? [{ at: lastCaptionEnds, duration: caption, piece: arrival(timeline, "label", from, ease) }] : []
156
+ return timeline.together([arm, beats(timeline, [...captions, ...label])])
157
+ },
158
+ },
159
+ },
160
+ })
127
161
 
128
- /**
129
- * Primes every caption (and the title) at its pre-entrance state: lifted above
130
- * its slot and transparent. Idempotent, so the chart can arm its whole
131
- * composition before the first frame is drawn.
132
- */
133
- arm(slide = SLIDE): void {
134
- for (const caption of this.captionNodes) caption.set({ opacity: 0, y: slide })
135
- this.labelNode?.set({ opacity: 0, y: slide })
136
- }
137
-
138
- /**
139
- * Slide + fade every caption in from above, staggered left-to-right, then
140
- * bring the title down behind them.
141
- *
142
- * `args.duration` is the **whole** entrance — the stagger and the title are
143
- * carved out of it rather than added on top. See {@link enterBudget}.
144
- *
145
- * `args.data.stagger` is seconds between successive captions (derived when
146
- * omitted); `args.data.slide` is the starting upward offset in px (node space
147
- * is y-up, so a positive start slides *down* into place).
148
- */
149
- @command()
150
- enter(args: CommandArgs<{ stagger?: number; slide?: number }> & { duration: number }): Seekable {
151
- const { stagger, slide = SLIDE } = args.data ?? {}
152
- const easing = args.easing ?? easeOut()
153
- const nodes = this.captionNodes
154
- if (nodes.length === 0) return hold(args.duration)
155
- const { step, per, caption } = enterBudget(
156
- args.duration,
157
- nodes.length,
158
- this.labelNode !== null,
159
- stagger
160
- )
161
-
162
- this.arm(slide)
163
-
164
- // The stagger was `wait(i * step)` inside a generator, so its offsets only
165
- // existed while it ran. They were always `i * step`; placed, the same
166
- // entrance can be asked what it looks like partway through.
167
- const lastCaptionEnds = (nodes.length - 1) * step + per
168
- return compose([
169
- ...nodes.map((node, i) =>
170
- at(i * step, node.to({ data: { opacity: 1, y: 0 }, duration: per, easing }))
171
- ),
172
- ...(this.labelNode
173
- ? [at(lastCaptionEnds, this.labelNode.to({ data: { opacity: 1, y: 0 }, duration: caption, easing }))]
174
- : []),
175
- ])
176
- }
177
- }
162
+ export type CategoryAxis = NodeOf<typeof CategoryAxis>
@@ -1,32 +1,30 @@
1
1
  import {
2
- command,
3
- type AssetScope,
4
2
  Graphics2D,
5
- Node2D,
6
3
  Text,
7
- easeOut,
4
+ Types,
5
+ component2d,
8
6
  fillOps,
9
7
  shadowOps,
10
8
  strokeOps,
11
- property,
12
9
  textOps,
13
- type Command,
14
- type CommandArgs,
15
10
  type Fill,
16
- type NodeConfig,
11
+ type FillResolved,
12
+ type NodeOf,
13
+ // Named so this component's emitted type reaches `Node2D` through the sdk, not core.
14
+ type Node2D as _Node2D,
17
15
  type Node2DProps,
18
- type RenderContext2D,
19
16
  type Shadow,
20
17
  type ShadowResolved,
21
18
  type Stroke,
22
19
  type StrokeResolved,
23
20
  type TextStyle,
24
- } from "@motionscript/core"
21
+ } from "@motionscript/sdk"
25
22
 
23
+ import { fresh } from "../kit/entries"
24
+ import { frameEase } from "../kit/motion"
25
+ import { loadPaints } from "../kit/paints"
26
26
  import { clampUnit, formatMarker } from "../kit/shared"
27
- import type { MarkerLabel } from "../kit/vertical-axis"
28
27
  import { DEFAULT_BAR_CHART_THEME } from "./shared"
29
- import { declarePaints } from "@motionscript/core/component"
30
28
 
31
29
  /**
32
30
  * One drawn column, already mapped into the plot's **centred pixel space**
@@ -43,12 +41,18 @@ export interface BarGeometry {
43
41
  width: number
44
42
  /** Pixel y of the column's base — the baseline when grouped, the running stack top when stacked. */
45
43
  from: number
46
- /** Pixel y of its far end, at full {@link ChartBars.growth}. */
44
+ /** Pixel y of its far end, at full {@link ChartBarsProps.growth}. */
47
45
  to: number
48
46
  /** Whether the far end takes the theme's corner radius (the stack's cap). */
49
47
  round: boolean
50
48
  /** The value this column plots, for the figure printed on it. */
51
49
  value: number
50
+ /**
51
+ * The figure printed on it, written as the chart's value axis writes its
52
+ * ticks, so the two cannot disagree. Absent, the bare marker reading of
53
+ * {@link value}.
54
+ */
55
+ caption?: string
52
56
  }
53
57
 
54
58
  /** Gap between a grouped column's end and the figure printed over it, in px. */
@@ -100,6 +104,81 @@ export interface ChartBarsProps extends Node2DProps {
100
104
  enabled: boolean
101
105
  }
102
106
 
107
+ /** What the column geometry reads. */
108
+ interface Columns {
109
+ readonly bars: readonly BarGeometry[]
110
+ readonly baseline: number
111
+ readonly growth: number
112
+ readonly dims: readonly number[]
113
+ readonly enabled: boolean
114
+ readonly valueStyle: TextStyle
115
+ }
116
+
117
+ /**
118
+ * How far column `i` is dimmed — the chart's category spotlight, and the series
119
+ * switch on top of it.
120
+ *
121
+ * The captions read this rather than being hidden by `render`'s guard: they are
122
+ * real child nodes, so a `return` in the draw would leave them on screen over a
123
+ * series that isn't there.
124
+ */
125
+ function dimAt(s: Columns, index: number): number {
126
+ if (!s.enabled) return 0
127
+ return clampUnit(s.dims[index] ?? 1)
128
+ }
129
+
130
+ /** The two ends of column `i` at the current growth. */
131
+ function endsAt(s: Columns, index: number): { base: number; end: number } | null {
132
+ const bar = s.bars[index]
133
+ if (!bar) return null
134
+ const g = clampUnit(s.growth)
135
+ return { base: s.baseline + (bar.from - s.baseline) * g, end: s.baseline + (bar.to - s.baseline) * g }
136
+ }
137
+
138
+ /** The type size the figures are set at, guarding the autofit/unset cases. */
139
+ function valueSize(s: Columns): number {
140
+ const size = s.valueStyle.fontSize
141
+ return typeof size === "number" ? size : VALUE_SIZE
142
+ }
143
+
144
+ /** Whether column `i`'s figure sits inside the column (a stacked segment) or past its end. */
145
+ function insideAt(s: Columns, index: number): boolean {
146
+ const bar = s.bars[index]
147
+ return bar !== undefined && Math.abs(bar.from - s.baseline) > 0.5
148
+ }
149
+
150
+ /**
151
+ * The figure printed on column `i`, or `""` where there is no room for it.
152
+ *
153
+ * Blanking the text rather than dropping the node keeps the caption list
154
+ * index-aligned with the columns, so a chart that rescales into more room simply
155
+ * starts printing again.
156
+ */
157
+ function captionAt(s: Columns, index: number): string {
158
+ const bar = s.bars[index]
159
+ if (!bar || bar.value === 0) return ""
160
+ // An interior segment prints inside itself, so it has to be tall enough to
161
+ // hold a line of type; a grouped column prints in the headroom above its end,
162
+ // where the only constraint is the plot's own top.
163
+ if (insideAt(s, index)) {
164
+ const ends = endsAt(s, index)
165
+ if (!ends) return ""
166
+ if (Math.abs(ends.end - ends.base) < valueSize(s) * VALUE_FIT) return ""
167
+ }
168
+ return bar.caption ?? formatMarker(bar.value)
169
+ }
170
+
171
+ /** Where column `i`'s figure sits, in the node's centred y-up space. */
172
+ function captionY(s: Columns, index: number): number {
173
+ const ends = endsAt(s, index)
174
+ if (!ends) return 0
175
+ if (insideAt(s, index)) return (ends.base + ends.end) / 2
176
+ // Text is centred on its `y`, so clear the column's end by the gap plus half a
177
+ // line — on whichever side of the baseline the column actually reached.
178
+ const direction = ends.end >= ends.base ? 1 : -1
179
+ return ends.end + direction * (VALUE_GAP + valueSize(s) / 2)
180
+ }
181
+
103
182
  /**
104
183
  * One **series** of a {@link BarChart}, drawn as one command list: its column in
105
184
  * every category, plus the figures printed on them.
@@ -116,195 +195,93 @@ export interface ChartBarsProps extends Node2DProps {
116
195
  * and ride the node's own opacity — which is why hiding the series takes its
117
196
  * figures with it for free.
118
197
  */
119
- export class ChartBars extends Node2D<ChartBarsProps> {
120
- @property({ default: () => [] as BarGeometry[] }) declare bars: BarGeometry[]
121
- @property({ default: 0 }) declare baseline: number
122
- @property({
123
- default: [],
124
- mapper: fillOps.resolve,
125
- tween: fillOps.lerp,
126
- })
127
- declare fill: Fill
128
- @property({
129
- default: DEFAULT_BAR_CHART_THEME.bar.stroke,
130
- mapper: strokeOps.resolve,
131
- tween: strokeOps.lerp,
132
- })
133
- declare barStroke: Stroke
134
- @property({
135
- default: DEFAULT_BAR_CHART_THEME.bar.shadow,
136
- mapper: shadowOps.resolve,
137
- tween: shadowOps.lerp,
138
- })
139
- declare barShadow: Shadow
140
- @property({ default: DEFAULT_BAR_CHART_THEME.bar.cornerRadius })
141
- declare cornerRadius: number
142
- @property({ default: 1 }) declare growth: number
143
- @property({ default: () => [] as number[] }) declare dims: number[]
144
- @property({ default: false }) declare showValues: boolean
145
- @property({ default: DEFAULT_BAR_CHART_THEME.valueStyle })
146
- declare valueStyle: TextStyle
147
- @property({ default: true }) declare enabled: boolean
148
-
149
- /**
150
- * How a column's figure is written down — the same seam the two axes carry,
151
- * so a chart's value axis and the numbers printed on its bars can't end up
152
- * saying `$1,200` in one place and `1200` in the other.
153
- */
154
- labelFor: MarkerLabel = formatMarker
155
-
156
- constructor(props?: NodeConfig<ChartBars, ChartBarsProps>) {
157
- super(props)
198
+ export const ChartBars = component2d({
199
+ key: "chartBars",
200
+ state: {
201
+ bars: fresh<BarGeometry[]>(() => []),
202
+ baseline: 0,
203
+ fill: Types.custom<Fill, FillResolved[]>([], { resolve: fillOps.resolve, lerp: fillOps.lerp }),
204
+ barStroke: Types.custom<Stroke, StrokeResolved[]>(DEFAULT_BAR_CHART_THEME.bar.stroke, { resolve: strokeOps.resolve, lerp: strokeOps.lerp }),
205
+ barShadow: Types.custom<Shadow, ShadowResolved[]>(DEFAULT_BAR_CHART_THEME.bar.shadow, { resolve: shadowOps.resolve, lerp: shadowOps.lerp }),
206
+ cornerRadius: DEFAULT_BAR_CHART_THEME.bar.cornerRadius,
207
+ growth: 1,
208
+ dims: fresh<number[]>(() => []),
209
+ showValues: false,
210
+ valueStyle: Types.custom<TextStyle>(DEFAULT_BAR_CHART_THEME.valueStyle),
211
+ enabled: true,
212
+ },
213
+ // A plain node filled its parent; one made of its figures would hug them.
214
+ defaults: { width: "fill", height: "fill" },
215
+ // One caption per column, built once (the row count is fixed at construction,
216
+ // like an axis's marker count) and bound to the live geometry so it travels
217
+ // with its column as the chart grows, rescales or reflows.
218
+ children: (state) => {
219
+ if (!state.showValues) return []
220
+ return state.bars.map((_, i) =>
221
+ Text({
222
+ ...textOps.styleProps(state.valueStyle),
223
+ text: () => captionAt(state, i),
224
+ x: () => state.bars[i]?.x ?? 0,
225
+ y: () => captionY(state, i),
226
+ opacity: () => dimAt(state, i),
227
+ })
228
+ )
229
+ },
230
+ // The column stroke and shadow are named past the conventional slots, so they are named here.
231
+ load: (loader, state) =>
232
+ loadPaints(loader, { fills: [state.fill], shadows: [state.barShadow], strokes: [state.barStroke] }),
233
+ commands: {
234
+ /**
235
+ * Raise every column from the baseline by tweening `growth` `0 → 1`.
236
+ *
237
+ * The mirror of `ChartLine.enter`: same contract that the resting state is
238
+ * the finished one, so the chart arms this back to 0 before animating rather
239
+ * than the node hiding itself at construction. No priming to 0 first: the
240
+ * value at `t` is a function of `t`, so a repeat call re-raises by definition.
241
+ */
242
+ enter: (timeline) => {
243
+ const ease = frameEase(timeline)
244
+ return timeline.animate((t) => ({ growth: ease(t) }))
245
+ },
246
+ },
247
+ render: (ctx, state) => {
248
+ if (state.enabled) {
249
+ for (let i = 0; i < state.bars.length; i++) {
250
+ const bar = state.bars[i]
251
+ const ends = endsAt(state, i)
252
+ if (!ends || bar.width <= 0) continue
253
+ const height = Math.abs(ends.end - ends.base)
254
+ if (height <= 0) continue
158
255
 
159
- // One caption per column, built once (the row count is fixed at
160
- // construction, like an axis's marker count) and bound to the live geometry
161
- // so it travels with its column as the chart grows, rescales or reflows.
162
- if (!this.showValues) return
163
- for (let i = 0; i < this.bars.length; i++) {
164
- this.add(
165
- new Text({
166
- ...textOps.styleProps(this.valueStyle),
167
- text: () => this.captionAt(i),
168
- x: () => this.bars[i]?.x ?? 0,
169
- y: () => this.captionY(i),
170
- opacity: () => this.dimAt(i),
256
+ const radius = bar.round ? Math.min(state.cornerRadius, height) : 0
257
+ // A column per `Graphics2D` rather than one list for the series: the
258
+ // category spotlight dims individual columns, and opacity is a property
259
+ // of the command list rather than of a shape within it.
260
+ const g = new Graphics2D()
261
+ const rect = g.rect({
262
+ x: bar.x,
263
+ y: (ends.base + ends.end) / 2,
264
+ width: bar.width,
265
+ height,
266
+ // Corner keys are visual (the renderer keeps them in y-up author space),
267
+ // so a column reaching *up* rounds its `top*` corners and one reaching
268
+ // down its `bottom*` ones. Only the far end rounds, which is what makes
269
+ // a stack read as a single capped column.
270
+ cornerRadius:
271
+ radius > 0
272
+ ? ends.end >= ends.base
273
+ ? { topLeft: radius, topRight: radius, bottomLeft: 0, bottomRight: 0 }
274
+ : { bottomLeft: radius, bottomRight: radius, topLeft: 0, topRight: 0 }
275
+ : 0,
171
276
  })
172
- )
277
+ rect.shadow(state.barShadow)
278
+ rect.fill(state.fill)
279
+ rect.stroke(state.barStroke)
280
+ ctx.draw(g.opacity(dimAt(state, i)))
281
+ }
173
282
  }
174
- }
175
-
176
- /**
177
- * How far column `i` is dimmed — the chart's category spotlight, and the
178
- * series switch on top of it.
179
- *
180
- * The captions read this rather than being hidden by `renderSelf`'s guard:
181
- * they are real child nodes, so a `return` in the draw pass would leave them
182
- * on screen over a series that isn't there.
183
- */
184
- private dimAt(index: number): number {
185
- if (!this.enabled) return 0
186
- return clampUnit(this.dims[index] ?? 1)
187
- }
188
-
189
- /** The two ends of column `i` at the current {@link growth}. */
190
- private endsAt(index: number): { base: number; end: number } | null {
191
- const bar = this.bars[index]
192
- if (!bar) return null
193
- const g = clampUnit(this.growth)
194
- const base = this.baseline + (bar.from - this.baseline) * g
195
- const end = this.baseline + (bar.to - this.baseline) * g
196
- return { base, end }
197
- }
198
-
199
- /** The type size the figures are set at, guarding the autofit/unset cases. */
200
- private get valueSize(): number {
201
- const size = this.valueStyle.fontSize
202
- return typeof size === "number" ? size : VALUE_SIZE
203
- }
204
-
205
- /**
206
- * The figure printed on column `i`, or `""` where there is no room for it.
207
- *
208
- * Blanking the text rather than dropping the node keeps the caption list
209
- * index-aligned with the columns, so a chart that rescales into more room
210
- * simply starts printing again.
211
- */
212
- private captionAt(index: number): string {
213
- const bar = this.bars[index]
214
- if (!bar || bar.value === 0) return ""
215
- // An interior segment prints inside itself, so it has to be tall enough to
216
- // hold a line of type; a grouped column prints in the headroom above its
217
- // end, where the only constraint is the plot's own top.
218
- if (this.insideAt(index)) {
219
- const ends = this.endsAt(index)
220
- if (!ends) return ""
221
- if (Math.abs(ends.end - ends.base) < this.valueSize * VALUE_FIT) return ""
222
- }
223
- return this.labelFor(bar.value)
224
- }
225
-
226
- /** Whether column `i`'s figure sits inside the column (a stacked segment) or past its end. */
227
- private insideAt(index: number): boolean {
228
- const bar = this.bars[index]
229
- return bar !== undefined && Math.abs(bar.from - this.baseline) > 0.5
230
- }
231
-
232
- /** Where column `i`'s figure sits, in the node's centred y-up space. */
233
- private captionY(index: number): number {
234
- const ends = this.endsAt(index)
235
- if (!ends) return 0
236
- if (this.insideAt(index)) return (ends.base + ends.end) / 2
237
- // Text is centred on its `y`, so clear the column's end by the gap plus half
238
- // a line — on whichever side of the baseline the column actually reached.
239
- const direction = ends.end >= ends.base ? 1 : -1
240
- return ends.end + direction * (VALUE_GAP + this.valueSize / 2)
241
- }
242
-
243
- /**
244
- * This node extends `Node2D` rather than `ShapeNode`, so nothing declares the
245
- * paint props it carries — see {@link declarePaints}. The column stroke and
246
- * shadow are named past the conventional slots, so they are passed explicitly.
247
- */
248
- override declareAssets(assets: AssetScope): void {
249
- super.declareAssets(assets)
250
- declarePaints(this, assets, {
251
- fills: (this.barShadow as ShadowResolved[]).map((shadow) => shadow.fill),
252
- strokes: [this.barStroke as StrokeResolved[]],
253
- })
254
- }
255
-
256
- protected renderSelf(ctx: RenderContext2D): void {
257
- if (!this.enabled) return
258
- for (let i = 0; i < this.bars.length; i++) {
259
- const bar = this.bars[i]
260
- const ends = this.endsAt(i)
261
- if (!ends || bar.width <= 0) continue
262
- const height = Math.abs(ends.end - ends.base)
263
- if (height <= 0) continue
264
-
265
- const radius = bar.round ? Math.min(this.cornerRadius, height) : 0
266
- // A column per `Graphics2D` rather than one list for the series: the
267
- // category spotlight dims individual columns, and opacity is a property
268
- // of the command list rather than of a shape within it.
269
- const g = new Graphics2D()
270
- const rect = g.rect({
271
- x: bar.x,
272
- y: (ends.base + ends.end) / 2,
273
- width: bar.width,
274
- height,
275
- // This node's space is y-up, but the renderer's corner-radius keys are
276
- // labelled in canvas y-down space — so a column reaching *up* rounds
277
- // through the `bottom*` keys, and one reaching down through the `top*`
278
- // ones. Only the far end rounds, which is what makes a stack read as a
279
- // single capped column.
280
- cornerRadius:
281
- radius > 0
282
- ? ends.end >= ends.base
283
- ? { bottomLeft: radius, bottomRight: radius, topLeft: 0, topRight: 0 }
284
- : { topLeft: radius, topRight: radius, bottomLeft: 0, bottomRight: 0 }
285
- : 0,
286
- })
287
- rect.shadow(this.barShadow)
288
- rect.fill(this.fill)
289
- rect.stroke(this.barStroke)
290
- ctx.draw(g.opacity(this.dimAt(i)))
291
- }
292
- }
293
-
294
- /**
295
- * Raise every column from the baseline by tweening {@link growth} `0 → 1`.
296
- *
297
- * The mirror of `ChartLine.enter`: same signature, same contract that the
298
- * resting state is the finished one, so the chart arms this back to 0 before
299
- * animating rather than the node hiding itself at construction.
300
- */
301
- @command()
302
- enter(args: CommandArgs<Record<string, never>> & { duration: number }): Command<ChartBarsGrowth> {
303
- // No priming to 0 first: the value at `t` is a function of `t`, so a repeat
304
- // call re-raises by definition rather than by having reset something.
305
- return this.command<ChartBarsGrowth>((t) => ({ growth: t }), args.duration, args.easing ?? easeOut())
306
- }
307
- }
283
+ ctx.children()
284
+ },
285
+ })
308
286
 
309
- /** The one signal {@link ChartBars.enter} moves. */
310
- type ChartBarsGrowth = { growth: number }
287
+ export type ChartBars = NodeOf<typeof ChartBars>