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.
- package/README.md +5 -0
- package/lib/module/components/badge/index.js +1 -1
- package/lib/module/components/badge/index.js.map +1 -1
- package/lib/module/components/bar-chart/index.js +39 -2
- package/lib/module/components/bar-chart/index.js.map +1 -1
- package/lib/module/components/bottom-sheet/index.js +2 -2
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/button/index.js +19 -9
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/carousel/carousel-lifecycle.js +51 -0
- package/lib/module/components/carousel/carousel-lifecycle.js.map +1 -0
- package/lib/module/components/carousel/index.js +86 -35
- package/lib/module/components/carousel/index.js.map +1 -1
- package/lib/module/components/checkbox/index.js +2 -3
- package/lib/module/components/checkbox/index.js.map +1 -1
- package/lib/module/components/context-menu/context-menu-invocation.js +23 -0
- package/lib/module/components/context-menu/context-menu-invocation.js.map +1 -0
- package/lib/module/components/context-menu/index.js +78 -9
- package/lib/module/components/context-menu/index.js.map +1 -1
- package/lib/module/components/dialog/index.js +2 -2
- package/lib/module/components/dialog/index.js.map +1 -1
- package/lib/module/components/drawer/index.js +2 -2
- package/lib/module/components/drawer/index.js.map +1 -1
- package/lib/module/components/fab/index.js +4 -3
- package/lib/module/components/fab/index.js.map +1 -1
- package/lib/module/components/field/index.js +41 -6
- package/lib/module/components/field/index.js.map +1 -1
- package/lib/module/components/flow/flow-accessibility.js +100 -0
- package/lib/module/components/flow/flow-accessibility.js.map +1 -0
- package/lib/module/components/flow/flow-identifiers.js +81 -0
- package/lib/module/components/flow/flow-identifiers.js.map +1 -0
- package/lib/module/components/flow/index.js +112 -57
- package/lib/module/components/flow/index.js.map +1 -1
- package/lib/module/components/form/use-form.js +93 -66
- package/lib/module/components/form/use-form.js.map +1 -1
- package/lib/module/components/heatmap-chart/index.js +2 -2
- package/lib/module/components/heatmap-chart/index.js.map +1 -1
- package/lib/module/components/hex-chart/index.js +2 -2
- package/lib/module/components/hex-chart/index.js.map +1 -1
- package/lib/module/components/kpi/index.js +3 -1
- package/lib/module/components/kpi/index.js.map +1 -1
- package/lib/module/components/line-chart/index.js +3 -3
- package/lib/module/components/line-chart/index.js.map +1 -1
- package/lib/module/components/map/index.js +71 -8
- package/lib/module/components/map/index.js.map +1 -1
- package/lib/module/components/map/map-accessibility.js +20 -0
- package/lib/module/components/map/map-accessibility.js.map +1 -0
- package/lib/module/components/map/maplibre.js.map +1 -1
- package/lib/module/components/menu/index.js +4 -1
- package/lib/module/components/menu/index.js.map +1 -1
- package/lib/module/components/message-scroller/index.js +17 -9
- package/lib/module/components/message-scroller/index.js.map +1 -1
- package/lib/module/components/message-scroller/message-scroller-math.js +20 -0
- package/lib/module/components/message-scroller/message-scroller-math.js.map +1 -0
- package/lib/module/components/number-input/index.js +1 -23
- package/lib/module/components/number-input/index.js.map +1 -1
- package/lib/module/components/number-input/number-input-math.js +44 -0
- package/lib/module/components/number-input/number-input-math.js.map +1 -0
- package/lib/module/components/plot/index.js +2 -2
- package/lib/module/components/plot/index.js.map +1 -1
- package/lib/module/components/popover/index.js.map +1 -1
- package/lib/module/components/radio-group/index.js +1 -2
- package/lib/module/components/radio-group/index.js.map +1 -1
- package/lib/module/components/selection-mode/index.js +26 -31
- package/lib/module/components/selection-mode/index.js.map +1 -1
- package/lib/module/components/selection-mode/selection-mode-contracts.js +48 -0
- package/lib/module/components/selection-mode/selection-mode-contracts.js.map +1 -0
- package/lib/module/components/signature/index.js +62 -20
- package/lib/module/components/signature/index.js.map +1 -1
- package/lib/module/components/signature/signature-accessibility.js +58 -0
- package/lib/module/components/signature/signature-accessibility.js.map +1 -0
- package/lib/module/components/slider/index.js +22 -18
- package/lib/module/components/slider/index.js.map +1 -1
- package/lib/module/components/sortable/index.js +5 -55
- package/lib/module/components/sortable/index.js.map +1 -1
- package/lib/module/components/sortable/reorder.js +63 -0
- package/lib/module/components/sortable/reorder.js.map +1 -0
- package/lib/module/components/swipe/index.js +4 -4
- package/lib/module/components/swipe/index.js.map +1 -1
- package/lib/module/components/switch/index.js +36 -3
- package/lib/module/components/switch/index.js.map +1 -1
- package/lib/module/components/time-picker/accessibility.js +21 -0
- package/lib/module/components/time-picker/accessibility.js.map +1 -0
- package/lib/module/components/time-picker/index.js +34 -8
- package/lib/module/components/time-picker/index.js.map +1 -1
- package/lib/module/components/waterfall-chart/index.js +1325 -0
- package/lib/module/components/waterfall-chart/index.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/primitives/modal-isolation-store.js +29 -0
- package/lib/module/primitives/modal-isolation-store.js.map +1 -0
- package/lib/module/primitives/portal.js +36 -4
- package/lib/module/primitives/portal.js.map +1 -1
- package/lib/module/primitives/scrim.js +94 -3
- package/lib/module/primitives/scrim.js.map +1 -1
- package/lib/module/providers/panel-ui-provider.js +27 -6
- package/lib/module/providers/panel-ui-provider.js.map +1 -1
- package/lib/typescript/src/components/badge/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +3 -0
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/carousel/carousel-lifecycle.d.ts +23 -0
- package/lib/typescript/src/components/carousel/carousel-lifecycle.d.ts.map +1 -0
- package/lib/typescript/src/components/carousel/index.d.ts +5 -2
- package/lib/typescript/src/components/carousel/index.d.ts.map +1 -1
- package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/context-menu/context-menu-invocation.d.ts +20 -0
- package/lib/typescript/src/components/context-menu/context-menu-invocation.d.ts.map +1 -0
- package/lib/typescript/src/components/context-menu/index.d.ts +18 -8
- package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -1
- package/lib/typescript/src/components/dialog/index.d.ts +3 -0
- package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
- package/lib/typescript/src/components/drawer/index.d.ts +3 -0
- package/lib/typescript/src/components/drawer/index.d.ts.map +1 -1
- package/lib/typescript/src/components/fab/index.d.ts.map +1 -1
- package/lib/typescript/src/components/field/index.d.ts +8 -1
- package/lib/typescript/src/components/field/index.d.ts.map +1 -1
- package/lib/typescript/src/components/flow/flow-accessibility.d.ts +57 -0
- package/lib/typescript/src/components/flow/flow-accessibility.d.ts.map +1 -0
- package/lib/typescript/src/components/flow/flow-identifiers.d.ts +24 -0
- package/lib/typescript/src/components/flow/flow-identifiers.d.ts.map +1 -0
- package/lib/typescript/src/components/flow/index.d.ts +20 -7
- package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
- package/lib/typescript/src/components/form/use-form.d.ts.map +1 -1
- package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
- package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/map/index.d.ts +13 -5
- package/lib/typescript/src/components/map/index.d.ts.map +1 -1
- package/lib/typescript/src/components/map/map-accessibility.d.ts +19 -0
- package/lib/typescript/src/components/map/map-accessibility.d.ts.map +1 -0
- package/lib/typescript/src/components/map/maplibre.d.ts +2 -0
- package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -1
- package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
- package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
- package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts +8 -0
- package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts.map +1 -0
- package/lib/typescript/src/components/number-input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/number-input/number-input-math.d.ts +9 -0
- package/lib/typescript/src/components/number-input/number-input-math.d.ts.map +1 -0
- package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
- package/lib/typescript/src/components/popover/index.d.ts +3 -0
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
- package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/selection-mode/index.d.ts +6 -4
- package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -1
- package/lib/typescript/src/components/selection-mode/selection-mode-contracts.d.ts +18 -0
- package/lib/typescript/src/components/selection-mode/selection-mode-contracts.d.ts.map +1 -0
- package/lib/typescript/src/components/signature/index.d.ts +6 -0
- package/lib/typescript/src/components/signature/index.d.ts.map +1 -1
- package/lib/typescript/src/components/signature/signature-accessibility.d.ts +27 -0
- package/lib/typescript/src/components/signature/signature-accessibility.d.ts.map +1 -0
- package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
- package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
- package/lib/typescript/src/components/sortable/reorder.d.ts +14 -0
- package/lib/typescript/src/components/sortable/reorder.d.ts.map +1 -0
- package/lib/typescript/src/components/swipe/index.d.ts.map +1 -1
- package/lib/typescript/src/components/switch/index.d.ts +3 -3
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
- package/lib/typescript/src/components/time-picker/accessibility.d.ts +10 -0
- package/lib/typescript/src/components/time-picker/accessibility.d.ts.map +1 -0
- package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/waterfall-chart/index.d.ts +379 -0
- package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/modal-isolation-store.d.ts +10 -0
- package/lib/typescript/src/primitives/modal-isolation-store.d.ts.map +1 -0
- package/lib/typescript/src/primitives/portal.d.ts +6 -0
- package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
- package/lib/typescript/src/primitives/scrim.d.ts +5 -3
- package/lib/typescript/src/primitives/scrim.d.ts.map +1 -1
- package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
- package/package.json +11 -5
- package/src/components/badge/index.tsx +4 -1
- package/src/components/bar-chart/index.tsx +40 -3
- package/src/components/bottom-sheet/index.tsx +6 -3
- package/src/components/button/index.tsx +15 -21
- package/src/components/carousel/carousel-lifecycle.ts +74 -0
- package/src/components/carousel/index.tsx +118 -41
- package/src/components/checkbox/index.tsx +1 -2
- package/src/components/context-menu/context-menu-invocation.ts +44 -0
- package/src/components/context-menu/index.tsx +113 -13
- package/src/components/dialog/index.tsx +6 -3
- package/src/components/drawer/index.tsx +6 -3
- package/src/components/fab/index.tsx +8 -12
- package/src/components/field/index.tsx +52 -10
- package/src/components/flow/flow-accessibility.ts +123 -0
- package/src/components/flow/flow-identifiers.ts +98 -0
- package/src/components/flow/index.tsx +191 -69
- package/src/components/form/use-form.ts +101 -65
- package/src/components/heatmap-chart/index.tsx +2 -3
- package/src/components/hex-chart/index.tsx +2 -3
- package/src/components/kpi/index.tsx +3 -1
- package/src/components/line-chart/index.tsx +3 -2
- package/src/components/map/index.tsx +110 -7
- package/src/components/map/map-accessibility.ts +41 -0
- package/src/components/map/maplibre.ts +2 -0
- package/src/components/menu/index.tsx +4 -1
- package/src/components/message-scroller/index.tsx +39 -7
- package/src/components/message-scroller/message-scroller-math.ts +24 -0
- package/src/components/number-input/index.tsx +1 -26
- package/src/components/number-input/number-input-math.ts +50 -0
- package/src/components/plot/index.tsx +2 -1
- package/src/components/popover/index.tsx +3 -0
- package/src/components/radio-group/index.tsx +1 -2
- package/src/components/selection-mode/index.tsx +37 -30
- package/src/components/selection-mode/selection-mode-contracts.ts +53 -0
- package/src/components/signature/index.tsx +107 -20
- package/src/components/signature/signature-accessibility.ts +67 -0
- package/src/components/slider/index.tsx +28 -18
- package/src/components/sortable/index.tsx +12 -64
- package/src/components/sortable/reorder.ts +88 -0
- package/src/components/swipe/index.tsx +7 -4
- package/src/components/switch/index.tsx +42 -6
- package/src/components/time-picker/accessibility.ts +24 -0
- package/src/components/time-picker/index.tsx +48 -2
- package/src/components/waterfall-chart/index.tsx +1589 -0
- package/src/index.ts +25 -0
- package/src/primitives/modal-isolation-store.ts +31 -0
- package/src/primitives/portal.tsx +34 -4
- package/src/primitives/scrim.tsx +127 -7
- package/src/providers/panel-ui-provider.tsx +41 -9
- 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
|
+
});
|