panelui-native 0.63.0 → 0.66.1

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 (226) hide show
  1. package/README.md +5 -0
  2. package/lib/module/components/badge/index.js +1 -1
  3. package/lib/module/components/badge/index.js.map +1 -1
  4. package/lib/module/components/bar-chart/index.js +39 -2
  5. package/lib/module/components/bar-chart/index.js.map +1 -1
  6. package/lib/module/components/bottom-sheet/index.js +2 -2
  7. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  8. package/lib/module/components/button/index.js +19 -9
  9. package/lib/module/components/button/index.js.map +1 -1
  10. package/lib/module/components/carousel/carousel-lifecycle.js +51 -0
  11. package/lib/module/components/carousel/carousel-lifecycle.js.map +1 -0
  12. package/lib/module/components/carousel/index.js +86 -35
  13. package/lib/module/components/carousel/index.js.map +1 -1
  14. package/lib/module/components/checkbox/index.js +2 -3
  15. package/lib/module/components/checkbox/index.js.map +1 -1
  16. package/lib/module/components/context-menu/context-menu-invocation.js +23 -0
  17. package/lib/module/components/context-menu/context-menu-invocation.js.map +1 -0
  18. package/lib/module/components/context-menu/index.js +78 -9
  19. package/lib/module/components/context-menu/index.js.map +1 -1
  20. package/lib/module/components/dialog/index.js +2 -2
  21. package/lib/module/components/dialog/index.js.map +1 -1
  22. package/lib/module/components/drawer/index.js +2 -2
  23. package/lib/module/components/drawer/index.js.map +1 -1
  24. package/lib/module/components/fab/index.js +4 -3
  25. package/lib/module/components/fab/index.js.map +1 -1
  26. package/lib/module/components/field/index.js +41 -6
  27. package/lib/module/components/field/index.js.map +1 -1
  28. package/lib/module/components/flow/flow-accessibility.js +100 -0
  29. package/lib/module/components/flow/flow-accessibility.js.map +1 -0
  30. package/lib/module/components/flow/flow-identifiers.js +81 -0
  31. package/lib/module/components/flow/flow-identifiers.js.map +1 -0
  32. package/lib/module/components/flow/index.js +112 -57
  33. package/lib/module/components/flow/index.js.map +1 -1
  34. package/lib/module/components/form/use-form.js +93 -66
  35. package/lib/module/components/form/use-form.js.map +1 -1
  36. package/lib/module/components/heatmap-chart/index.js +2 -2
  37. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  38. package/lib/module/components/hex-chart/index.js +2 -2
  39. package/lib/module/components/hex-chart/index.js.map +1 -1
  40. package/lib/module/components/kpi/index.js +3 -1
  41. package/lib/module/components/kpi/index.js.map +1 -1
  42. package/lib/module/components/line-chart/index.js +3 -3
  43. package/lib/module/components/line-chart/index.js.map +1 -1
  44. package/lib/module/components/map/index.js +71 -8
  45. package/lib/module/components/map/index.js.map +1 -1
  46. package/lib/module/components/map/map-accessibility.js +20 -0
  47. package/lib/module/components/map/map-accessibility.js.map +1 -0
  48. package/lib/module/components/map/maplibre.js.map +1 -1
  49. package/lib/module/components/menu/index.js +4 -1
  50. package/lib/module/components/menu/index.js.map +1 -1
  51. package/lib/module/components/message-scroller/index.js +17 -9
  52. package/lib/module/components/message-scroller/index.js.map +1 -1
  53. package/lib/module/components/message-scroller/message-scroller-math.js +20 -0
  54. package/lib/module/components/message-scroller/message-scroller-math.js.map +1 -0
  55. package/lib/module/components/number-input/index.js +1 -23
  56. package/lib/module/components/number-input/index.js.map +1 -1
  57. package/lib/module/components/number-input/number-input-math.js +44 -0
  58. package/lib/module/components/number-input/number-input-math.js.map +1 -0
  59. package/lib/module/components/plot/index.js +2 -2
  60. package/lib/module/components/plot/index.js.map +1 -1
  61. package/lib/module/components/popover/index.js.map +1 -1
  62. package/lib/module/components/radio-group/index.js +1 -2
  63. package/lib/module/components/radio-group/index.js.map +1 -1
  64. package/lib/module/components/selection-mode/index.js +26 -31
  65. package/lib/module/components/selection-mode/index.js.map +1 -1
  66. package/lib/module/components/selection-mode/selection-mode-contracts.js +48 -0
  67. package/lib/module/components/selection-mode/selection-mode-contracts.js.map +1 -0
  68. package/lib/module/components/signature/index.js +62 -20
  69. package/lib/module/components/signature/index.js.map +1 -1
  70. package/lib/module/components/signature/signature-accessibility.js +58 -0
  71. package/lib/module/components/signature/signature-accessibility.js.map +1 -0
  72. package/lib/module/components/slider/index.js +22 -18
  73. package/lib/module/components/slider/index.js.map +1 -1
  74. package/lib/module/components/sortable/index.js +5 -55
  75. package/lib/module/components/sortable/index.js.map +1 -1
  76. package/lib/module/components/sortable/reorder.js +63 -0
  77. package/lib/module/components/sortable/reorder.js.map +1 -0
  78. package/lib/module/components/swipe/index.js +4 -4
  79. package/lib/module/components/swipe/index.js.map +1 -1
  80. package/lib/module/components/switch/index.js +36 -3
  81. package/lib/module/components/switch/index.js.map +1 -1
  82. package/lib/module/components/time-picker/accessibility.js +21 -0
  83. package/lib/module/components/time-picker/accessibility.js.map +1 -0
  84. package/lib/module/components/time-picker/index.js +34 -8
  85. package/lib/module/components/time-picker/index.js.map +1 -1
  86. package/lib/module/components/waterfall-chart/index.js +1325 -0
  87. package/lib/module/components/waterfall-chart/index.js.map +1 -0
  88. package/lib/module/index.js +1 -0
  89. package/lib/module/index.js.map +1 -1
  90. package/lib/module/primitives/modal-isolation-store.js +29 -0
  91. package/lib/module/primitives/modal-isolation-store.js.map +1 -0
  92. package/lib/module/primitives/portal.js +36 -4
  93. package/lib/module/primitives/portal.js.map +1 -1
  94. package/lib/module/primitives/scrim.js +94 -3
  95. package/lib/module/primitives/scrim.js.map +1 -1
  96. package/lib/module/providers/panel-ui-provider.js +27 -6
  97. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  98. package/lib/typescript/src/components/badge/index.d.ts.map +1 -1
  99. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  100. package/lib/typescript/src/components/bottom-sheet/index.d.ts +3 -0
  101. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  102. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  103. package/lib/typescript/src/components/carousel/carousel-lifecycle.d.ts +23 -0
  104. package/lib/typescript/src/components/carousel/carousel-lifecycle.d.ts.map +1 -0
  105. package/lib/typescript/src/components/carousel/index.d.ts +5 -2
  106. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -1
  107. package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
  108. package/lib/typescript/src/components/context-menu/context-menu-invocation.d.ts +20 -0
  109. package/lib/typescript/src/components/context-menu/context-menu-invocation.d.ts.map +1 -0
  110. package/lib/typescript/src/components/context-menu/index.d.ts +18 -8
  111. package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -1
  112. package/lib/typescript/src/components/dialog/index.d.ts +3 -0
  113. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  114. package/lib/typescript/src/components/drawer/index.d.ts +3 -0
  115. package/lib/typescript/src/components/drawer/index.d.ts.map +1 -1
  116. package/lib/typescript/src/components/fab/index.d.ts.map +1 -1
  117. package/lib/typescript/src/components/field/index.d.ts +8 -1
  118. package/lib/typescript/src/components/field/index.d.ts.map +1 -1
  119. package/lib/typescript/src/components/flow/flow-accessibility.d.ts +57 -0
  120. package/lib/typescript/src/components/flow/flow-accessibility.d.ts.map +1 -0
  121. package/lib/typescript/src/components/flow/flow-identifiers.d.ts +24 -0
  122. package/lib/typescript/src/components/flow/flow-identifiers.d.ts.map +1 -0
  123. package/lib/typescript/src/components/flow/index.d.ts +20 -7
  124. package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
  125. package/lib/typescript/src/components/form/use-form.d.ts.map +1 -1
  126. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  127. package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
  128. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
  129. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  130. package/lib/typescript/src/components/map/index.d.ts +13 -5
  131. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  132. package/lib/typescript/src/components/map/map-accessibility.d.ts +19 -0
  133. package/lib/typescript/src/components/map/map-accessibility.d.ts.map +1 -0
  134. package/lib/typescript/src/components/map/maplibre.d.ts +2 -0
  135. package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -1
  136. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  137. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
  138. package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts +8 -0
  139. package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts.map +1 -0
  140. package/lib/typescript/src/components/number-input/index.d.ts.map +1 -1
  141. package/lib/typescript/src/components/number-input/number-input-math.d.ts +9 -0
  142. package/lib/typescript/src/components/number-input/number-input-math.d.ts.map +1 -0
  143. package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
  144. package/lib/typescript/src/components/popover/index.d.ts +3 -0
  145. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  146. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  147. package/lib/typescript/src/components/selection-mode/index.d.ts +6 -4
  148. package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -1
  149. package/lib/typescript/src/components/selection-mode/selection-mode-contracts.d.ts +18 -0
  150. package/lib/typescript/src/components/selection-mode/selection-mode-contracts.d.ts.map +1 -0
  151. package/lib/typescript/src/components/signature/index.d.ts +6 -0
  152. package/lib/typescript/src/components/signature/index.d.ts.map +1 -1
  153. package/lib/typescript/src/components/signature/signature-accessibility.d.ts +27 -0
  154. package/lib/typescript/src/components/signature/signature-accessibility.d.ts.map +1 -0
  155. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  156. package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
  157. package/lib/typescript/src/components/sortable/reorder.d.ts +14 -0
  158. package/lib/typescript/src/components/sortable/reorder.d.ts.map +1 -0
  159. package/lib/typescript/src/components/swipe/index.d.ts.map +1 -1
  160. package/lib/typescript/src/components/switch/index.d.ts +3 -3
  161. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  162. package/lib/typescript/src/components/time-picker/accessibility.d.ts +10 -0
  163. package/lib/typescript/src/components/time-picker/accessibility.d.ts.map +1 -0
  164. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  165. package/lib/typescript/src/components/waterfall-chart/index.d.ts +379 -0
  166. package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -0
  167. package/lib/typescript/src/index.d.ts +3 -2
  168. package/lib/typescript/src/index.d.ts.map +1 -1
  169. package/lib/typescript/src/primitives/modal-isolation-store.d.ts +10 -0
  170. package/lib/typescript/src/primitives/modal-isolation-store.d.ts.map +1 -0
  171. package/lib/typescript/src/primitives/portal.d.ts +6 -0
  172. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  173. package/lib/typescript/src/primitives/scrim.d.ts +5 -3
  174. package/lib/typescript/src/primitives/scrim.d.ts.map +1 -1
  175. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  176. package/package.json +11 -5
  177. package/src/components/badge/index.tsx +4 -1
  178. package/src/components/bar-chart/index.tsx +40 -3
  179. package/src/components/bottom-sheet/index.tsx +6 -3
  180. package/src/components/button/index.tsx +15 -21
  181. package/src/components/carousel/carousel-lifecycle.ts +74 -0
  182. package/src/components/carousel/index.tsx +118 -41
  183. package/src/components/checkbox/index.tsx +1 -2
  184. package/src/components/context-menu/context-menu-invocation.ts +44 -0
  185. package/src/components/context-menu/index.tsx +113 -13
  186. package/src/components/dialog/index.tsx +6 -3
  187. package/src/components/drawer/index.tsx +6 -3
  188. package/src/components/fab/index.tsx +8 -12
  189. package/src/components/field/index.tsx +52 -10
  190. package/src/components/flow/flow-accessibility.ts +123 -0
  191. package/src/components/flow/flow-identifiers.ts +98 -0
  192. package/src/components/flow/index.tsx +191 -69
  193. package/src/components/form/use-form.ts +101 -65
  194. package/src/components/heatmap-chart/index.tsx +2 -3
  195. package/src/components/hex-chart/index.tsx +2 -3
  196. package/src/components/kpi/index.tsx +3 -1
  197. package/src/components/line-chart/index.tsx +3 -2
  198. package/src/components/map/index.tsx +110 -7
  199. package/src/components/map/map-accessibility.ts +41 -0
  200. package/src/components/map/maplibre.ts +2 -0
  201. package/src/components/menu/index.tsx +4 -1
  202. package/src/components/message-scroller/index.tsx +39 -7
  203. package/src/components/message-scroller/message-scroller-math.ts +24 -0
  204. package/src/components/number-input/index.tsx +1 -26
  205. package/src/components/number-input/number-input-math.ts +50 -0
  206. package/src/components/plot/index.tsx +2 -1
  207. package/src/components/popover/index.tsx +3 -0
  208. package/src/components/radio-group/index.tsx +1 -2
  209. package/src/components/selection-mode/index.tsx +37 -30
  210. package/src/components/selection-mode/selection-mode-contracts.ts +53 -0
  211. package/src/components/signature/index.tsx +107 -20
  212. package/src/components/signature/signature-accessibility.ts +67 -0
  213. package/src/components/slider/index.tsx +28 -18
  214. package/src/components/sortable/index.tsx +12 -64
  215. package/src/components/sortable/reorder.ts +88 -0
  216. package/src/components/swipe/index.tsx +7 -4
  217. package/src/components/switch/index.tsx +42 -6
  218. package/src/components/time-picker/accessibility.ts +24 -0
  219. package/src/components/time-picker/index.tsx +48 -2
  220. package/src/components/waterfall-chart/index.tsx +1589 -0
  221. package/src/index.ts +25 -0
  222. package/src/primitives/modal-isolation-store.ts +31 -0
  223. package/src/primitives/portal.tsx +34 -4
  224. package/src/primitives/scrim.tsx +127 -7
  225. package/src/providers/panel-ui-provider.tsx +41 -9
  226. package/theme.css +30 -3
@@ -0,0 +1,1589 @@
1
+ /**
2
+ * WaterfallChart — how a run of changes carried one total to another.
3
+ *
4
+ * Composed the same way the other charts are: the grid, the bars, the
5
+ * connectors, the axes and the readout are separate children, so a chart that
6
+ * wants no grid simply does not have one.
7
+ *
8
+ * ```tsx
9
+ * <WaterfallChart data={bridge}>
10
+ * <WaterfallChart.Grid />
11
+ * <WaterfallChart.Connectors />
12
+ * <WaterfallChart.Bars />
13
+ * <WaterfallChart.XAxis />
14
+ * <WaterfallChart.Tooltip />
15
+ * </WaterfallChart>
16
+ * ```
17
+ *
18
+ * ## What the shape is asserting
19
+ *
20
+ * **Every bar but a total floats.** A step's bar starts where the previous one
21
+ * ended and reaches as far as its own value carries it, so the gap under it is
22
+ * the running total it is acting on. That floating is the entire point: a bar
23
+ * chart of the same numbers would compare the changes against each other, and
24
+ * this compares each of them against the balance it moved.
25
+ *
26
+ * **A total is anchored to zero.** It is a reading rather than a change, so it
27
+ * is measured from the baseline like an ordinary bar and drawn in a neutral
28
+ * colour. Marking the opening and closing steps `total` is what gives the run
29
+ * two ends to be a bridge between.
30
+ *
31
+ * **Three colours, and no more.** Up, down, and total. A fourth would have to
32
+ * mean something the reader has to be told, and the one thing this chart has
33
+ * going for it is that the direction of a bar is legible before its label is.
34
+ *
35
+ * **The connectors are the sequence.** Without them the bars are a row of
36
+ * floating rectangles at unexplained heights; the line from one bar's end to
37
+ * the next bar's start is what says the second continues the first.
38
+ */
39
+ import {
40
+ Children,
41
+ createContext,
42
+ forwardRef,
43
+ isValidElement,
44
+ useContext,
45
+ useEffect,
46
+ useImperativeHandle,
47
+ useMemo,
48
+ useRef,
49
+ useState,
50
+ type ReactNode,
51
+ } from 'react';
52
+ import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
53
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
54
+ import Animated, {
55
+ Easing,
56
+ cancelAnimation,
57
+ runOnJS,
58
+ useAnimatedProps,
59
+ useAnimatedStyle,
60
+ useReducedMotion,
61
+ useSharedValue,
62
+ withRepeat,
63
+ withTiming,
64
+ type SharedValue,
65
+ } from 'react-native-reanimated';
66
+ import Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Stop } from 'react-native-svg';
67
+ import { useCSSVariable } from 'uniwind';
68
+ import { Text } from '../../primitives/text';
69
+ import { barPath, compactNumber, type Plot } from '../../utils/chart';
70
+ import { cn } from '../../utils/cn';
71
+
72
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
73
+ const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
74
+
75
+ /** Room left around the plot for the axis labels. */
76
+ const PADDING = { top: 12, right: 10, bottom: 22, left: 10 };
77
+
78
+ /**
79
+ * Sideways, the step names run down the left instead of along the bottom, so
80
+ * the room has to come off that side. Reserved rather than overlaid: a name
81
+ * drawn on top of the bars is unreadable against them and makes the bars
82
+ * unreadable too.
83
+ */
84
+ const PADDING_SIDEWAYS = { top: 6, right: 10, bottom: 6, left: 76 };
85
+
86
+ /** Width the readout is laid out at, so it can be clamped inside the plot. */
87
+ const LABEL_WIDTH = 148;
88
+
89
+ /** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */
90
+ const Y_AXIS_WIDTH = 44;
91
+
92
+ /** Line height of an `xs` label, for centring one on the grid line it names. */
93
+ const AXIS_LABEL_HEIGHT = 16;
94
+
95
+ /** Fallbacks, only reached if the theme CSS was never imported. */
96
+ const FALLBACK_RISE = '#10b981';
97
+ const FALLBACK_FALL = '#ef4444';
98
+ const FALLBACK_TOTAL = '#262626';
99
+
100
+ type Layer = 'svg' | 'overlay' | 'header';
101
+
102
+ export type WaterfallChartStatus = 'loading' | 'ready';
103
+ export type WaterfallChartOrientation = 'vertical' | 'horizontal';
104
+
105
+ /** Which of the three roles a step's bar is drawn in. */
106
+ export type WaterfallKind = 'rise' | 'fall' | 'total';
107
+
108
+ export interface WaterfallDatum {
109
+ /** Name of the step, as the axis and the readout show it. */
110
+ label: string;
111
+ /**
112
+ * The change this step makes to the running total.
113
+ *
114
+ * On a `total` step it is added to the running total *before* the bar is
115
+ * drawn, so `0` reads the balance as it stands and a non-zero one opens the
116
+ * run at a starting balance.
117
+ */
118
+ value: number;
119
+ /**
120
+ * Draw this step as a reading rather than a change: measured from the
121
+ * baseline, in the neutral colour, and counted in the legend as a total.
122
+ */
123
+ total?: boolean;
124
+ /** Explicit colour for this one bar, overriding the role's. */
125
+ color?: string;
126
+ }
127
+
128
+ /** One step, resolved against the running total it acts on. */
129
+ export interface WaterfallStep {
130
+ datum: WaterfallDatum;
131
+ label: string;
132
+ value: number;
133
+ kind: WaterfallKind;
134
+ /** Value the bar is measured from — the running total before this step. */
135
+ start: number;
136
+ /** Value the bar reaches — the running total after it. */
137
+ end: number;
138
+ }
139
+
140
+ interface WaterfallChartContextValue {
141
+ data: WaterfallDatum[];
142
+ steps: WaterfallStep[];
143
+ plot: Plot;
144
+ status: WaterfallChartStatus;
145
+ orientation: WaterfallChartOrientation;
146
+ barGap: number;
147
+ barWidth: number | undefined;
148
+ cornerRadius: number;
149
+ minBarLength: number;
150
+ fadedOpacity: number;
151
+ colors: Record<WaterfallKind, string>;
152
+ domainMin: SharedValue<number>;
153
+ domainMax: SharedValue<number>;
154
+ /** The settled domain, for the parts that draw text rather than geometry. */
155
+ extent: [number, number];
156
+ /** 0 to 1 as the bars grow in. Shared, so they arrive as one chart. */
157
+ reveal: SharedValue<number>;
158
+ activeIndex: SharedValue<number>;
159
+ activeIndexJS: number;
160
+ setActiveIndexJS: (index: number) => void;
161
+ }
162
+
163
+ const WaterfallChartContext = createContext<WaterfallChartContextValue | null>(null);
164
+
165
+ function useChart(component: string): WaterfallChartContextValue {
166
+ const context = useContext(WaterfallChartContext);
167
+ if (!context) {
168
+ throw new Error(`${component} must be used within a <WaterfallChart>`);
169
+ }
170
+ return context;
171
+ }
172
+
173
+ /**
174
+ * The step under the finger, for something rendered *inside* the chart. A
175
+ * readout in the card's header is outside this provider — use
176
+ * `onActiveIndexChange` for that.
177
+ */
178
+ export function useWaterfallChart() {
179
+ const { steps, activeIndexJS } = useChart('useWaterfallChart');
180
+ return {
181
+ activeIndex: activeIndexJS,
182
+ activeStep: activeIndexJS >= 0 ? (steps[activeIndexJS] ?? null) : null,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * The running totals every step sits on.
188
+ *
189
+ * Split out because it is the one piece of the chart that is pure arithmetic
190
+ * over the data, and every part that draws anything needs the same answer —
191
+ * two parts deriving it separately is two chances for the bars and the
192
+ * connectors to disagree about where a step ended.
193
+ */
194
+ export function waterfallSteps(data: WaterfallDatum[]): WaterfallStep[] {
195
+ const steps: WaterfallStep[] = [];
196
+ let running = 0;
197
+
198
+ for (const datum of data) {
199
+ const value = typeof datum.value === 'number' && !Number.isNaN(datum.value) ? datum.value : 0;
200
+
201
+ if (datum.total) {
202
+ // A total is a reading, so it is measured from the baseline and the
203
+ // running figure becomes whatever it reads.
204
+ running += value;
205
+ steps.push({
206
+ datum,
207
+ label: datum.label,
208
+ value: running,
209
+ kind: 'total',
210
+ start: 0,
211
+ end: running,
212
+ });
213
+ continue;
214
+ }
215
+
216
+ const start = running;
217
+ running += value;
218
+ steps.push({
219
+ datum,
220
+ label: datum.label,
221
+ value,
222
+ kind: value < 0 ? 'fall' : 'rise',
223
+ start,
224
+ end: running,
225
+ });
226
+ }
227
+
228
+ return steps;
229
+ }
230
+
231
+ export interface WaterfallChartProps extends ViewProps {
232
+ className?: string;
233
+ /** The steps, in the order they happen. */
234
+ data: WaterfallDatum[];
235
+ /**
236
+ * `loading` holds the bars at the baseline and grows them into the real ones
237
+ * when it turns `ready`. One component throughout, rather than a spinner
238
+ * swapped for a chart — swapping loses the transition. Add a
239
+ * `WaterfallChart.Skeleton` for something to stand in the plot meanwhile.
240
+ */
241
+ status?: WaterfallChartStatus;
242
+ /** Width ÷ height. `2` is the wide card shape. */
243
+ aspectRatio?: number;
244
+ /** Milliseconds for the bars to grow in on mount. */
245
+ animationDuration?: number;
246
+ /** Milliseconds for the value axis to settle after the data changes. */
247
+ domainDuration?: number;
248
+ /**
249
+ * Fix the value axis instead of deriving it. The derived domain always
250
+ * includes zero, and one that does not is a run whose bars cannot be
251
+ * compared — pass this only when you mean it.
252
+ */
253
+ yDomain?: [number, number];
254
+ /** `vertical` stands the bars up; `horizontal` lays the run down the side. */
255
+ orientation?: WaterfallChartOrientation;
256
+ /**
257
+ * Fraction of each band left empty, `0` to `1`. A fraction rather than a
258
+ * pixel gap so the proportions hold at any width.
259
+ */
260
+ barGap?: number;
261
+ /** Fixed bar thickness in points. Derived from the band when omitted. */
262
+ barWidth?: number;
263
+ /** Corner radius on the ends of a bar. */
264
+ cornerRadius?: number;
265
+ /**
266
+ * Smallest length a non-zero bar is drawn at, in points. A step that rounds
267
+ * to nothing still happened, and a bar of zero length says it did not.
268
+ */
269
+ minBarLength?: number;
270
+ /** Opacity of the bars that are not under the finger. */
271
+ fadedOpacity?: number;
272
+ /** Colour of a step that adds. Defaults to the success token. */
273
+ riseColor?: string;
274
+ /** Colour of a step that subtracts. Defaults to the destructive token. */
275
+ fallColor?: string;
276
+ /** Colour of a `total` step. Defaults to the first chart token. */
277
+ totalColor?: string;
278
+ /**
279
+ * The step under the finger as it moves, and `-1`/`null` when it lifts.
280
+ * Fires when the index changes, not per frame.
281
+ */
282
+ onActiveIndexChange?: (index: number, step: WaterfallStep | null) => void;
283
+ /** Drop the axis padding, for a run with no axis or readout. */
284
+ compact?: boolean;
285
+ children?: ReactNode;
286
+ }
287
+
288
+ /** Imperative handle: re-run the grow-in, for a "replay" control. */
289
+ export interface WaterfallChartHandle {
290
+ replay: () => void;
291
+ }
292
+
293
+ function partition(children: ReactNode) {
294
+ const svg: ReactNode[] = [];
295
+ const overlay: ReactNode[] = [];
296
+ const header: ReactNode[] = [];
297
+ Children.forEach(children, (child, index) => {
298
+ if (!isValidElement(child)) return;
299
+ const layer = (child.type as { layer?: Layer }).layer ?? 'svg';
300
+ const slot = <ChildSlot key={index}>{child}</ChildSlot>;
301
+ (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot);
302
+ });
303
+ return { svg, overlay, header };
304
+ }
305
+
306
+ function ChildSlot({ children }: { children: ReactNode }) {
307
+ return <>{children}</>;
308
+ }
309
+
310
+ const WaterfallChartRoot = forwardRef<WaterfallChartHandle, WaterfallChartProps>(
311
+ function WaterfallChartRoot(
312
+ {
313
+ className,
314
+ data,
315
+ status = 'ready',
316
+ aspectRatio = 2,
317
+ animationDuration = 700,
318
+ domainDuration = 500,
319
+ yDomain,
320
+ orientation = 'vertical',
321
+ barGap = 0.34,
322
+ barWidth,
323
+ cornerRadius = 4,
324
+ minBarLength = 2,
325
+ fadedOpacity = 0.3,
326
+ riseColor,
327
+ fallColor,
328
+ totalColor,
329
+ onActiveIndexChange,
330
+ compact = false,
331
+ children,
332
+ ...props
333
+ },
334
+ ref
335
+ ) {
336
+ const [size, setSize] = useState({ width: 0, height: 0 });
337
+ const [activeIndexJS, setActiveIndexJS] = useState(-1);
338
+
339
+ const reveal = useSharedValue(0);
340
+ const domainMin = useSharedValue(0);
341
+ const domainMax = useSharedValue(0);
342
+ const activeIndex = useSharedValue(-1);
343
+ const reducedMotion = useReducedMotion();
344
+
345
+ const riseToken = useCSSVariable('--color-success');
346
+ const fallToken = useCSSVariable('--color-destructive');
347
+ const totalToken = useCSSVariable('--color-chart-1');
348
+
349
+ const colors = useMemo<Record<WaterfallKind, string>>(
350
+ () => ({
351
+ rise: riseColor ?? (typeof riseToken === 'string' ? riseToken : FALLBACK_RISE),
352
+ fall: fallColor ?? (typeof fallToken === 'string' ? fallToken : FALLBACK_FALL),
353
+ total: totalColor ?? (typeof totalToken === 'string' ? totalToken : FALLBACK_TOTAL),
354
+ }),
355
+ [riseColor, fallColor, totalColor, riseToken, fallToken, totalToken]
356
+ );
357
+
358
+ const steps = useMemo(() => waterfallSteps(data), [data]);
359
+
360
+ /*
361
+ * Whether an axis is asking for room. It has to be known before the plot is
362
+ * laid out, and only the root sees the children early enough to ask — the
363
+ * axis itself renders into a box that has already been decided.
364
+ */
365
+ const hasYAxis = useMemo(() => {
366
+ let found = false;
367
+ Children.forEach(children, (child) => {
368
+ if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {
369
+ found = true;
370
+ }
371
+ });
372
+ return found;
373
+ }, [children]);
374
+
375
+ const pad = compact
376
+ ? { top: 2, right: 1, bottom: 2, left: 1 }
377
+ : orientation === 'horizontal'
378
+ ? PADDING_SIDEWAYS
379
+ : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };
380
+ const plot: Plot = {
381
+ left: pad.left,
382
+ top: pad.top,
383
+ width: Math.max(size.width - pad.left - pad.right, 0),
384
+ height: Math.max(size.height - pad.top - pad.bottom, 0),
385
+ };
386
+
387
+ const extent = useMemo<[number, number]>(() => {
388
+ if (yDomain) return yDomain;
389
+
390
+ /*
391
+ * Both ends of every bar, not just the values. A step's bar occupies the
392
+ * span between two running totals, and a domain taken from the changes
393
+ * alone would be the range of the *deltas* — which on any run that
394
+ * climbs before it falls is a fraction of the height the bars need.
395
+ */
396
+ let min = 0;
397
+ let max = 0;
398
+ for (const step of steps) {
399
+ const low = Math.min(step.start, step.end);
400
+ const high = Math.max(step.start, step.end);
401
+ if (low < min) min = low;
402
+ if (high > max) max = high;
403
+ }
404
+
405
+ if (min === 0 && max === 0) return [0, 1];
406
+ // Headroom past the furthest bar only. The zero end is left exactly where
407
+ // it is: padding it would lift the run off its own baseline.
408
+ return [min === 0 ? 0 : min * 1.1, max === 0 ? 0 : max * 1.1];
409
+ }, [steps, yDomain]);
410
+
411
+ const loading = status === 'loading';
412
+
413
+ useEffect(() => {
414
+ if (loading) return;
415
+ const [min, max] = extent;
416
+ const first = domainMin.value === 0 && domainMax.value === 0;
417
+ if (first || reducedMotion) {
418
+ domainMin.value = min;
419
+ domainMax.value = max;
420
+ return;
421
+ }
422
+ domainMin.value = withTiming(min, { duration: domainDuration });
423
+ domainMax.value = withTiming(max, { duration: domainDuration });
424
+ }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);
425
+
426
+ const revealed = useRef(false);
427
+ const playReveal = useMemo(
428
+ () => () => {
429
+ if (reducedMotion) {
430
+ reveal.value = 1;
431
+ return;
432
+ }
433
+ reveal.value = 0;
434
+ reveal.value = withTiming(1, {
435
+ duration: animationDuration,
436
+ easing: Easing.out(Easing.cubic),
437
+ });
438
+ },
439
+ [reducedMotion, animationDuration, reveal]
440
+ );
441
+
442
+ useEffect(() => {
443
+ /*
444
+ * Going back to `loading` arms the reveal again. Without this a chart that
445
+ * is refetched comes back fully drawn on the frame the data lands, which
446
+ * reads as the loading state having been for nothing.
447
+ */
448
+ if (loading) {
449
+ revealed.current = false;
450
+ reveal.value = 0;
451
+ return;
452
+ }
453
+ if (revealed.current || plot.width <= 0 || !steps.length) return;
454
+ revealed.current = true;
455
+ playReveal();
456
+ }, [loading, plot.width, steps.length, playReveal, reveal]);
457
+
458
+ useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
459
+
460
+ const handleActiveIndex = useMemo(
461
+ () => (index: number) => {
462
+ setActiveIndexJS(index);
463
+ onActiveIndexChange?.(index, index >= 0 ? (steps[index] ?? null) : null);
464
+ },
465
+ [onActiveIndexChange, steps]
466
+ );
467
+
468
+ const onLayout = (event: LayoutChangeEvent) => {
469
+ const { width, height } = event.nativeEvent.layout;
470
+ setSize((current) =>
471
+ Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
472
+ ? current
473
+ : { width, height }
474
+ );
475
+ props.onLayout?.(event);
476
+ };
477
+
478
+ const context = useMemo<WaterfallChartContextValue>(
479
+ () => ({
480
+ data,
481
+ steps,
482
+ plot,
483
+ status,
484
+ orientation,
485
+ barGap,
486
+ barWidth,
487
+ cornerRadius,
488
+ minBarLength,
489
+ fadedOpacity,
490
+ colors,
491
+ domainMin,
492
+ domainMax,
493
+ extent,
494
+ reveal,
495
+ activeIndex,
496
+ activeIndexJS,
497
+ setActiveIndexJS: handleActiveIndex,
498
+ }),
499
+ // `plot` is rebuilt every render from `size`, so it is compared by value.
500
+ // eslint-disable-next-line react-hooks/exhaustive-deps
501
+ [
502
+ data,
503
+ steps,
504
+ plot.width,
505
+ plot.height,
506
+ plot.left,
507
+ plot.top,
508
+ status,
509
+ orientation,
510
+ barGap,
511
+ barWidth,
512
+ cornerRadius,
513
+ minBarLength,
514
+ fadedOpacity,
515
+ colors,
516
+ domainMin,
517
+ domainMax,
518
+ extent,
519
+ reveal,
520
+ activeIndex,
521
+ activeIndexJS,
522
+ handleActiveIndex,
523
+ ]
524
+ );
525
+
526
+ const { svg, overlay, header } = partition(children);
527
+
528
+ /*
529
+ * Two views, because the header is not part of the plot. `aspectRatio` and
530
+ * the layout measurement belong to the drawing area alone — measured on the
531
+ * outer view they would take in the header too, and the plot would lose as
532
+ * much height as the readout took while still claiming the shape asked for.
533
+ */
534
+ return (
535
+ <WaterfallChartContext.Provider value={context}>
536
+ <View {...props} style={props.style} className={cn('w-full', className)}>
537
+ {header}
538
+ <View onLayout={onLayout} style={{ aspectRatio }} className="w-full">
539
+ {plot.width > 0 ? (
540
+ <>
541
+ <Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
542
+ {svg}
543
+ </Svg>
544
+ {overlay}
545
+ </>
546
+ ) : null}
547
+ </View>
548
+ </View>
549
+ </WaterfallChartContext.Provider>
550
+ );
551
+ }
552
+ );
553
+ WaterfallChartRoot.displayName = 'WaterfallChart';
554
+
555
+ /* -------------------------------------------------------------------------- */
556
+ /* Geometry */
557
+ /* -------------------------------------------------------------------------- */
558
+
559
+ /** The band measurements every drawn part reads the same way. */
560
+ interface Bands {
561
+ along: number;
562
+ across: number;
563
+ alongStart: number;
564
+ acrossStart: number;
565
+ band: number;
566
+ thickness: number;
567
+ }
568
+
569
+ /**
570
+ * How the plot divides between the steps.
571
+ *
572
+ * A worklet because the bars rebuild on the UI thread every frame the domain is
573
+ * tweening, and shared with the parts that only run on JS so a label and the
574
+ * bar it names can never land on different centres.
575
+ */
576
+ function bandsOf(
577
+ plot: Plot,
578
+ total: number,
579
+ horizontal: boolean,
580
+ barGap: number,
581
+ barWidth: number | undefined
582
+ ): Bands {
583
+ 'worklet';
584
+ const along = horizontal ? plot.height : plot.width;
585
+ const across = horizontal ? plot.width : plot.height;
586
+ const band = along / Math.max(1, total);
587
+ const usable = band * (1 - barGap);
588
+ return {
589
+ along,
590
+ across,
591
+ alongStart: horizontal ? plot.top : plot.left,
592
+ acrossStart: horizontal ? plot.left : plot.top,
593
+ band,
594
+ thickness: Math.min(barWidth ?? usable, usable),
595
+ };
596
+ }
597
+
598
+ /* -------------------------------------------------------------------------- */
599
+ /* SVG layer */
600
+ /* -------------------------------------------------------------------------- */
601
+
602
+ export interface WaterfallChartGridProps {
603
+ /** How many lines to draw across the value axis. */
604
+ rows?: number;
605
+ color?: string;
606
+ dashArray?: string;
607
+ opacity?: number;
608
+ }
609
+
610
+ /**
611
+ * Lines across the value axis, so a bar can be read against a number rather
612
+ * than only against the bar beside it.
613
+ */
614
+ function WaterfallChartGrid({
615
+ rows = 4,
616
+ color,
617
+ dashArray = '4,6',
618
+ opacity = 1,
619
+ }: WaterfallChartGridProps) {
620
+ const { plot, orientation } = useChart('WaterfallChart.Grid');
621
+ const token = useCSSVariable('--color-border');
622
+ const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');
623
+
624
+ const lines = Array.from({ length: rows + 1 }, (_unused, index) => index / rows);
625
+
626
+ return (
627
+ <G opacity={opacity}>
628
+ {lines.map((fraction) =>
629
+ orientation === 'vertical' ? (
630
+ <SvgLine
631
+ key={fraction}
632
+ x1={plot.left}
633
+ x2={plot.left + plot.width}
634
+ y1={plot.top + plot.height * fraction}
635
+ y2={plot.top + plot.height * fraction}
636
+ stroke={stroke}
637
+ strokeDasharray={dashArray}
638
+ strokeWidth={1}
639
+ />
640
+ ) : (
641
+ <SvgLine
642
+ key={fraction}
643
+ x1={plot.left + plot.width * fraction}
644
+ x2={plot.left + plot.width * fraction}
645
+ y1={plot.top}
646
+ y2={plot.top + plot.height}
647
+ stroke={stroke}
648
+ strokeDasharray={dashArray}
649
+ strokeWidth={1}
650
+ />
651
+ )
652
+ )}
653
+ </G>
654
+ );
655
+ }
656
+ WaterfallChartGrid.displayName = 'WaterfallChart.Grid';
657
+ WaterfallChartGrid.layer = 'svg' as Layer;
658
+
659
+ export interface WaterfallChartBarsProps {
660
+ /** Corner radius, overriding the chart's. */
661
+ cornerRadius?: number;
662
+ }
663
+
664
+ /**
665
+ * The bars.
666
+ *
667
+ * Six animated paths a frame rather than one per step: one per role, so the
668
+ * three colours can be three fills, and each of those split into the bar under
669
+ * the finger and the rest, so the others can dim without every bar carrying its
670
+ * own opacity. A run of forty steps costs the same as a run of four.
671
+ *
672
+ * Each bar grows from its own `start` towards its `end` rather than up from the
673
+ * baseline. A step is a movement from one balance to another, and growing it
674
+ * from zero would animate a quantity the chart is not claiming.
675
+ */
676
+ function WaterfallChartBars({ cornerRadius }: WaterfallChartBarsProps) {
677
+ const {
678
+ steps,
679
+ plot,
680
+ status,
681
+ orientation,
682
+ barGap,
683
+ barWidth,
684
+ cornerRadius: chartRadius,
685
+ minBarLength,
686
+ fadedOpacity,
687
+ colors,
688
+ domainMin,
689
+ domainMax,
690
+ reveal,
691
+ activeIndex,
692
+ } = useChart('WaterfallChart.Bars');
693
+
694
+ const radius = cornerRadius ?? chartRadius;
695
+ const loading = status === 'loading';
696
+ const total = steps.length;
697
+ const horizontal = orientation === 'horizontal';
698
+
699
+ /*
700
+ * All six paths come out of one builder, filtered by role and by whether the
701
+ * bar is the active one. Six passes over the data a frame is still cheaper
702
+ * than the bookkeeping needed to build them all at once, and it keeps the
703
+ * geometry in exactly one place.
704
+ */
705
+ const build = (kind: WaterfallKind, wantActive: boolean) => () => {
706
+ 'worklet';
707
+ if (!total || plot.width <= 0) {
708
+ return { d: '', opacity: 1 };
709
+ }
710
+
711
+ const bands = bandsOf(plot, total, horizontal, barGap, barWidth);
712
+ const min = domainMin.value;
713
+ const max = domainMax.value;
714
+ const range = max - min || 1;
715
+ const grow = reveal.value;
716
+ const active = activeIndex.value;
717
+
718
+ /*
719
+ * Where a value sits along the value axis. Vertical counts down from the
720
+ * top, horizontal counts up from the left — the same scale read in
721
+ * opposite directions, which is the only thing orientation changes.
722
+ */
723
+ const project = (value: number) => {
724
+ 'worklet';
725
+ const fraction = (value - min) / range;
726
+ return horizontal
727
+ ? bands.acrossStart + fraction * bands.across
728
+ : bands.acrossStart + bands.across - fraction * bands.across;
729
+ };
730
+
731
+ let d = '';
732
+
733
+ for (let i = 0; i < total; i++) {
734
+ const step = steps[i];
735
+ if (!step || step.kind !== kind) continue;
736
+ if ((i === active) !== wantActive) continue;
737
+
738
+ /*
739
+ * Staggered by step, but every bar still finishes inside the one
740
+ * duration: the window each gets is what is left after the stagger, so a
741
+ * run of forty steps does not take forty times as long to arrive.
742
+ */
743
+ const startAt = total > 1 ? (i / total) * 0.45 : 0;
744
+ const eased = Math.max(0, Math.min(1, (grow - startAt) / 0.55));
745
+ const reached = loading ? step.start : step.start + (step.end - step.start) * eased;
746
+
747
+ const from = project(step.start);
748
+ const to = project(reached);
749
+ let length = Math.abs(to - from);
750
+ // A step that rounds to nothing still happened, and a bar of zero length
751
+ // says it did not. Held back until the grow-in has actually started, so
752
+ // the stub does not appear before the bar it belongs to.
753
+ if (minBarLength > 0 && eased > 0 && !loading && length < minBarLength) {
754
+ length = minBarLength;
755
+ }
756
+ if (length <= 0) continue;
757
+
758
+ const lead = bands.alongStart + i * bands.band + (bands.band - bands.thickness) / 2;
759
+ // Which way the bar points is the direction of the change, not the sign
760
+ // of the value — a fall from 900 to 400 points down whatever those two
761
+ // numbers are, and a total always points away from the baseline.
762
+ const forward = reached >= step.start;
763
+
764
+ d += horizontal
765
+ ? barPath(
766
+ forward ? from : from - length,
767
+ lead,
768
+ length,
769
+ bands.thickness,
770
+ radius,
771
+ forward ? 'right' : 'left'
772
+ )
773
+ : barPath(
774
+ lead,
775
+ forward ? from - length : from,
776
+ bands.thickness,
777
+ length,
778
+ radius,
779
+ forward ? 'up' : 'down'
780
+ );
781
+ }
782
+
783
+ // Dimming only happens while something *is* active; with nothing under the
784
+ // finger every bar is at full ink, which is the resting state.
785
+ const dim = !wantActive && active >= 0 ? fadedOpacity : 1;
786
+ return { d, opacity: dim };
787
+ };
788
+
789
+ const riseRest = useAnimatedProps(build('rise', false));
790
+ const riseActive = useAnimatedProps(build('rise', true));
791
+ const fallRest = useAnimatedProps(build('fall', false));
792
+ const fallActive = useAnimatedProps(build('fall', true));
793
+ const totalRest = useAnimatedProps(build('total', false));
794
+ const totalActive = useAnimatedProps(build('total', true));
795
+
796
+ return (
797
+ <G>
798
+ <AnimatedPath animatedProps={riseRest} fill={colors.rise} />
799
+ <AnimatedPath animatedProps={riseActive} fill={colors.rise} />
800
+ <AnimatedPath animatedProps={fallRest} fill={colors.fall} />
801
+ <AnimatedPath animatedProps={fallActive} fill={colors.fall} />
802
+ <AnimatedPath animatedProps={totalRest} fill={colors.total} />
803
+ <AnimatedPath animatedProps={totalActive} fill={colors.total} />
804
+ </G>
805
+ );
806
+ }
807
+ WaterfallChartBars.displayName = 'WaterfallChart.Bars';
808
+ WaterfallChartBars.layer = 'svg' as Layer;
809
+
810
+ export interface WaterfallChartConnectorsProps {
811
+ color?: string;
812
+ dashArray?: string;
813
+ strokeWidth?: number;
814
+ opacity?: number;
815
+ }
816
+
817
+ /**
818
+ * The lines from each bar's end to the next bar's start.
819
+ *
820
+ * Drawn under the bars, and reaching the full width of both bands rather than
821
+ * only the gap between them, so the ends are hidden behind the bars they touch
822
+ * and the line reads as passing behind the run instead of stopping short of it.
823
+ *
824
+ * They arrive with the reveal, each one held back until the bar on its left has
825
+ * finished growing — a connector drawn to a bar that is not there yet points at
826
+ * nothing.
827
+ */
828
+ function WaterfallChartConnectors({
829
+ color,
830
+ dashArray = '3,4',
831
+ strokeWidth = 1,
832
+ opacity = 1,
833
+ }: WaterfallChartConnectorsProps) {
834
+ const {
835
+ steps,
836
+ plot,
837
+ status,
838
+ orientation,
839
+ barGap,
840
+ barWidth,
841
+ domainMin,
842
+ domainMax,
843
+ reveal,
844
+ } = useChart('WaterfallChart.Connectors');
845
+
846
+ const token = useCSSVariable('--color-muted-foreground');
847
+ const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.4)');
848
+
849
+ const total = steps.length;
850
+ const horizontal = orientation === 'horizontal';
851
+ const loading = status === 'loading';
852
+
853
+ const animatedProps = useAnimatedProps(() => {
854
+ 'worklet';
855
+ if (total < 2 || plot.width <= 0 || loading) return { d: '' };
856
+
857
+ const bands = bandsOf(plot, total, horizontal, barGap, barWidth);
858
+ const min = domainMin.value;
859
+ const max = domainMax.value;
860
+ const range = max - min || 1;
861
+ const grow = reveal.value;
862
+
863
+ const project = (value: number) => {
864
+ 'worklet';
865
+ const fraction = (value - min) / range;
866
+ return horizontal
867
+ ? bands.acrossStart + fraction * bands.across
868
+ : bands.acrossStart + bands.across - fraction * bands.across;
869
+ };
870
+
871
+ let d = '';
872
+
873
+ for (let i = 0; i < total - 1; i++) {
874
+ const step = steps[i];
875
+ if (!step) continue;
876
+
877
+ // The same window the bar on the left is growing in. The connector only
878
+ // starts once that bar has arrived at the level it is drawn at.
879
+ const startAt = total > 1 ? (i / total) * 0.45 : 0;
880
+ const eased = Math.max(0, Math.min(1, (grow - startAt) / 0.55));
881
+ if (eased <= 0) continue;
882
+
883
+ const level = project(step.end);
884
+ const from = bands.alongStart + i * bands.band + (bands.band - bands.thickness) / 2;
885
+ const to = bands.alongStart + (i + 2) * bands.band - (bands.band - bands.thickness) / 2;
886
+ // Extends as the next bar arrives, so the line and the bar it is
887
+ // reaching for grow together rather than the line waiting drawn.
888
+ const reach = from + (to - from) * eased;
889
+
890
+ d += horizontal
891
+ ? `M${level},${from}L${level},${reach}`
892
+ : `M${from},${level}L${reach},${level}`;
893
+ }
894
+
895
+ return { d };
896
+ });
897
+
898
+ if (total < 2) return null;
899
+
900
+ return (
901
+ <AnimatedPath
902
+ animatedProps={animatedProps}
903
+ stroke={stroke}
904
+ strokeWidth={strokeWidth}
905
+ strokeDasharray={dashArray}
906
+ strokeOpacity={opacity}
907
+ fill="none"
908
+ />
909
+ );
910
+ }
911
+ WaterfallChartConnectors.displayName = 'WaterfallChart.Connectors';
912
+ WaterfallChartConnectors.layer = 'svg' as Layer;
913
+
914
+ /** How much of the value axis a placeholder bar takes. */
915
+ const SKELETON_LENGTH = 0.18;
916
+
917
+ /** Steps to draw when there is no data yet to count them from. */
918
+ const SKELETON_BARS = 6;
919
+
920
+ export interface WaterfallChartSkeletonProps {
921
+ /**
922
+ * How many placeholder bars to draw. Defaults to one per step, and to six
923
+ * when the data has not arrived — the count is the one thing a loading chart
924
+ * can be honest about only if it already has the steps.
925
+ */
926
+ bars?: number;
927
+ /** Milliseconds for one pass of the sweep. */
928
+ duration?: number;
929
+ color?: string;
930
+ }
931
+
932
+ /**
933
+ * The loading state: a row of short, equal stubs on the baseline, with a
934
+ * highlight travelling across them.
935
+ *
936
+ * Equal and on the baseline on purpose. Placeholder bars at differing heights
937
+ * are a run the reader has no way to tell from the real one until it changes
938
+ * under them, and floating them would invent a set of running totals — which is
939
+ * the one thing this chart exists to report.
940
+ */
941
+ function WaterfallChartSkeleton({
942
+ bars,
943
+ duration = 1400,
944
+ color,
945
+ }: WaterfallChartSkeletonProps) {
946
+ const { plot, status, orientation, steps, barGap, barWidth, cornerRadius } = useChart(
947
+ 'WaterfallChart.Skeleton'
948
+ );
949
+ const token = useCSSVariable('--color-skeleton');
950
+ const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
951
+ const highlightToken = useCSSVariable('--color-chart-1');
952
+ const highlight = typeof highlightToken === 'string' ? highlightToken : FALLBACK_TOTAL;
953
+
954
+ const sweep = useSharedValue(0);
955
+ const reducedMotion = useReducedMotion();
956
+ const loading = status === 'loading';
957
+
958
+ useEffect(() => {
959
+ if (!loading || reducedMotion) {
960
+ cancelAnimation(sweep);
961
+ sweep.value = 0;
962
+ return;
963
+ }
964
+ sweep.value = 0;
965
+ sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);
966
+ return () => cancelAnimation(sweep);
967
+ }, [loading, reducedMotion, duration, sweep]);
968
+
969
+ // The band travels by moving the gradient's own endpoints, so the whole
970
+ // effect is two numbers changing on the UI thread.
971
+ const animatedProps = useAnimatedProps(() => ({
972
+ x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,
973
+ x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,
974
+ }));
975
+
976
+ const horizontal = orientation === 'horizontal';
977
+ const total = Math.max(1, bars ?? (steps.length || SKELETON_BARS));
978
+
979
+ const d = useMemo(() => {
980
+ if (plot.width <= 0 || plot.height <= 0) return '';
981
+
982
+ const bands = bandsOf(plot, total, horizontal, barGap, barWidth);
983
+ const length = bands.across * SKELETON_LENGTH;
984
+
985
+ let path = '';
986
+ for (let i = 0; i < total; i += 1) {
987
+ const lead = bands.alongStart + i * bands.band + (bands.band - bands.thickness) / 2;
988
+ path += horizontal
989
+ ? barPath(plot.left, lead, length, bands.thickness, cornerRadius, 'right')
990
+ : barPath(
991
+ lead,
992
+ plot.top + plot.height - length,
993
+ bands.thickness,
994
+ length,
995
+ cornerRadius,
996
+ 'up'
997
+ );
998
+ }
999
+ return path;
1000
+ }, [plot, horizontal, total, barGap, barWidth, cornerRadius]);
1001
+
1002
+ if (!loading || !d) return null;
1003
+
1004
+ const gradientId = 'panelui-waterfall-skeleton';
1005
+
1006
+ return (
1007
+ <G>
1008
+ <Defs>
1009
+ <AnimatedLinearGradient id={gradientId} animatedProps={animatedProps} y1="0" y2="0">
1010
+ <Stop offset="0" stopColor={base} />
1011
+ <Stop offset="0.5" stopColor={highlight} stopOpacity={0.35} />
1012
+ <Stop offset="1" stopColor={base} />
1013
+ </AnimatedLinearGradient>
1014
+ </Defs>
1015
+ <Path d={d} fill={`url(#${gradientId})`} />
1016
+ </G>
1017
+ );
1018
+ }
1019
+ WaterfallChartSkeleton.displayName = 'WaterfallChart.Skeleton';
1020
+ WaterfallChartSkeleton.layer = 'svg' as Layer;
1021
+
1022
+ /* -------------------------------------------------------------------------- */
1023
+ /* Overlay layer */
1024
+ /* -------------------------------------------------------------------------- */
1025
+
1026
+ /**
1027
+ * Narrowest a step label may be drawn before the axis starts dropping some.
1028
+ * Roughly three characters at `xs`, which is what a short name needs.
1029
+ */
1030
+ const MIN_BAND_LABEL = 34;
1031
+
1032
+ export interface WaterfallChartXAxisProps {
1033
+ /**
1034
+ * How many labels to show. Every step by default, thinned only when the bands
1035
+ * get too narrow to read — pass a number to force it lower.
1036
+ */
1037
+ ticks?: number;
1038
+ /** Turn a step into its label. Defaults to its `label`. */
1039
+ format?: (step: WaterfallStep, index: number) => string;
1040
+ className?: string;
1041
+ }
1042
+
1043
+ /**
1044
+ * The step names, one under each band it has room for. Real text rather than
1045
+ * SVG text, so they follow the theme's font and the platform's text scaling —
1046
+ * SVG text does neither.
1047
+ */
1048
+ function WaterfallChartXAxis({ ticks, format, className }: WaterfallChartXAxisProps) {
1049
+ const { steps, plot, orientation } = useChart('WaterfallChart.XAxis');
1050
+
1051
+ const labels = useMemo(() => {
1052
+ if (!steps.length) return [];
1053
+
1054
+ /*
1055
+ * Every step, unless they will not fit. The axis asks the plot how much
1056
+ * room there is and only thins when the answer is not enough — a fixed tick
1057
+ * count drops names that had room to be drawn.
1058
+ */
1059
+ const room = Math.max(1, Math.floor(plot.width / MIN_BAND_LABEL));
1060
+ const count = Math.max(1, Math.min(ticks ?? room, steps.length));
1061
+
1062
+ // Every nth band, rather than a fractional step rounded to the nearest
1063
+ // index — rounding lands on the same band twice and skips its neighbour.
1064
+ const stride = Math.ceil(steps.length / count);
1065
+ const picked: { key: number; text: string }[] = [];
1066
+ for (let index = 0; index < steps.length; index += stride) {
1067
+ const step = steps[index];
1068
+ if (!step) continue;
1069
+ picked.push({ key: index, text: format ? format(step, index) : step.label });
1070
+ }
1071
+ return picked;
1072
+ }, [steps, ticks, format, plot.width]);
1073
+
1074
+ if (orientation === 'horizontal') return null;
1075
+
1076
+ /*
1077
+ * One box per band, exactly the band's width. Tiling them rather than giving
1078
+ * each a fixed width means they can never overlap each other and the first
1079
+ * and last can never hang off the ends of the plot — the row of labels
1080
+ * occupies precisely the space the bars do.
1081
+ */
1082
+ const bandWidth = steps.length > 0 ? plot.width / steps.length : 0;
1083
+
1084
+ return (
1085
+ <View
1086
+ style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
1087
+ className={cn(className)}
1088
+ >
1089
+ {labels.map((label) => (
1090
+ <Text
1091
+ key={label.key}
1092
+ size="xs"
1093
+ muted
1094
+ numberOfLines={1}
1095
+ style={{
1096
+ position: 'absolute',
1097
+ bottom: 0,
1098
+ left: plot.left + bandWidth * label.key,
1099
+ width: bandWidth,
1100
+ textAlign: 'center',
1101
+ }}
1102
+ >
1103
+ {label.text}
1104
+ </Text>
1105
+ ))}
1106
+ </View>
1107
+ );
1108
+ }
1109
+ WaterfallChartXAxis.displayName = 'WaterfallChart.XAxis';
1110
+ WaterfallChartXAxis.layer = 'overlay' as Layer;
1111
+
1112
+ export interface WaterfallChartYAxisProps {
1113
+ /** How many labels to show along the value axis. */
1114
+ ticks?: number;
1115
+ /** Format a value for its label. Defaults to a compact number. */
1116
+ format?: (value: number) => string;
1117
+ className?: string;
1118
+ }
1119
+
1120
+ /** Value labels down the side, aligned to the grid lines. */
1121
+ function WaterfallChartYAxis({ ticks = 4, format, className }: WaterfallChartYAxisProps) {
1122
+ const { plot, steps, orientation, extent } = useChart('WaterfallChart.YAxis');
1123
+
1124
+ const horizontal = orientation === 'horizontal';
1125
+
1126
+ /*
1127
+ * Read off the settled domain rather than the shared values the paths use.
1128
+ * A label is text, and text is JS — following the tween would re-render on
1129
+ * every frame of it to redraw a number nobody can read while it moves.
1130
+ */
1131
+ const labels = useMemo(() => {
1132
+ if (horizontal) {
1133
+ // Sideways, the side of the chart is the step axis.
1134
+ return steps.map((step, index) => ({ key: index, text: step.label }));
1135
+ }
1136
+ const [min, max] = extent;
1137
+ if (min === 0 && max === 0) return [];
1138
+ return Array.from({ length: ticks + 1 }, (_unused, index) => {
1139
+ const value = max - ((max - min) * index) / ticks;
1140
+ return { key: index, text: format ? format(value) : compactNumber(value) };
1141
+ });
1142
+ }, [extent, ticks, format, horizontal, steps]);
1143
+
1144
+ /*
1145
+ * Sideways the labels sit in the gutter the plot already left for them, one
1146
+ * band each — `flex-1` per row rather than spacing them edge to edge, so
1147
+ * every name lands beside its own bar instead of only the first and last
1148
+ * doing so. Upright there are no bands to line up with, so the ticks space
1149
+ * themselves against the grid.
1150
+ */
1151
+ return (
1152
+ <View
1153
+ pointerEvents="none"
1154
+ style={{
1155
+ position: 'absolute',
1156
+ left: 0,
1157
+ // Upright, each label is centred on the grid line it names: the strip
1158
+ // is lifted half a label and grown by a whole one, so `justify-between`
1159
+ // lands the text's middle on the line rather than its top edge on the
1160
+ // first and its bottom edge on the last.
1161
+ top: horizontal ? plot.top : plot.top - AXIS_LABEL_HEIGHT / 2,
1162
+ height: horizontal ? plot.height : plot.height + AXIS_LABEL_HEIGHT,
1163
+ width: horizontal ? Math.max(plot.left - 8, 0) : undefined,
1164
+ }}
1165
+ className={cn(horizontal ? 'items-end' : 'justify-between', className)}
1166
+ >
1167
+ {labels.map((label) => (
1168
+ <View key={label.key} className={horizontal ? 'flex-1 justify-center' : undefined}>
1169
+ <Text size="xs" muted numberOfLines={1}>
1170
+ {label.text}
1171
+ </Text>
1172
+ </View>
1173
+ ))}
1174
+ </View>
1175
+ );
1176
+ }
1177
+ WaterfallChartYAxis.displayName = 'WaterfallChart.YAxis';
1178
+ WaterfallChartYAxis.layer = 'overlay' as Layer;
1179
+ // Read by the root, which has to leave room for the labels before it lays the
1180
+ // plot out — an axis drawn over the plot is unreadable, and makes what it is
1181
+ // drawn over unreadable too.
1182
+ WaterfallChartYAxis.axis = 'y' as const;
1183
+
1184
+ export interface WaterfallChartValuesProps {
1185
+ /** Format a step's number. Defaults to a signed compact number. */
1186
+ format?: (step: WaterfallStep, index: number) => string;
1187
+ className?: string;
1188
+ }
1189
+
1190
+ /**
1191
+ * The change each step made, written at the far end of its bar.
1192
+ *
1193
+ * Signed, because on this chart the sign is the reading: a bar's direction
1194
+ * already says which way it went, and a label that drops the sign makes the
1195
+ * two directions look like the same number twice.
1196
+ *
1197
+ * Only drawn upright. Sideways the bars run across a plot whose width is a
1198
+ * phone's, and a number at the end of one has nowhere to go that is not on top
1199
+ * of the bar or off the chart.
1200
+ */
1201
+ function WaterfallChartValues({ format, className }: WaterfallChartValuesProps) {
1202
+ const { steps, plot, orientation, status, barGap, barWidth, extent } =
1203
+ useChart('WaterfallChart.Values');
1204
+
1205
+ if (orientation === 'horizontal' || status === 'loading' || !steps.length) return null;
1206
+ if (plot.width <= 0 || plot.height <= 0) return null;
1207
+
1208
+ const bands = bandsOf(plot, steps.length, false, barGap, barWidth);
1209
+ const [min, max] = extent;
1210
+ const range = max - min || 1;
1211
+
1212
+ return (
1213
+ <View style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
1214
+ {steps.map((step, index) => {
1215
+ const fraction = (step.end - min) / range;
1216
+ const y = plot.top + plot.height - fraction * plot.height;
1217
+ const forward = step.end >= step.start;
1218
+ const text = format
1219
+ ? format(step, index)
1220
+ : step.kind === 'total'
1221
+ ? compactNumber(step.value)
1222
+ : `${step.value > 0 ? '+' : ''}${compactNumber(step.value)}`;
1223
+
1224
+ return (
1225
+ <Text
1226
+ key={index}
1227
+ size="xs"
1228
+ weight="medium"
1229
+ numberOfLines={1}
1230
+ className={cn(className)}
1231
+ style={{
1232
+ position: 'absolute',
1233
+ // Sat off the growing end of the bar, on whichever side that is.
1234
+ // A label inside a bar is unreadable on a short one and a label
1235
+ // always above is inside the plot's ceiling on the tall one.
1236
+ top: forward ? y - AXIS_LABEL_HEIGHT - 2 : y + 2,
1237
+ left: plot.left + bands.band * index,
1238
+ width: bands.band,
1239
+ textAlign: 'center',
1240
+ }}
1241
+ >
1242
+ {text}
1243
+ </Text>
1244
+ );
1245
+ })}
1246
+ </View>
1247
+ );
1248
+ }
1249
+ WaterfallChartValues.displayName = 'WaterfallChart.Values';
1250
+ WaterfallChartValues.layer = 'overlay' as Layer;
1251
+
1252
+ export interface WaterfallChartTooltipProps {
1253
+ /** Format the step's change. Defaults to a signed compact number. */
1254
+ formatValue?: (step: WaterfallStep) => string;
1255
+ /** Format the running total line. Return `null` to drop it. */
1256
+ formatTotal?: (step: WaterfallStep) => string | null;
1257
+ className?: string;
1258
+ }
1259
+
1260
+ /**
1261
+ * The readout, and the drag that drives it.
1262
+ *
1263
+ * It reports two numbers, because a step on this chart has two: what it changed
1264
+ * by, and what the balance stood at afterwards. The second is the one a bar's
1265
+ * position encodes and its length does not, so a readout that only gave the
1266
+ * change would leave the reader converting the height back by eye.
1267
+ *
1268
+ * The hit area is the whole plot. A readout you have to land on the bar to
1269
+ * summon is a readout nobody finds — and the bars here are narrower than a bar
1270
+ * chart's, since the gap between them is what the connectors run through.
1271
+ */
1272
+ function WaterfallChartTooltip({
1273
+ formatValue,
1274
+ formatTotal,
1275
+ className,
1276
+ }: WaterfallChartTooltipProps) {
1277
+ const {
1278
+ steps,
1279
+ plot,
1280
+ orientation,
1281
+ colors,
1282
+ activeIndex,
1283
+ activeIndexJS,
1284
+ setActiveIndexJS,
1285
+ status,
1286
+ } = useChart('WaterfallChart.Tooltip');
1287
+
1288
+ const total = steps.length;
1289
+ const horizontal = orientation === 'horizontal';
1290
+ const left = plot.left;
1291
+ const top = plot.top;
1292
+ const width = plot.width;
1293
+ const height = plot.height;
1294
+
1295
+ /*
1296
+ * The readout's own height, measured rather than assumed. Sideways it has to
1297
+ * be clamped inside the plot vertically, and how tall it is depends on
1298
+ * whether the running total line is shown.
1299
+ */
1300
+ const labelHeight = useSharedValue(0);
1301
+
1302
+ /*
1303
+ * Declared inside the memo, next to its callers: a worklet may only call
1304
+ * another worklet, and the rule is enforced by crashing rather than warning.
1305
+ */
1306
+ const pan = useMemo(() => {
1307
+ const resolve = (x: number, y: number) => {
1308
+ 'worklet';
1309
+ if (!total) return;
1310
+ const span = horizontal ? height : width;
1311
+ const offset = (horizontal ? y - top : x - left) / (span || 1);
1312
+ // Bands, not points: the finger is inside whichever slice it lands on,
1313
+ // which is a floor rather than a round to the nearest centre.
1314
+ const next = Math.max(0, Math.min(total - 1, Math.floor(offset * total)));
1315
+ if (next === activeIndex.value) return;
1316
+ activeIndex.value = next;
1317
+ runOnJS(setActiveIndexJS)(next);
1318
+ };
1319
+
1320
+ return Gesture.Pan()
1321
+ .minDistance(0)
1322
+ .onBegin((event) => {
1323
+ 'worklet';
1324
+ resolve(event.x, event.y);
1325
+ })
1326
+ .onUpdate((event) => {
1327
+ 'worklet';
1328
+ resolve(event.x, event.y);
1329
+ })
1330
+ .onFinalize(() => {
1331
+ 'worklet';
1332
+ activeIndex.value = -1;
1333
+ runOnJS(setActiveIndexJS)(-1);
1334
+ });
1335
+ }, [total, left, top, width, height, horizontal, activeIndex, setActiveIndexJS]);
1336
+
1337
+ // The readout centres over its band and is clamped inside the plot, so it
1338
+ // never runs off the edge at the first or last one. Which axis it follows is
1339
+ // the axis the bands run along.
1340
+ const labelStyle = useAnimatedStyle(() => {
1341
+ const index = activeIndex.value;
1342
+ if (index < 0 || !total) return { opacity: 0 };
1343
+ const band = (horizontal ? plot.height : plot.width) / total;
1344
+ const centre = (horizontal ? plot.top : plot.left) + band * (index + 0.5);
1345
+ const half = LABEL_WIDTH / 2;
1346
+ const clamped = Math.min(
1347
+ plot.left + plot.width - half,
1348
+ Math.max(plot.left + half, horizontal ? plot.left + plot.width / 2 : centre)
1349
+ );
1350
+
1351
+ if (!horizontal) {
1352
+ return { opacity: 1, transform: [{ translateX: clamped - half }] };
1353
+ }
1354
+
1355
+ const tall = labelHeight.value;
1356
+ const y = Math.min(
1357
+ plot.top + Math.max(plot.height - tall, 0),
1358
+ Math.max(plot.top, centre - tall / 2)
1359
+ );
1360
+ return { opacity: 1, transform: [{ translateX: clamped - half }, { translateY: y }] };
1361
+ });
1362
+
1363
+ const active = activeIndexJS >= 0 ? steps[activeIndexJS] : null;
1364
+ const fmtValue =
1365
+ formatValue ??
1366
+ ((step: WaterfallStep) =>
1367
+ step.kind === 'total'
1368
+ ? compactNumber(step.value)
1369
+ : `${step.value > 0 ? '+' : ''}${compactNumber(step.value)}`);
1370
+ const fmtTotal =
1371
+ formatTotal ??
1372
+ ((step: WaterfallStep) => (step.kind === 'total' ? null : compactNumber(step.end)));
1373
+
1374
+ if (status === 'loading') return null;
1375
+
1376
+ const runningTotal = active ? fmtTotal(active) : null;
1377
+
1378
+ return (
1379
+ <GestureDetector gesture={pan}>
1380
+ <View style={StyleSheet.absoluteFill}>
1381
+ <Animated.View
1382
+ pointerEvents="none"
1383
+ style={[{ position: 'absolute', left: 0, top: 0, width: LABEL_WIDTH }, labelStyle]}
1384
+ >
1385
+ {active ? (
1386
+ <View
1387
+ onLayout={(event) => {
1388
+ labelHeight.value = event.nativeEvent.layout.height;
1389
+ }}
1390
+ className={cn(
1391
+ 'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',
1392
+ className
1393
+ )}
1394
+ >
1395
+ <Text size="xs" muted numberOfLines={1}>
1396
+ {active.label}
1397
+ </Text>
1398
+ <View className="flex-row items-center gap-1.5">
1399
+ <View
1400
+ style={{
1401
+ width: 6,
1402
+ height: 6,
1403
+ borderRadius: 3,
1404
+ backgroundColor: active.datum.color ?? colors[active.kind],
1405
+ }}
1406
+ />
1407
+ <Text size="xs" weight="medium">
1408
+ {fmtValue(active)}
1409
+ </Text>
1410
+ </View>
1411
+ {runningTotal ? (
1412
+ <Text size="xs" muted numberOfLines={1}>
1413
+ {runningTotal}
1414
+ </Text>
1415
+ ) : null}
1416
+ </View>
1417
+ ) : null}
1418
+ </Animated.View>
1419
+ </View>
1420
+ </GestureDetector>
1421
+ );
1422
+ }
1423
+ WaterfallChartTooltip.displayName = 'WaterfallChart.Tooltip';
1424
+ WaterfallChartTooltip.layer = 'overlay' as Layer;
1425
+
1426
+ /** One role's colour and name. Shared by the legend and the header. */
1427
+ function RoleSwatch({ color, label }: { color: string; label: string }) {
1428
+ return (
1429
+ <View className="flex-row items-center gap-1.5">
1430
+ <View style={{ backgroundColor: color }} className="h-2 w-2 rounded-full" />
1431
+ <Text size="xs" muted>
1432
+ {label}
1433
+ </Text>
1434
+ </View>
1435
+ );
1436
+ }
1437
+
1438
+ export interface WaterfallChartLegendProps extends ViewProps {
1439
+ className?: string;
1440
+ /** Names for the three roles. */
1441
+ labels?: Partial<Record<WaterfallKind, string>>;
1442
+ }
1443
+
1444
+ /**
1445
+ * A swatch and a name for each role the run actually contains.
1446
+ *
1447
+ * Three entries at most, and only the ones present — a run with no totals in it
1448
+ * listing a "Total" colour is a key to a colour that is not on the chart.
1449
+ */
1450
+ function WaterfallChartLegend({ className, labels, ...props }: WaterfallChartLegendProps) {
1451
+ const { steps, colors } = useChart('WaterfallChart.Legend');
1452
+
1453
+ const present = useMemo(() => {
1454
+ const order: WaterfallKind[] = ['rise', 'fall', 'total'];
1455
+ const seen = new Set(steps.map((step) => step.kind));
1456
+ return order.filter((kind) => seen.has(kind));
1457
+ }, [steps]);
1458
+
1459
+ if (!present.length) return null;
1460
+
1461
+ const names: Record<WaterfallKind, string> = {
1462
+ rise: labels?.rise ?? 'Increase',
1463
+ fall: labels?.fall ?? 'Decrease',
1464
+ total: labels?.total ?? 'Total',
1465
+ };
1466
+
1467
+ return (
1468
+ <View
1469
+ {...props}
1470
+ style={[{ pointerEvents: 'none' }, props.style]}
1471
+ className={cn('absolute right-2 top-1 flex-row gap-3', className)}
1472
+ >
1473
+ {present.map((kind) => (
1474
+ <RoleSwatch key={kind} color={colors[kind]} label={names[kind]} />
1475
+ ))}
1476
+ </View>
1477
+ );
1478
+ }
1479
+ WaterfallChartLegend.displayName = 'WaterfallChart.Legend';
1480
+ WaterfallChartLegend.layer = 'overlay' as Layer;
1481
+
1482
+ /* -------------------------------------------------------------------------- */
1483
+ /* Header layer */
1484
+ /* -------------------------------------------------------------------------- */
1485
+
1486
+ export interface WaterfallChartHeaderProps extends ViewProps {
1487
+ className?: string;
1488
+ /** Small line above the value — what the run is of. */
1489
+ title?: string;
1490
+ /** The readout. The largest thing on the card, and the first thing read. */
1491
+ value?: string;
1492
+ /** One muted line under the value — a period, a comparison, a total. */
1493
+ caption?: string;
1494
+ /** Names for the three roles, as the legend takes. */
1495
+ labels?: Partial<Record<WaterfallKind, string>>;
1496
+ /**
1497
+ * Draw a swatch and a name per role along the trailing edge. Prefer this to
1498
+ * `WaterfallChart.Legend` on a chart that has a header: the legend floats
1499
+ * over the plot, where it competes with the bars for the same corner.
1500
+ */
1501
+ legend?: boolean;
1502
+ /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */
1503
+ children?: ReactNode;
1504
+ }
1505
+
1506
+ /**
1507
+ * The strip above the plot: what the run is of, what it currently reads, and
1508
+ * what the colours mean.
1509
+ *
1510
+ * The value is not derived here. A readout that follows the finger belongs to
1511
+ * whoever owns the data — take it from `onActiveIndexChange` and pass the
1512
+ * formatted string down, so one header can show the closing balance when
1513
+ * nothing is pressed and a step's change when something is.
1514
+ */
1515
+ function WaterfallChartHeader({
1516
+ className,
1517
+ title,
1518
+ value,
1519
+ caption,
1520
+ labels,
1521
+ legend = false,
1522
+ children,
1523
+ ...props
1524
+ }: WaterfallChartHeaderProps) {
1525
+ const { steps, colors } = useChart('WaterfallChart.Header');
1526
+
1527
+ const present = useMemo(() => {
1528
+ const order: WaterfallKind[] = ['rise', 'fall', 'total'];
1529
+ const seen = new Set(steps.map((step) => step.kind));
1530
+ return order.filter((kind) => seen.has(kind));
1531
+ }, [steps]);
1532
+
1533
+ const names: Record<WaterfallKind, string> = {
1534
+ rise: labels?.rise ?? 'Increase',
1535
+ fall: labels?.fall ?? 'Decrease',
1536
+ total: labels?.total ?? 'Total',
1537
+ };
1538
+
1539
+ const trailing =
1540
+ children ??
1541
+ (legend && present.length ? (
1542
+ <View className="flex-row flex-wrap items-center justify-end gap-x-3 gap-y-1">
1543
+ {present.map((kind) => (
1544
+ <RoleSwatch key={kind} color={colors[kind]} label={names[kind]} />
1545
+ ))}
1546
+ </View>
1547
+ ) : null);
1548
+
1549
+ return (
1550
+ <View {...props} className={cn('flex-row items-start justify-between gap-3 pb-3', className)}>
1551
+ <View className="flex-1 gap-0.5">
1552
+ {title ? (
1553
+ <Text size="xs" muted>
1554
+ {title}
1555
+ </Text>
1556
+ ) : null}
1557
+ {value ? (
1558
+ <Text size="xl" weight="bold">
1559
+ {value}
1560
+ </Text>
1561
+ ) : null}
1562
+ {caption ? (
1563
+ <Text size="xs" muted>
1564
+ {caption}
1565
+ </Text>
1566
+ ) : null}
1567
+ </View>
1568
+ {/* Shrinkable, unlike a view's default in React Native. Held rigid, a
1569
+ three-role key takes the width it wants and the caption underneath the
1570
+ value wraps to two lines to make room for it. */}
1571
+ {trailing ? <View className="shrink pt-1">{trailing}</View> : null}
1572
+ </View>
1573
+ );
1574
+ }
1575
+ WaterfallChartHeader.displayName = 'WaterfallChart.Header';
1576
+ WaterfallChartHeader.layer = 'header' as Layer;
1577
+
1578
+ export const WaterfallChart = Object.assign(WaterfallChartRoot, {
1579
+ Header: WaterfallChartHeader,
1580
+ Grid: WaterfallChartGrid,
1581
+ Connectors: WaterfallChartConnectors,
1582
+ Bars: WaterfallChartBars,
1583
+ Values: WaterfallChartValues,
1584
+ Skeleton: WaterfallChartSkeleton,
1585
+ XAxis: WaterfallChartXAxis,
1586
+ YAxis: WaterfallChartYAxis,
1587
+ Tooltip: WaterfallChartTooltip,
1588
+ Legend: WaterfallChartLegend,
1589
+ });