@motionscript/charts 0.1.1-alpha.0 → 0.1.1-alpha.2

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 (207) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +109 -28
  3. package/dist/bar-chart/bar-chart.d.ts +265 -39
  4. package/dist/bar-chart/bar-chart.d.ts.map +1 -1
  5. package/dist/bar-chart/bar-chart.js +219 -171
  6. package/dist/bar-chart/bar-chart.js.map +1 -1
  7. package/dist/bar-chart/category-axis.d.ts +12 -7
  8. package/dist/bar-chart/category-axis.d.ts.map +1 -1
  9. package/dist/bar-chart/category-axis.js +56 -42
  10. package/dist/bar-chart/category-axis.js.map +1 -1
  11. package/dist/bar-chart/chart-bars.d.ts +76 -52
  12. package/dist/bar-chart/chart-bars.d.ts.map +1 -1
  13. package/dist/bar-chart/chart-bars.js +105 -89
  14. package/dist/bar-chart/chart-bars.js.map +1 -1
  15. package/dist/bar-chart/index.d.ts +4 -4
  16. package/dist/bar-chart/index.d.ts.map +1 -1
  17. package/dist/bar-chart/index.js +2 -2
  18. package/dist/bar-chart/index.js.map +1 -1
  19. package/dist/bar-chart/shared.d.ts +41 -65
  20. package/dist/bar-chart/shared.d.ts.map +1 -1
  21. package/dist/bar-chart/shared.js +73 -117
  22. package/dist/bar-chart/shared.js.map +1 -1
  23. package/dist/browser/chunks/chunk-7XCRX6GE.js +2 -0
  24. package/dist/browser/chunks/chunk-7XCRX6GE.js.map +7 -0
  25. package/dist/browser/index.js +1 -1
  26. package/dist/browser/index.js.map +4 -4
  27. package/dist/browser/kit.js +1 -1
  28. package/dist/browser/manifest.json +1 -1
  29. package/dist/engine.d.ts +5 -2
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +5 -2
  32. package/dist/engine.js.map +1 -1
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/kit/cartesian.d.ts +51 -15
  38. package/dist/kit/cartesian.d.ts.map +1 -1
  39. package/dist/kit/cartesian.js +81 -29
  40. package/dist/kit/cartesian.js.map +1 -1
  41. package/dist/kit/chart-canvas.d.ts +32 -24
  42. package/dist/kit/chart-canvas.d.ts.map +1 -1
  43. package/dist/kit/chart-canvas.js +52 -46
  44. package/dist/kit/chart-canvas.js.map +1 -1
  45. package/dist/kit/chart-legend.d.ts +8 -4
  46. package/dist/kit/chart-legend.d.ts.map +1 -1
  47. package/dist/kit/chart-legend.js +30 -20
  48. package/dist/kit/chart-legend.js.map +1 -1
  49. package/dist/{line-chart → kit}/horizontal-axis.d.ts +31 -6
  50. package/dist/kit/horizontal-axis.d.ts.map +1 -0
  51. package/dist/{line-chart → kit}/horizontal-axis.js +23 -24
  52. package/dist/kit/horizontal-axis.js.map +1 -0
  53. package/dist/kit/motion.d.ts +5 -0
  54. package/dist/kit/motion.d.ts.map +1 -1
  55. package/dist/kit/motion.js +8 -0
  56. package/dist/kit/motion.js.map +1 -1
  57. package/dist/{pie-chart → kit}/pie-legend.d.ts +3 -3
  58. package/dist/kit/pie-legend.d.ts.map +1 -0
  59. package/dist/{pie-chart → kit}/pie-legend.js +11 -10
  60. package/dist/kit/pie-legend.js.map +1 -0
  61. package/dist/kit/polar.d.ts +31 -0
  62. package/dist/kit/polar.d.ts.map +1 -0
  63. package/dist/kit/polar.js +33 -0
  64. package/dist/kit/polar.js.map +1 -0
  65. package/dist/kit/shared.d.ts +11 -50
  66. package/dist/kit/shared.d.ts.map +1 -1
  67. package/dist/kit/shared.js +58 -141
  68. package/dist/kit/shared.js.map +1 -1
  69. package/dist/kit/style.d.ts +517 -0
  70. package/dist/kit/style.d.ts.map +1 -0
  71. package/dist/kit/style.js +292 -0
  72. package/dist/kit/style.js.map +1 -0
  73. package/dist/kit/vertical-axis.d.ts +22 -2
  74. package/dist/kit/vertical-axis.d.ts.map +1 -1
  75. package/dist/kit/vertical-axis.js +56 -43
  76. package/dist/kit/vertical-axis.js.map +1 -1
  77. package/dist/line-chart/chart-line.d.ts +95 -3
  78. package/dist/line-chart/chart-line.d.ts.map +1 -1
  79. package/dist/line-chart/chart-line.js +165 -53
  80. package/dist/line-chart/chart-line.js.map +1 -1
  81. package/dist/line-chart/chart-region.d.ts +1 -1
  82. package/dist/line-chart/chart-region.d.ts.map +1 -1
  83. package/dist/line-chart/chart-region.js +7 -8
  84. package/dist/line-chart/chart-region.js.map +1 -1
  85. package/dist/line-chart/index.d.ts +5 -6
  86. package/dist/line-chart/index.d.ts.map +1 -1
  87. package/dist/line-chart/index.js +3 -4
  88. package/dist/line-chart/index.js.map +1 -1
  89. package/dist/line-chart/line-chart.d.ts +281 -18
  90. package/dist/line-chart/line-chart.d.ts.map +1 -1
  91. package/dist/line-chart/line-chart.js +284 -134
  92. package/dist/line-chart/line-chart.js.map +1 -1
  93. package/dist/line-chart/shared.d.ts +51 -34
  94. package/dist/line-chart/shared.d.ts.map +1 -1
  95. package/dist/line-chart/shared.js +175 -83
  96. package/dist/line-chart/shared.js.map +1 -1
  97. package/dist/line-chart/x-scale.d.ts +2 -0
  98. package/dist/line-chart/x-scale.d.ts.map +1 -1
  99. package/dist/line-chart/x-scale.js +15 -0
  100. package/dist/line-chart/x-scale.js.map +1 -1
  101. package/dist/nodes.d.ts +3 -1
  102. package/dist/nodes.d.ts.map +1 -1
  103. package/dist/nodes.js +9 -1
  104. package/dist/nodes.js.map +1 -1
  105. package/dist/pie-chart/index.d.ts +2 -3
  106. package/dist/pie-chart/index.d.ts.map +1 -1
  107. package/dist/pie-chart/index.js +1 -2
  108. package/dist/pie-chart/index.js.map +1 -1
  109. package/dist/pie-chart/pie-chart.d.ts +114 -18
  110. package/dist/pie-chart/pie-chart.d.ts.map +1 -1
  111. package/dist/pie-chart/pie-chart.js +38 -24
  112. package/dist/pie-chart/pie-chart.js.map +1 -1
  113. package/dist/pie-chart/pie-ring.d.ts +16 -5
  114. package/dist/pie-chart/pie-ring.d.ts.map +1 -1
  115. package/dist/pie-chart/pie-ring.js +89 -55
  116. package/dist/pie-chart/pie-ring.js.map +1 -1
  117. package/dist/pie-chart/shared.d.ts +61 -62
  118. package/dist/pie-chart/shared.d.ts.map +1 -1
  119. package/dist/pie-chart/shared.js +44 -123
  120. package/dist/pie-chart/shared.js.map +1 -1
  121. package/dist/radar-chart/index.d.ts +5 -0
  122. package/dist/radar-chart/index.d.ts.map +1 -0
  123. package/dist/radar-chart/index.js +5 -0
  124. package/dist/radar-chart/index.js.map +1 -0
  125. package/dist/radar-chart/radar-chart.d.ts +212 -0
  126. package/dist/radar-chart/radar-chart.d.ts.map +1 -0
  127. package/dist/radar-chart/radar-chart.js +224 -0
  128. package/dist/radar-chart/radar-chart.js.map +1 -0
  129. package/dist/radar-chart/radar-grid.d.ts +49 -0
  130. package/dist/radar-chart/radar-grid.d.ts.map +1 -0
  131. package/dist/radar-chart/radar-grid.js +63 -0
  132. package/dist/radar-chart/radar-grid.js.map +1 -0
  133. package/dist/radar-chart/radar-series.d.ts +59 -0
  134. package/dist/radar-chart/radar-series.d.ts.map +1 -0
  135. package/dist/radar-chart/radar-series.js +71 -0
  136. package/dist/radar-chart/radar-series.js.map +1 -0
  137. package/dist/radar-chart/shared.d.ts +78 -0
  138. package/dist/radar-chart/shared.d.ts.map +1 -0
  139. package/dist/radar-chart/shared.js +129 -0
  140. package/dist/radar-chart/shared.js.map +1 -0
  141. package/dist/radial-chart/index.d.ts +6 -0
  142. package/dist/radial-chart/index.d.ts.map +1 -0
  143. package/dist/radial-chart/index.js +4 -0
  144. package/dist/radial-chart/index.js.map +1 -0
  145. package/dist/radial-chart/radial-chart.d.ts +210 -0
  146. package/dist/radial-chart/radial-chart.d.ts.map +1 -0
  147. package/dist/radial-chart/radial-chart.js +175 -0
  148. package/dist/radial-chart/radial-chart.js.map +1 -0
  149. package/dist/radial-chart/radial-rings.d.ts +121 -0
  150. package/dist/radial-chart/radial-rings.d.ts.map +1 -0
  151. package/dist/radial-chart/radial-rings.js +273 -0
  152. package/dist/radial-chart/radial-rings.js.map +1 -0
  153. package/dist/radial-chart/shared.d.ts +77 -0
  154. package/dist/radial-chart/shared.d.ts.map +1 -0
  155. package/dist/radial-chart/shared.js +78 -0
  156. package/dist/radial-chart/shared.js.map +1 -0
  157. package/package.json +4 -4
  158. package/registry.json +21 -6
  159. package/src/bar-chart/bar-chart.ts +263 -179
  160. package/src/bar-chart/category-axis.ts +46 -40
  161. package/src/bar-chart/chart-bars.ts +136 -117
  162. package/src/bar-chart/index.ts +7 -10
  163. package/src/bar-chart/shared.ts +88 -233
  164. package/src/engine.ts +5 -2
  165. package/src/index.ts +2 -0
  166. package/src/kit/cartesian.ts +121 -40
  167. package/src/kit/chart-canvas.ts +49 -54
  168. package/src/kit/chart-legend.ts +28 -20
  169. package/src/kit/horizontal-axis.ts +114 -0
  170. package/src/kit/motion.ts +7 -0
  171. package/src/{pie-chart → kit}/pie-legend.ts +12 -11
  172. package/src/kit/polar.ts +54 -0
  173. package/src/kit/shared.ts +55 -277
  174. package/src/kit/style.ts +353 -0
  175. package/src/kit/vertical-axis.ts +67 -48
  176. package/src/line-chart/chart-line.ts +176 -54
  177. package/src/line-chart/chart-region.ts +6 -10
  178. package/src/line-chart/index.ts +14 -10
  179. package/src/line-chart/line-chart.ts +353 -151
  180. package/src/line-chart/shared.ts +193 -174
  181. package/src/line-chart/x-scale.ts +13 -0
  182. package/src/nodes.ts +9 -1
  183. package/src/pie-chart/index.ts +2 -23
  184. package/src/pie-chart/pie-chart.ts +54 -35
  185. package/src/pie-chart/pie-ring.ts +92 -71
  186. package/src/pie-chart/shared.ts +80 -270
  187. package/src/radar-chart/index.ts +4 -0
  188. package/src/radar-chart/radar-chart.ts +290 -0
  189. package/src/radar-chart/radar-grid.ts +83 -0
  190. package/src/radar-chart/radar-series.ts +88 -0
  191. package/src/radar-chart/shared.ts +153 -0
  192. package/src/radial-chart/index.ts +5 -0
  193. package/src/radial-chart/radial-chart.ts +212 -0
  194. package/src/radial-chart/radial-rings.ts +328 -0
  195. package/src/radial-chart/shared.ts +126 -0
  196. package/dist/browser/chunks/chunk-4SMRCU24.js +0 -2
  197. package/dist/browser/chunks/chunk-4SMRCU24.js.map +0 -7
  198. package/dist/kit/chart-border.d.ts +0 -18
  199. package/dist/kit/chart-border.d.ts.map +0 -1
  200. package/dist/kit/chart-border.js +0 -23
  201. package/dist/kit/chart-border.js.map +0 -1
  202. package/dist/line-chart/horizontal-axis.d.ts.map +0 -1
  203. package/dist/line-chart/horizontal-axis.js.map +0 -1
  204. package/dist/pie-chart/pie-legend.d.ts.map +0 -1
  205. package/dist/pie-chart/pie-legend.js.map +0 -1
  206. package/src/kit/chart-border.ts +0 -36
  207. package/src/line-chart/horizontal-axis.ts +0 -122
@@ -4,8 +4,6 @@ import {
4
4
  Types,
5
5
  clamp,
6
6
  component2d,
7
- insetsOps,
8
- textOps,
9
7
  type NodeOf,
10
8
  // Named so this component's emitted type reaches `Node2D` and its props through the sdk, not core.
11
9
  type Node2D,
@@ -13,21 +11,25 @@ import {
13
11
  } from "@motionscript/sdk"
14
12
  import { enterBudget } from "@motionscript/sdk/component"
15
13
 
14
+ import { boundText } from "../kit/cartesian"
16
15
  import { asParts, fresh } from "../kit/entries"
17
16
  import { armed, arrival, arrivalOf, arrivalsEntry, beats, frameEase, type Arrival } from "../kit/motion"
18
- import {
19
- DEFAULT_X_AXIS,
20
- type AxisConfig,
21
- } from "../kit/shared"
17
+ import { DEFAULT_X_AXIS, type AxisConfig } from "../kit/shared"
18
+ import { axisPads, captionColumn } from "../kit/vertical-axis"
22
19
 
23
20
  const SLIDE = 24
24
21
 
22
+ /** Slot `index`'s centre along a run of `length` px divided into `count` equal slots, first slot at `-length / 2`. */
23
+ export function slotCentre(index: number, count: number, length: number): number {
24
+ return count <= 0 ? 0 : -length / 2 + (length / count) * (index + 0.5)
25
+ }
26
+
25
27
  /**
26
28
  * The categorical axis of a {@link BarChart}: one {@link Text} caption per row,
27
- * centred on its slot. Not folded into `HorizontalAxis`, which places markers by
28
- * value on a scale; this divides its width into equal slots. It is laid out to
29
- * exactly the plot's width, so each caption lands under its columns with neither
30
- * side told about the other. The resting state is landed.
29
+ * centred on its slot. `horizontal` is a row under the plot, first category on
30
+ * the left; `vertical` a right-aligned column beside it, first category at the
31
+ * top. It is laid out to exactly the plot's length, so each caption lands by its
32
+ * bars with neither side told about the other. The resting state is landed.
31
33
  */
32
34
  export const CategoryAxis = component2d({
33
35
  key: "categoryAxis",
@@ -35,64 +37,69 @@ export const CategoryAxis = component2d({
35
37
  categories: fresh<string[]>(() => []),
36
38
  axis: Types.custom<AxisConfig>(DEFAULT_X_AXIS),
37
39
  label: "",
38
- /** Per-category opacity multiplier, so the caption under a dimmed column dims with it. Missing reads as 1. */
40
+ orientation: Types.oneOf(["horizontal", "vertical"] as const),
41
+ /** Per-category opacity multiplier, so the caption by a dimmed bar dims with it. Missing reads as 1. */
39
42
  dims: fresh<number[]>(() => []),
40
43
  arrivals: arrivalsEntry(),
41
44
  },
42
45
  // A node made of captions would otherwise hug them.
43
46
  defaults: { width: "fill", height: "fill" },
44
47
  children: (state) => {
45
- const tickPad = insetsOps.resolve(state.axis.tickPadding).top
46
- const titlePad = insetsOps.resolve(state.axis.titlePadding).top
47
-
48
- const column: Node2D[] = []
49
- // The column's parent is the axis: its box is what the slots divide.
50
- const width = () => (column[0]?.parent as Node2D | null | undefined)?.layoutBounds.width ?? 0
51
- const slotCenter = (index: number) => {
52
- const n = state.categories.length
53
- if (n <= 0) return 0
54
- return -width() / 2 + (width() / n) * (index + 0.5)
48
+ const n = state.categories.length
49
+ const dim = (i: number) => clamp(state.dims[i] ?? 1, 0, 1)
50
+ if (state.orientation === "vertical") {
51
+ return asParts([
52
+ captionColumn({
53
+ count: n,
54
+ axis: () => state.axis,
55
+ text: (i) => state.categories[i] ?? "",
56
+ // y-up: the first category takes the top slot.
57
+ place: (i, height) => -slotCentre(i, state.categories.length, height),
58
+ arrivals: () => state.arrivals,
59
+ opacity: dim,
60
+ label: state.label,
61
+ }),
62
+ ])
55
63
  }
56
64
 
65
+ const pads = () => axisPads(state.axis, "top")
57
66
  // The spotlight dim sits on a hug box around each caption, so the entrance animates the text's own opacity without the two fighting over one value.
58
- const slots = state.categories.map((caption, i) =>
59
- new Rect({
67
+ const captions = state.categories.map((caption, i) => {
68
+ const slot: Node2D = new Rect({
60
69
  width: "hug",
61
70
  height: "hug",
62
71
  flow: "freeform",
63
- opacity: () => clamp(state.dims[i] ?? 1, 0, 1),
64
- x: () => slotCenter(i),
72
+ opacity: () => dim(i),
73
+ x: () => slotCentre(i, state.categories.length, (slot.parent as Node2D | null)?.layoutBounds.width ?? 0),
65
74
  children: [
66
75
  new Text({
67
- ...textOps.styleProps(state.axis.labelStyle),
76
+ ...boundText(() => state.axis.labelStyle),
68
77
  text: caption,
69
78
  opacity: () => arrivalOf(state.arrivals, i).opacity,
70
79
  y: () => arrivalOf(state.arrivals, i).shift,
71
80
  }),
72
81
  ],
73
- })
74
- )
75
-
76
- const children: Node2D[] = [new Rect({ width: "fill", height: "hug", padding: { top: tickPad }, children: slots })]
82
+ }) as unknown as Node2D
83
+ return slot
84
+ })
85
+ const children: Node2D[] = [new Rect({ width: "fill", height: "hug", padding: () => ({ top: pads().tick }), children: captions }) as unknown as Node2D]
77
86
  if (state.label !== "") {
78
87
  children.push(
79
88
  new Text({
80
- ...textOps.styleProps(state.axis.titleStyle),
89
+ ...boundText(() => state.axis.titleStyle),
81
90
  text: state.label,
82
91
  opacity: () => arrivalOf(state.arrivals, "label").opacity,
83
92
  y: () => arrivalOf(state.arrivals, "label").shift,
84
- })
93
+ }) as unknown as Node2D
85
94
  )
86
95
  }
87
-
88
- column.push(new Rect({ flow: "vertical", gap: titlePad, height: "hug", width: "fill", children }))
89
- return asParts(column)
96
+ return asParts([new Rect({ flow: "vertical", gap: () => pads().title, height: "hug", width: "fill", children }) as unknown as Node2D])
90
97
  },
91
98
  commands: {
92
99
  /**
93
- * Slide and fade captions in left to right, then the title. The duration is
94
- * the whole entrance ({@link enterBudget}). `stagger` is seconds between
95
- * captions (derived when omitted); `slide` is the starting offset in px.
100
+ * Slide and fade captions in, first category first, then the title. The
101
+ * duration is the whole entrance ({@link enterBudget}). `stagger` is seconds
102
+ * between captions (derived when omitted); `slide` is the starting offset in px.
96
103
  */
97
104
  enter: {
98
105
  args: {
@@ -109,9 +116,8 @@ export const CategoryAxis = component2d({
109
116
  if (count === 0) return arm
110
117
  const { step, per, caption } = enterBudget(timeline.duration, count, captioned, stagger)
111
118
  const ease = frameEase(timeline)
112
- const lastCaptionEnds = (count - 1) * step + per
113
119
  const captions = state.categories.map((_, i) => ({ at: i * step, duration: per, piece: arrival(timeline, i, from, ease) }))
114
- const label = captioned ? [{ at: lastCaptionEnds, duration: caption, piece: arrival(timeline, "label", from, ease) }] : []
120
+ const label = captioned ? [{ at: (count - 1) * step + per, duration: caption, piece: arrival(timeline, "label", from, ease) }] : []
115
121
  return timeline.together([arm, beats(timeline, [...captions, ...label])])
116
122
  },
117
123
  },
@@ -5,168 +5,157 @@ import {
5
5
  clamp,
6
6
  component2d,
7
7
  fillOps,
8
- shadowOps,
9
- strokeOps,
10
- textOps,
11
8
  type Fill,
12
9
  type FillResolved,
13
10
  type NodeOf,
14
11
  // Named so this component's emitted type reaches `Node2D` and its props through the sdk, not core.
15
- type Node2D as _Node2D,
12
+ type Node2D,
16
13
  type Node2DProps as _Node2DProps,
17
- type Shadow,
18
- type ShadowResolved,
19
- type Stroke,
20
- type StrokeResolved,
14
+ type RectCornerRadius as _RectCornerRadius,
15
+ type RectCornerStyle as _RectCornerStyle,
21
16
  type TextStyle,
17
+ type CornerRadiusResolved,
18
+ type CornerStyleResolved,
22
19
  } from "@motionscript/sdk"
23
20
 
24
- import { fresh } from "../kit/entries"
21
+ import { boundText } from "../kit/cartesian"
22
+ import { asParts, fresh } from "../kit/entries"
25
23
  import { frameEase } from "../kit/motion"
26
24
  import { loadPaints } from "../kit/paints"
27
25
  import { formatMarker } from "../kit/shared"
28
- import { DEFAULT_BAR_CHART_THEME } from "./shared"
26
+ import { inkOn } from "./shared"
29
27
 
30
28
  /**
31
- * One drawn column in the plot's centred pixel space (origin at the centre,
32
- * y-up), mapped by the parent {@link BarChart}. `from`/`to` are the two ends
33
- * rather than a base and a height, like {@link BarSpan}: one shape fits grouped,
34
- * stacked and negative.
29
+ * One drawn bar in the plot's centred pixel space (origin at the centre, y-up),
30
+ * mapped by the parent {@link BarChart}: `center` and `thickness` across the
31
+ * category slot, `from` and `to` along the value axis (y for a column, x for a
32
+ * horizontal bar). Two ends rather than a base and a length, like `BarSpan`:
33
+ * one shape fits grouped, stacked and negative.
35
34
  */
36
35
  export interface BarGeometry {
37
- /** Centre x. */
38
- x: number
39
- width: number
40
- /** Pixel y of the base: the baseline when grouped, the running stack top when stacked. */
36
+ center: number
37
+ thickness: number
38
+ /** The base: the baseline when grouped, the running stack end when stacked. */
41
39
  from: number
42
- /** Pixel y of the far end at full growth. */
40
+ /** The far end at full growth. */
43
41
  to: number
44
- /** Whether the far end takes the corner radius (the stack's cap). */
42
+ /** Whether the far end takes the far corners (the stack's last segment on its side). */
45
43
  round: boolean
46
- /** The value plotted, for the printed figure. */
47
- value: number
48
- /** The printed figure, written as the value axis writes its ticks; absent, the bare marker reading of `value`. */
49
- caption?: string
44
+ /** Whether the base takes the base corners (it stands on the baseline: every grouped bar, a stack's first segment). */
45
+ grounded?: boolean
46
+ /** The figure printed by the bar; empty for none. */
47
+ caption: string
50
48
  }
51
49
 
52
- /** Gap between a grouped column's end and the figure printed over it, in px. */
53
- const VALUE_GAP = 10
54
- /** A segment shorter than this many multiples of the type size prints no figure. */
55
- const VALUE_FIT = 1.6
56
- /** Fallback type size when the theme's value style leaves it unset or autofit. */
57
- const VALUE_SIZE = 24
50
+ /** A figure inside a bar needs this much room around it, in multiples of the type size. */
51
+ const INSIDE_ROOM = 0.6
58
52
 
59
- interface Columns {
53
+ interface Bars {
60
54
  readonly bars: readonly BarGeometry[]
61
55
  readonly baseline: number
56
+ readonly horizontal: boolean
62
57
  readonly growth: number
63
58
  readonly dims: readonly number[]
64
59
  readonly enabled: boolean
60
+ readonly values: "none" | "end" | "inside"
65
61
  readonly valueStyle: TextStyle
66
62
  }
67
63
 
68
- /**
69
- * Column `i`'s dimming: the category spotlight, then the series switch. Captions
70
- * read this because they are child nodes, which a `render` guard would leave on
71
- * screen over a series that is not there.
72
- */
73
- function dimAt(s: Columns, index: number): number {
64
+ /** Bar `i`'s dimming: the category spotlight, then the series switch. */
65
+ function dimAt(s: Bars, index: number): number {
74
66
  if (!s.enabled) return 0
75
67
  return clamp(s.dims[index] ?? 1, 0, 1)
76
68
  }
77
69
 
78
- function endsAt(s: Columns, index: number): { base: number; end: number } | null {
70
+ /** Bar `i`'s two ends as grown so far, along the value axis. */
71
+ function endsAt(s: Bars, index: number): { base: number; end: number } | null {
79
72
  const bar = s.bars[index]
80
73
  if (!bar) return null
81
74
  const g = clamp(s.growth, 0, 1)
82
75
  return { base: s.baseline + (bar.from - s.baseline) * g, end: s.baseline + (bar.to - s.baseline) * g }
83
76
  }
84
77
 
85
- function valueSize(s: Columns): number {
86
- const size = s.valueStyle.fontSize
87
- return typeof size === "number" ? size : VALUE_SIZE
78
+ function sizeOf(style: TextStyle): number {
79
+ return typeof style.fontSize === "number" ? style.fontSize : 24
88
80
  }
89
81
 
90
- function insideAt(s: Columns, index: number): boolean {
91
- const bar = s.bars[index]
92
- return bar !== undefined && Math.abs(bar.from - s.baseline) > 0.5
82
+ /** Where bar `i`'s figure sits along the value axis: past its end, or in its middle. */
83
+ function captionAlong(s: Bars, index: number, extent: number): number {
84
+ const ends = endsAt(s, index)
85
+ if (!ends) return 0
86
+ if (s.values === "inside") return (ends.base + ends.end) / 2
87
+ const direction = ends.end >= ends.base ? 1 : -1
88
+ return ends.end + direction * (sizeOf(s.valueStyle) * 0.4 + extent / 2)
93
89
  }
94
90
 
95
- /** Column `i`'s figure, or `""` where there is no room. Blanking rather than dropping the node keeps captions index-aligned, so a rescale into more room prints again. */
96
- function captionAt(s: Columns, index: number): string {
91
+ /** Whether bar `i` has room for a figure `extent` long inside it. */
92
+ function fitsInside(s: Bars, index: number, extent: number, across: number): boolean {
97
93
  const bar = s.bars[index]
98
- if (!bar || bar.value === 0) return ""
99
- // An interior segment prints inside itself and must hold a line of type; a grouped column prints past its end.
100
- if (insideAt(s, index)) {
101
- const ends = endsAt(s, index)
102
- if (!ends) return ""
103
- if (Math.abs(ends.end - ends.base) < valueSize(s) * VALUE_FIT) return ""
104
- }
105
- return bar.caption ?? formatMarker(bar.value)
106
- }
107
-
108
- function captionY(s: Columns, index: number): number {
109
94
  const ends = endsAt(s, index)
110
- if (!ends) return 0
111
- if (insideAt(s, index)) return (ends.base + ends.end) / 2
112
- // Text is centred on `y`: clear the end by the gap plus half a line, on the side the column reached.
113
- const direction = ends.end >= ends.base ? 1 : -1
114
- return ends.end + direction * (VALUE_GAP + valueSize(s) / 2)
95
+ if (!bar || !ends) return false
96
+ const room = sizeOf(s.valueStyle) * INSIDE_ROOM
97
+ return Math.abs(ends.end - ends.base) >= extent + room && bar.thickness >= across
115
98
  }
116
99
 
117
100
  /**
118
- * One series of a {@link BarChart}: its column in every category plus the
119
- * printed figures. A node per series, like `ChartLine`, so `showSeries` and
101
+ * One series of a {@link BarChart}: its bar in every category plus the printed
102
+ * figures. A node per series, like `ChartLine`, so `showSeries` and
120
103
  * `spotlightSeries` mean the same on both: enable the node or tween its opacity.
121
104
  * Positions come from the parent, which owns the slot arithmetic.
122
- *
123
- * The figures are {@link Text} children, so they take the theme's type style,
124
- * are measured by layout, and hide with the series.
125
105
  */
126
106
  export const ChartBars = component2d({
127
107
  key: "chartBars",
128
108
  state: {
129
109
  bars: fresh<BarGeometry[]>(() => []),
130
- /** Pixel y of value 0, where every column grows from. */
110
+ /** Pixel position of value 0 along the value axis, where every bar grows from. */
131
111
  baseline: 0,
112
+ /** Bars grow along x from a category column rather than along y from a row. */
113
+ horizontal: false,
132
114
  fill: Types.custom<Fill, FillResolved[]>([], { resolve: fillOps.resolve, lerp: fillOps.lerp }),
133
- barStroke: Types.custom<Stroke, StrokeResolved[]>(DEFAULT_BAR_CHART_THEME.bar.stroke, { resolve: strokeOps.resolve, lerp: strokeOps.lerp }),
134
- barShadow: Types.custom<Shadow, ShadowResolved[]>(DEFAULT_BAR_CHART_THEME.bar.shadow, { resolve: shadowOps.resolve, lerp: shadowOps.lerp }),
135
- cornerRadius: DEFAULT_BAR_CHART_THEME.bar.cornerRadius,
115
+ /** As for a bar standing up from the baseline: `topLeft`/`topRight` round its far end, the bottom pair its base. */
116
+ cornerRadius: Types.cornerRadius(8),
117
+ cornerStyle: Types.cornerStyle("rounded"),
136
118
  /**
137
- * Rise of every column in `[0, 1]` about the baseline, not each column's own
138
- * base: every segment travels by the same factor, so a stack stays flush
139
- * mid-animation and a negative column grows down.
119
+ * Rise of every bar in `[0, 1]` about the baseline, not each bar's own base:
120
+ * every segment travels by the same factor, so a stack stays flush
121
+ * mid-animation and a negative bar grows the other way.
140
122
  */
141
123
  growth: 1,
142
- /** Per-column opacity by row index, how the category spotlight reaches one column. Missing reads as 1. */
124
+ /** Per-bar opacity by row index, how the category spotlight reaches one bar. Missing reads as 1. */
143
125
  dims: fresh<number[]>(() => []),
144
- showValues: false,
145
- valueStyle: Types.custom<TextStyle>(DEFAULT_BAR_CHART_THEME.valueStyle),
146
- /** The wholesale switch behind `showSeries`/`hideSeries`; off skips the columns and their figures. */
126
+ /** Read as built: whether each bar prints its figure, and where. */
127
+ values: Types.custom<"none" | "end" | "inside">("none"),
128
+ valueStyle: Types.custom<TextStyle>({ fontSize: 24, fill: "white" }),
129
+ /** The wholesale switch behind `showSeries`/`hideSeries`; off skips the bars and their figures. */
147
130
  enabled: true,
148
131
  },
149
132
  // A node made of its figures would otherwise hug them.
150
133
  defaults: { width: "fill", height: "fill" },
151
- // One caption per column, built once (the row count is fixed at construction)
152
- // and bound to the live geometry so it travels with its column.
134
+ // One figure per bar, built once (the row count is fixed at construction)
135
+ // and bound to the live geometry so it travels with its bar.
153
136
  children: (state) => {
154
- if (!state.showValues) return []
155
- return state.bars.map((_, i) =>
156
- Text({
157
- ...textOps.styleProps(state.valueStyle),
158
- text: () => captionAt(state, i),
159
- x: () => state.bars[i]?.x ?? 0,
160
- y: () => captionY(state, i),
161
- opacity: () => dimAt(state, i),
162
- })
163
- )
137
+ if (state.values === "none") return []
138
+ return asParts(state.bars.map((_, i) => {
139
+ const caption: Node2D = new Text({
140
+ ...boundText(() => ({ ...state.valueStyle, ...(state.values === "inside" && { fill: inkOn(state.fill) }) })),
141
+ text: () => state.bars[i]?.caption ?? "",
142
+ x: () => (state.horizontal ? captionAlong(state, i, caption.layoutBounds.width) : (state.bars[i]?.center ?? 0)),
143
+ y: () => (state.horizontal ? (state.bars[i]?.center ?? 0) : captionAlong(state, i, caption.layoutBounds.height)),
144
+ opacity: () => {
145
+ const box = caption.layoutBounds
146
+ const along = state.horizontal ? box.width : box.height
147
+ const across = state.horizontal ? box.height : box.width
148
+ if (state.values === "inside" && !fitsInside(state, i, along, across)) return 0
149
+ // A figure past the end rises in with its bar's last stretch.
150
+ return dimAt(state, i) * clamp((state.growth - 0.6) / 0.4, 0, 1)
151
+ },
152
+ }) as unknown as Node2D
153
+ return caption
154
+ }))
164
155
  },
165
- // The stroke and shadow sit past the conventional slots, so they are named here.
166
- load: (loader, state) =>
167
- loadPaints(loader, { fills: [state.fill], shadows: [state.barShadow], strokes: [state.barStroke] }),
156
+ load: (loader, state) => loadPaints(loader, { fills: [state.fill] }),
168
157
  commands: {
169
- /** Raise every column from the baseline (`growth` 0 to 1). The resting state is the finished one; the chart arms it back to 0. */
158
+ /** Raise every bar from the baseline (`growth` 0 to 1). The resting state is the finished one; the chart arms it back to 0. */
170
159
  enter: (timeline) => {
171
160
  const ease = frameEase(timeline)
172
161
  return timeline.animate((t) => ({ growth: ease(t) }))
@@ -177,29 +166,19 @@ export const ChartBars = component2d({
177
166
  for (let i = 0; i < state.bars.length; i++) {
178
167
  const bar = state.bars[i]
179
168
  const ends = endsAt(state, i)
180
- if (!ends || bar.width <= 0) continue
181
- const height = Math.abs(ends.end - ends.base)
182
- if (height <= 0) continue
183
-
184
- const radius = bar.round ? Math.min(state.cornerRadius, height) : 0
185
- // One `Graphics2D` per column: opacity belongs to the command list, and the spotlight dims single columns.
186
- const g = new Graphics2D()
187
- const rect = g.rect({
188
- x: bar.x,
189
- y: (ends.base + ends.end) / 2,
190
- width: bar.width,
191
- height,
192
- // Corner keys are visual (y-up): a column reaching up rounds `top*`, one reaching down `bottom*`. Only the far end rounds.
193
- cornerRadius:
194
- radius > 0
195
- ? ends.end >= ends.base
196
- ? { topLeft: radius, topRight: radius, bottomLeft: 0, bottomRight: 0 }
197
- : { bottomLeft: radius, bottomRight: radius, topLeft: 0, topRight: 0 }
198
- : 0,
199
- })
200
- rect.shadow(state.barShadow)
201
- rect.fill(state.fill)
202
- rect.stroke(state.barStroke)
169
+ if (!ends || bar.thickness <= 0) continue
170
+ // A segment inside a stack reaches a pixel under the next, so no seam of background shows between them.
171
+ const seam = bar.round ? 0 : 1
172
+ const length = Math.abs(ends.end - ends.base) + seam
173
+ if (length <= seam) continue
174
+ const forward = ends.end >= ends.base
175
+ const mid = (ends.base + ends.end) / 2 + (forward ? seam : -seam) / 2
176
+ const corners = barCorners(state, bar, forward, length)
177
+ // One `Graphics2D` per bar: opacity belongs to the command list, and the spotlight dims single bars.
178
+ const g = new Graphics2D().rect(state.horizontal
179
+ ? { x: mid, y: bar.center, width: length, height: bar.thickness, ...corners }
180
+ : { x: bar.center, y: mid, width: bar.thickness, height: length, ...corners })
181
+ g.fill(state.fill)
203
182
  ctx.draw(g.opacity(dimAt(state, i)))
204
183
  }
205
184
  }
@@ -208,3 +187,43 @@ export const ChartBars = component2d({
208
187
  })
209
188
 
210
189
  export type ChartBars = NodeOf<typeof ChartBars>
190
+
191
+ type Corners<T> = { topLeft: T; topRight: T; bottomRight: T; bottomLeft: T }
192
+
193
+ /**
194
+ * Turns corners written for a bar standing up into the drawn bar's visual
195
+ * (y-up) corners: mirrored for a bar below zero, turned a quarter clockwise
196
+ * for one growing right, and both for one growing left.
197
+ */
198
+ function orient<T>(c: Corners<T>, horizontal: boolean, forward: boolean): Corners<T> {
199
+ if (!horizontal) return forward ? c : { topLeft: c.bottomLeft, topRight: c.bottomRight, bottomRight: c.topRight, bottomLeft: c.topLeft }
200
+ return forward
201
+ ? { topLeft: c.bottomLeft, topRight: c.topLeft, bottomRight: c.topRight, bottomLeft: c.bottomRight }
202
+ : { topLeft: c.topLeft, topRight: c.bottomLeft, bottomRight: c.bottomRight, bottomLeft: c.topRight }
203
+ }
204
+
205
+ /** Bar `bar`'s rect corners: the far pair only where its stack ends, the base pair only on the baseline, each held inside the bar. */
206
+ export function barCorners(
207
+ state: { readonly cornerRadius: CornerRadiusResolved; readonly cornerStyle: CornerStyleResolved; readonly horizontal: boolean },
208
+ bar: BarGeometry,
209
+ forward: boolean,
210
+ length: number
211
+ ): { cornerRadius: Corners<number>; cornerStyle: CornerStyleResolved } {
212
+ const grounded = bar.grounded ?? true
213
+ const r = state.cornerRadius
214
+ const ends = (bar.round ? 1 : 0) + (grounded ? 1 : 0)
215
+ const cap = Math.max(0, Math.min(bar.thickness / 2, ends > 1 ? length / 2 : length))
216
+ const fit = (v: number, on: boolean) => (on ? Math.max(0, Math.min(v, cap)) : 0)
217
+ const upright: Corners<number> = {
218
+ topLeft: fit(r.topLeft, bar.round),
219
+ topRight: fit(r.topRight, bar.round),
220
+ bottomRight: fit(r.bottomRight, grounded),
221
+ bottomLeft: fit(r.bottomLeft, grounded),
222
+ }
223
+ return { cornerRadius: orient(upright, state.horizontal, forward), cornerStyle: orient(state.cornerStyle, state.horizontal, forward) }
224
+ }
225
+
226
+ /** A figure as the bar prints it when the chart gives no caption. */
227
+ export function barCaption(value: number): string {
228
+ return value === 0 ? "" : formatMarker(value)
229
+ }
@@ -1,23 +1,20 @@
1
1
  export { BarChart, barChartDomain } from "./bar-chart"
2
- export type { BarChartDomain, BarChartValues } from "./bar-chart"
2
+ export type { BarChartDomain, BarChartStyle, BarChartValues } from "./bar-chart"
3
3
  export { ChartBars } from "./chart-bars"
4
4
  export type { BarGeometry } from "./chart-bars"
5
- export { CategoryAxis } from "./category-axis"
5
+ export { CategoryAxis, slotCentre } from "./category-axis"
6
6
  export {
7
- DEFAULT_BAR_CHART_THEME,
7
+ BAR_LABELS,
8
+ BAR_MARKS,
8
9
  barCategories,
9
10
  barSpans,
10
11
  barValueExtent,
11
- lerpBarChartTheme,
12
- resolveBarChartTheme,
13
- resolveBarFill,
12
+ inkOn,
14
13
  resolveBarScale,
15
- stackCapIndex,
16
14
  } from "./shared"
17
15
  export type {
18
- BarChartConfig,
19
- BarChartTheme,
16
+ BarOrientation,
20
17
  BarSeries,
21
18
  BarSpan,
22
- BarStyleConfig,
19
+ BarStack,
23
20
  } from "./shared"