panelui-native 0.86.1 → 0.88.0
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 +4 -2
- package/lib/module/components/animated-badge/index.js +451 -0
- package/lib/module/components/animated-badge/index.js.map +1 -0
- package/lib/module/components/bottom-sheet/index.js +17 -6
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/bubble-chart/index.js +1782 -0
- package/lib/module/components/bubble-chart/index.js.map +1 -0
- package/lib/module/components/feedback-dialog/index.js +474 -0
- package/lib/module/components/feedback-dialog/index.js.map +1 -0
- package/lib/module/components/pyramid-chart/index.js +1143 -0
- package/lib/module/components/pyramid-chart/index.js.map +1 -0
- package/lib/module/components/scroll-blur/index.js +363 -0
- package/lib/module/components/scroll-blur/index.js.map +1 -0
- package/lib/module/components/scroll-canvas/index.js +2 -0
- package/lib/module/components/scroll-canvas/index.js.map +1 -1
- package/lib/module/components/scroll-text/index.js +3 -0
- package/lib/module/components/scroll-text/index.js.map +1 -1
- package/lib/module/components/search-bar/index.js +753 -25
- package/lib/module/components/search-bar/index.js.map +1 -1
- package/lib/module/hooks/use-keyboard-avoidance.js +21 -1
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
- package/lib/module/hooks/use-reveal-progress.js +21 -1
- package/lib/module/hooks/use-reveal-progress.js.map +1 -1
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/chart.js +24 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/animated-badge/index.d.ts +224 -0
- package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -0
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +1 -15
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bubble-chart/index.d.ts +481 -0
- package/lib/typescript/src/components/bubble-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/feedback-dialog/index.d.ts +156 -0
- package/lib/typescript/src/components/feedback-dialog/index.d.ts.map +1 -0
- package/lib/typescript/src/components/pyramid-chart/index.d.ts +328 -0
- package/lib/typescript/src/components/pyramid-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/scroll-blur/index.d.ts +113 -0
- package/lib/typescript/src/components/scroll-blur/index.d.ts.map +1 -0
- package/lib/typescript/src/components/scroll-canvas/index.d.ts.map +1 -1
- package/lib/typescript/src/components/search-bar/index.d.ts +350 -6
- package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-reveal-progress.d.ts +6 -27
- package/lib/typescript/src/hooks/use-reveal-progress.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +6 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +14 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/animated-badge/index.tsx +513 -0
- package/src/components/bottom-sheet/index.tsx +23 -8
- package/src/components/bubble-chart/index.tsx +2145 -0
- package/src/components/feedback-dialog/index.tsx +557 -0
- package/src/components/pyramid-chart/index.tsx +1360 -0
- package/src/components/scroll-blur/index.tsx +466 -0
- package/src/components/scroll-canvas/index.tsx +2 -1
- package/src/components/scroll-text/index.tsx +3 -3
- package/src/components/search-bar/index.tsx +933 -45
- package/src/hooks/use-keyboard-avoidance.ts +24 -1
- package/src/hooks/use-reveal-progress.ts +30 -1
- package/src/index.ts +72 -1
- package/src/utils/chart.ts +27 -0
|
@@ -0,0 +1,1360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PyramidChart — two series mirrored about a centre line, drawn and animated on
|
|
3
|
+
* the UI thread.
|
|
4
|
+
*
|
|
5
|
+
* ```tsx
|
|
6
|
+
* <PyramidChart data={penguins} xDataKey="species">
|
|
7
|
+
* <PyramidChart.Grid />
|
|
8
|
+
* <PyramidChart.Bar dataKey="male" side="start" />
|
|
9
|
+
* <PyramidChart.Bar dataKey="female" side="end" />
|
|
10
|
+
* <PyramidChart.XAxis />
|
|
11
|
+
* <PyramidChart.YAxis />
|
|
12
|
+
* <PyramidChart.Tooltip />
|
|
13
|
+
* </PyramidChart>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* ## The two wings share one scale
|
|
17
|
+
*
|
|
18
|
+
* This is the whole contract, and everything else here follows from it. The
|
|
19
|
+
* domain is derived from the larger of the two series' extents and used by
|
|
20
|
+
* both, so a bar twice as long as the one facing it means twice as much.
|
|
21
|
+
* Scaling each side to its own maximum produces a chart whose halves cannot be
|
|
22
|
+
* compared — which is the only thing a pyramid is for.
|
|
23
|
+
*
|
|
24
|
+
* For the same reason the centre is zero and stays there. There is no `yDomain`
|
|
25
|
+
* that starts anywhere else, because a wing cropped at its base is a length
|
|
26
|
+
* that lies.
|
|
27
|
+
*
|
|
28
|
+
* ## Lengths, not signed values
|
|
29
|
+
*
|
|
30
|
+
* Which side a series is on comes from `side`, not from the sign of its
|
|
31
|
+
* numbers, so a value is a distance outward from the centre. A negative one has
|
|
32
|
+
* no direction left to grow in and is drawn as nothing; it still appears in the
|
|
33
|
+
* readout, so a data error shows up as a gap rather than as a bar pointing the
|
|
34
|
+
* wrong way.
|
|
35
|
+
*
|
|
36
|
+
* ## Where the category names go
|
|
37
|
+
*
|
|
38
|
+
* `labelPlacement="above"`, the default, gives each row a line of its own over
|
|
39
|
+
* its pair of bars. That leaves the two wings meeting in the middle with
|
|
40
|
+
* nothing standing between them, and puts the name where it is read before the
|
|
41
|
+
* lengths it belongs to rather than beside them.
|
|
42
|
+
*
|
|
43
|
+
* `"center"` puts the names in a gutter between the wings instead — taken off
|
|
44
|
+
* the bars rather than off the edges, so both wings stay equal — and `"start"`
|
|
45
|
+
* puts them down the left, for a chart with room to spare on that side.
|
|
46
|
+
*
|
|
47
|
+
* ## Every series is one path
|
|
48
|
+
*
|
|
49
|
+
* A `Bar` draws all its rectangles as subpaths of a single animated path, split
|
|
50
|
+
* in two so the row under the finger keeps full ink while the rest fade. Thirty
|
|
51
|
+
* rows is two animated props a frame rather than thirty.
|
|
52
|
+
*/
|
|
53
|
+
import {
|
|
54
|
+
Children,
|
|
55
|
+
createContext,
|
|
56
|
+
forwardRef,
|
|
57
|
+
isValidElement,
|
|
58
|
+
useContext,
|
|
59
|
+
useEffect,
|
|
60
|
+
useImperativeHandle,
|
|
61
|
+
useMemo,
|
|
62
|
+
useRef,
|
|
63
|
+
useState,
|
|
64
|
+
type ReactNode,
|
|
65
|
+
} from 'react';
|
|
66
|
+
import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
67
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
68
|
+
import Animated, {
|
|
69
|
+
Easing,
|
|
70
|
+
cancelAnimation,
|
|
71
|
+
runOnJS,
|
|
72
|
+
useAnimatedProps,
|
|
73
|
+
useAnimatedStyle,
|
|
74
|
+
useReducedMotion,
|
|
75
|
+
useSharedValue,
|
|
76
|
+
withRepeat,
|
|
77
|
+
withTiming,
|
|
78
|
+
type SharedValue,
|
|
79
|
+
} from 'react-native-reanimated';
|
|
80
|
+
import Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Stop } from 'react-native-svg';
|
|
81
|
+
import { useCSSVariable } from 'uniwind';
|
|
82
|
+
import {
|
|
83
|
+
ChartAccessibilityData,
|
|
84
|
+
type ChartAccessibilityProps,
|
|
85
|
+
} from '../../primitives/chart-accessibility';
|
|
86
|
+
import { Text } from '../../primitives/text';
|
|
87
|
+
import {
|
|
88
|
+
barPath,
|
|
89
|
+
columnValues,
|
|
90
|
+
compactNumber,
|
|
91
|
+
niceDomain,
|
|
92
|
+
useSeriesColor,
|
|
93
|
+
type Plot,
|
|
94
|
+
type SeriesColorIndex,
|
|
95
|
+
} from '../../utils/chart';
|
|
96
|
+
import { cn } from '../../utils/cn';
|
|
97
|
+
|
|
98
|
+
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
99
|
+
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
|
|
100
|
+
|
|
101
|
+
/** Room left around the plot for the value labels along the bottom. */
|
|
102
|
+
const PADDING = { top: 6, right: 10, bottom: 22, left: 10 };
|
|
103
|
+
|
|
104
|
+
/** Left gutter reserved for the category names when they sit at the start. */
|
|
105
|
+
const CATEGORY_GUTTER = 68;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Middle gutter reserved for them when they sit between the wings. Taken off
|
|
109
|
+
* the bars rather than off the edges, so the two wings stay equal.
|
|
110
|
+
*/
|
|
111
|
+
const CENTRE_GUTTER = 72;
|
|
112
|
+
|
|
113
|
+
/** Width the readout is laid out at, so it can be clamped inside the plot. */
|
|
114
|
+
const LABEL_WIDTH = 132;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Box each value label is centred in. Narrow, because the outermost tick on
|
|
118
|
+
* each wing sits on the plot's own edge — a box centred there hangs half its
|
|
119
|
+
* width off the side of the chart, and the number is cropped away.
|
|
120
|
+
*/
|
|
121
|
+
const AXIS_LABEL_WIDTH = 56;
|
|
122
|
+
|
|
123
|
+
/** Line height of an `xs` label, for centring one on the tick it names. */
|
|
124
|
+
const AXIS_LABEL_HEIGHT = 16;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Steps the value axis is rounded out to. Four rather than the two labels each
|
|
128
|
+
* wing draws, so the halfway label lands on a round number too.
|
|
129
|
+
*/
|
|
130
|
+
const DOMAIN_STEPS = 4;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Height taken off the top of every band for the name sitting over it. An `xs`
|
|
134
|
+
* line plus the air that keeps it off the bar it belongs to.
|
|
135
|
+
*/
|
|
136
|
+
const LABEL_ROW = 20;
|
|
137
|
+
|
|
138
|
+
type Layer = 'svg' | 'overlay' | 'header';
|
|
139
|
+
|
|
140
|
+
export type PyramidChartStatus = 'loading' | 'ready';
|
|
141
|
+
|
|
142
|
+
/** Which wing a series grows into. */
|
|
143
|
+
export type PyramidChartSide = 'start' | 'end';
|
|
144
|
+
|
|
145
|
+
/** Where the category names sit. */
|
|
146
|
+
export type PyramidChartLabelPlacement = 'above' | 'center' | 'start';
|
|
147
|
+
|
|
148
|
+
export type PyramidChartDatum = Record<string, string | number | null | undefined>;
|
|
149
|
+
|
|
150
|
+
interface PyramidSeries {
|
|
151
|
+
key: string;
|
|
152
|
+
color: string;
|
|
153
|
+
side: PyramidChartSide;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface PyramidChartContextValue {
|
|
157
|
+
data: PyramidChartDatum[];
|
|
158
|
+
xDataKey: string;
|
|
159
|
+
plot: Plot;
|
|
160
|
+
status: PyramidChartStatus;
|
|
161
|
+
/** Points held back in the middle for the category names. Zero otherwise. */
|
|
162
|
+
gutter: number;
|
|
163
|
+
/** Points taken off the top of every band for a name sitting over it. */
|
|
164
|
+
labelInset: number;
|
|
165
|
+
labelPlacement: PyramidChartLabelPlacement;
|
|
166
|
+
barGap: number;
|
|
167
|
+
barWidth: number | undefined;
|
|
168
|
+
cornerRadius: number;
|
|
169
|
+
minBarLength: number;
|
|
170
|
+
fadedOpacity: number;
|
|
171
|
+
series: PyramidSeries[];
|
|
172
|
+
registerSeries: (series: PyramidSeries) => void;
|
|
173
|
+
unregisterSeries: (key: string) => void;
|
|
174
|
+
/** The far end of the shared scale. The near end is zero, always. */
|
|
175
|
+
domainMax: SharedValue<number>;
|
|
176
|
+
/** The settled maximum, for the parts that draw text rather than geometry. */
|
|
177
|
+
extentMax: number;
|
|
178
|
+
/** 0 to 1 as the bars grow out. Shared, so they arrive as one chart. */
|
|
179
|
+
reveal: SharedValue<number>;
|
|
180
|
+
activeIndex: SharedValue<number>;
|
|
181
|
+
activeIndexJS: number;
|
|
182
|
+
setActiveIndexJS: (index: number) => void;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const PyramidChartContext = createContext<PyramidChartContextValue | null>(null);
|
|
186
|
+
|
|
187
|
+
function useChart(component: string): PyramidChartContextValue {
|
|
188
|
+
const context = useContext(PyramidChartContext);
|
|
189
|
+
if (!context) {
|
|
190
|
+
throw new Error(`${component} must be used within a <PyramidChart>`);
|
|
191
|
+
}
|
|
192
|
+
return context;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The row under the finger, for something rendered *inside* the chart. A
|
|
197
|
+
* readout in the card's header is outside this provider — use
|
|
198
|
+
* `onActiveIndexChange` for that.
|
|
199
|
+
*/
|
|
200
|
+
export function usePyramidChart() {
|
|
201
|
+
const { data, activeIndexJS, xDataKey } = useChart('usePyramidChart');
|
|
202
|
+
return {
|
|
203
|
+
activeIndex: activeIndexJS,
|
|
204
|
+
activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,
|
|
205
|
+
xDataKey,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface PyramidChartProps
|
|
210
|
+
extends ViewProps,
|
|
211
|
+
ChartAccessibilityProps<PyramidChartDatum> {
|
|
212
|
+
className?: string;
|
|
213
|
+
/** The rows. Each one is a band across the chart, with a wing either side. */
|
|
214
|
+
data: PyramidChartDatum[];
|
|
215
|
+
/** Key holding the category label. Used by the axis and the readout. */
|
|
216
|
+
xDataKey?: string;
|
|
217
|
+
/**
|
|
218
|
+
* `loading` holds the bars at the centre and grows them out into the real
|
|
219
|
+
* ones when it turns `ready`. One component throughout, rather than a spinner
|
|
220
|
+
* swapped for a chart — swapping loses the transition. Add a
|
|
221
|
+
* `PyramidChart.Skeleton` for something to stand in the plot meanwhile.
|
|
222
|
+
*/
|
|
223
|
+
status?: PyramidChartStatus;
|
|
224
|
+
/** Width ÷ height. `1.2` suits three or four rows in a card. */
|
|
225
|
+
aspectRatio?: number;
|
|
226
|
+
/** Milliseconds for the bars to grow out on mount. */
|
|
227
|
+
animationDuration?: number;
|
|
228
|
+
/** Milliseconds for the scale to settle after the data changes. */
|
|
229
|
+
domainDuration?: number;
|
|
230
|
+
/**
|
|
231
|
+
* Fix the far end of the shared scale instead of deriving it. The near end is
|
|
232
|
+
* zero either way — a pyramid measures outward from its centre.
|
|
233
|
+
*/
|
|
234
|
+
maxValue?: number;
|
|
235
|
+
/**
|
|
236
|
+
* Where the category names sit. `above`, the default, gives each row a line
|
|
237
|
+
* of its own over its pair of bars, so the two wings meet in the middle with
|
|
238
|
+
* nothing standing between them. `center` puts the names in a gutter between
|
|
239
|
+
* the wings instead, and `start` down the left edge.
|
|
240
|
+
*/
|
|
241
|
+
labelPlacement?: PyramidChartLabelPlacement;
|
|
242
|
+
/**
|
|
243
|
+
* Fraction of each band left empty, `0` to `1`. A fraction rather than a
|
|
244
|
+
* pixel gap so the proportions hold at any height.
|
|
245
|
+
*/
|
|
246
|
+
barGap?: number;
|
|
247
|
+
/** Fixed bar thickness in points. Derived from the band when omitted. */
|
|
248
|
+
barWidth?: number;
|
|
249
|
+
/** Corner radius on the outward end of a bar. */
|
|
250
|
+
cornerRadius?: number;
|
|
251
|
+
/**
|
|
252
|
+
* Smallest length a non-zero bar is drawn at, in points. A value that rounds
|
|
253
|
+
* to nothing still happened, and a bar of zero length says it did not.
|
|
254
|
+
*/
|
|
255
|
+
minBarLength?: number;
|
|
256
|
+
/** Opacity of the rows that are not under the finger. */
|
|
257
|
+
fadedOpacity?: number;
|
|
258
|
+
/**
|
|
259
|
+
* The row under the finger as it moves, and `-1`/`null` when it lifts.
|
|
260
|
+
* Fires when the index changes, not per frame.
|
|
261
|
+
*/
|
|
262
|
+
onActiveIndexChange?: (index: number, datum: PyramidChartDatum | null) => void;
|
|
263
|
+
children?: ReactNode;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Imperative handle: re-run the grow-out, for a "replay" control. */
|
|
267
|
+
export interface PyramidChartHandle {
|
|
268
|
+
replay: () => void;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function partition(children: ReactNode) {
|
|
272
|
+
const svg: ReactNode[] = [];
|
|
273
|
+
const overlay: ReactNode[] = [];
|
|
274
|
+
const header: ReactNode[] = [];
|
|
275
|
+
Children.forEach(children, (child, index) => {
|
|
276
|
+
if (!isValidElement(child)) return;
|
|
277
|
+
const layer = (child.type as { layer?: Layer }).layer ?? 'svg';
|
|
278
|
+
const slot = <ChildSlot key={index}>{child}</ChildSlot>;
|
|
279
|
+
(layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot);
|
|
280
|
+
});
|
|
281
|
+
return { svg, overlay, header };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function ChildSlot({ children }: { children: ReactNode }) {
|
|
285
|
+
return <>{children}</>;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const PyramidChartRoot = forwardRef<PyramidChartHandle, PyramidChartProps>(
|
|
289
|
+
function PyramidChartRoot(
|
|
290
|
+
{
|
|
291
|
+
className,
|
|
292
|
+
data,
|
|
293
|
+
xDataKey = 'name',
|
|
294
|
+
status = 'ready',
|
|
295
|
+
aspectRatio = 1.2,
|
|
296
|
+
animationDuration = 700,
|
|
297
|
+
domainDuration = 500,
|
|
298
|
+
maxValue,
|
|
299
|
+
labelPlacement = 'above',
|
|
300
|
+
barGap = 0.25,
|
|
301
|
+
barWidth,
|
|
302
|
+
cornerRadius = 4,
|
|
303
|
+
minBarLength = 0,
|
|
304
|
+
fadedOpacity = 0.3,
|
|
305
|
+
onActiveIndexChange,
|
|
306
|
+
accessible,
|
|
307
|
+
accessibilityLabel,
|
|
308
|
+
accessibilityHint,
|
|
309
|
+
accessibilityLabelForDatum,
|
|
310
|
+
onAccessibilityDatumPress,
|
|
311
|
+
children,
|
|
312
|
+
...props
|
|
313
|
+
},
|
|
314
|
+
ref
|
|
315
|
+
) {
|
|
316
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
317
|
+
const [series, setSeries] = useState<PyramidSeries[]>([]);
|
|
318
|
+
const [activeIndexJS, setActiveIndexJS] = useState(-1);
|
|
319
|
+
|
|
320
|
+
const reveal = useSharedValue(0);
|
|
321
|
+
const domainMax = useSharedValue(0);
|
|
322
|
+
const activeIndex = useSharedValue(-1);
|
|
323
|
+
const reducedMotion = useReducedMotion();
|
|
324
|
+
|
|
325
|
+
const registerSeries = useMemo(
|
|
326
|
+
() => (next: PyramidSeries) =>
|
|
327
|
+
setSeries((current) => {
|
|
328
|
+
const existing = current.find((entry) => entry.key === next.key);
|
|
329
|
+
if (existing?.color === next.color && existing.side === next.side) return current;
|
|
330
|
+
return [...current.filter((entry) => entry.key !== next.key), next];
|
|
331
|
+
}),
|
|
332
|
+
[]
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const unregisterSeries = useMemo(
|
|
336
|
+
() => (key: string) =>
|
|
337
|
+
setSeries((current) => current.filter((entry) => entry.key !== key)),
|
|
338
|
+
[]
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
/*
|
|
342
|
+
* Whether the category names are asking for room. It has to be known before
|
|
343
|
+
* the plot is laid out, and only the root sees the children early enough to
|
|
344
|
+
* ask — the axis itself renders into a box that has already been decided.
|
|
345
|
+
*/
|
|
346
|
+
const hasYAxis = useMemo(() => {
|
|
347
|
+
let found = false;
|
|
348
|
+
Children.forEach(children, (child) => {
|
|
349
|
+
if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {
|
|
350
|
+
found = true;
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
return found;
|
|
354
|
+
}, [children]);
|
|
355
|
+
|
|
356
|
+
/*
|
|
357
|
+
* Three places a name can go, and each takes its room from somewhere
|
|
358
|
+
* different: `above` off the top of every band, `center` out of the middle
|
|
359
|
+
* of the plot, `start` off the left edge.
|
|
360
|
+
*/
|
|
361
|
+
const gutter = hasYAxis && labelPlacement === 'center' ? CENTRE_GUTTER : 0;
|
|
362
|
+
const labelInset = hasYAxis && labelPlacement === 'above' ? LABEL_ROW : 0;
|
|
363
|
+
const pad = {
|
|
364
|
+
...PADDING,
|
|
365
|
+
left: hasYAxis && labelPlacement === 'start' ? CATEGORY_GUTTER : PADDING.left,
|
|
366
|
+
};
|
|
367
|
+
const plot: Plot = {
|
|
368
|
+
left: pad.left,
|
|
369
|
+
top: pad.top,
|
|
370
|
+
width: Math.max(size.width - pad.left - pad.right, 0),
|
|
371
|
+
height: Math.max(size.height - pad.top - pad.bottom, 0),
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const seriesKeys = series.map((entry) => entry.key).join('|');
|
|
375
|
+
const extentMax = useMemo(() => {
|
|
376
|
+
if (maxValue !== undefined && maxValue > 0) return maxValue;
|
|
377
|
+
const keys = seriesKeys ? seriesKeys.split('|') : [];
|
|
378
|
+
let max = 0;
|
|
379
|
+
/*
|
|
380
|
+
* One number for both wings. Taking a maximum per side is what makes a
|
|
381
|
+
* pyramid whose halves cannot be compared, so the largest value anywhere
|
|
382
|
+
* in either series sets the scale for all of them.
|
|
383
|
+
*/
|
|
384
|
+
for (const row of data) {
|
|
385
|
+
for (const key of keys) {
|
|
386
|
+
const value = row[key];
|
|
387
|
+
if (typeof value !== 'number' || Number.isNaN(value)) continue;
|
|
388
|
+
if (value > max) max = value;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/*
|
|
392
|
+
* Rounded out to a step a reader can divide in their head, rather than
|
|
393
|
+
* padded by a fraction. A tenth added to 73 ends the axis at 80.3 and
|
|
394
|
+
* labels the wings 40.2 and 80.3 — numbers that are true and that nobody
|
|
395
|
+
* was looking for. The centre is left exactly where it is: rounding it
|
|
396
|
+
* would lift the bars off their own baseline.
|
|
397
|
+
*
|
|
398
|
+
* Four steps, not two, so the halfway label the axis draws by default is
|
|
399
|
+
* a round number as well as the end one.
|
|
400
|
+
*/
|
|
401
|
+
return max === 0 ? 1 : niceDomain(0, max, DOMAIN_STEPS)[1];
|
|
402
|
+
}, [data, maxValue, seriesKeys]);
|
|
403
|
+
|
|
404
|
+
const loading = status === 'loading';
|
|
405
|
+
|
|
406
|
+
useEffect(() => {
|
|
407
|
+
if (loading) return;
|
|
408
|
+
const first = domainMax.value === 0;
|
|
409
|
+
if (first || reducedMotion) {
|
|
410
|
+
domainMax.value = extentMax;
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
domainMax.value = withTiming(extentMax, { duration: domainDuration });
|
|
414
|
+
}, [extentMax, loading, reducedMotion, domainDuration, domainMax]);
|
|
415
|
+
|
|
416
|
+
const revealed = useRef(false);
|
|
417
|
+
const playReveal = useMemo(
|
|
418
|
+
() => () => {
|
|
419
|
+
if (reducedMotion) {
|
|
420
|
+
reveal.value = 1;
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
reveal.value = 0;
|
|
424
|
+
reveal.value = withTiming(1, {
|
|
425
|
+
duration: animationDuration,
|
|
426
|
+
easing: Easing.out(Easing.cubic),
|
|
427
|
+
});
|
|
428
|
+
},
|
|
429
|
+
[reducedMotion, animationDuration, reveal]
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
useEffect(() => {
|
|
433
|
+
/*
|
|
434
|
+
* Going back to `loading` arms the reveal again. Without this a chart
|
|
435
|
+
* that is refetched comes back fully drawn on the frame the data lands,
|
|
436
|
+
* which reads as the loading state having been for nothing.
|
|
437
|
+
*/
|
|
438
|
+
if (loading) {
|
|
439
|
+
revealed.current = false;
|
|
440
|
+
reveal.value = 0;
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (revealed.current || plot.width <= 0 || !data.length) return;
|
|
444
|
+
revealed.current = true;
|
|
445
|
+
playReveal();
|
|
446
|
+
}, [loading, plot.width, data.length, playReveal, reveal]);
|
|
447
|
+
|
|
448
|
+
useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
|
|
449
|
+
|
|
450
|
+
const handleActiveIndex = useMemo(
|
|
451
|
+
() => (index: number) => {
|
|
452
|
+
setActiveIndexJS(index);
|
|
453
|
+
onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);
|
|
454
|
+
},
|
|
455
|
+
[onActiveIndexChange, data]
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
459
|
+
const { width, height } = event.nativeEvent.layout;
|
|
460
|
+
setSize((current) =>
|
|
461
|
+
Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
|
|
462
|
+
? current
|
|
463
|
+
: { width, height }
|
|
464
|
+
);
|
|
465
|
+
props.onLayout?.(event);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const context = useMemo<PyramidChartContextValue>(
|
|
469
|
+
() => ({
|
|
470
|
+
data,
|
|
471
|
+
xDataKey,
|
|
472
|
+
plot,
|
|
473
|
+
status,
|
|
474
|
+
gutter,
|
|
475
|
+
labelInset,
|
|
476
|
+
labelPlacement,
|
|
477
|
+
barGap,
|
|
478
|
+
barWidth,
|
|
479
|
+
cornerRadius,
|
|
480
|
+
minBarLength,
|
|
481
|
+
fadedOpacity,
|
|
482
|
+
series,
|
|
483
|
+
registerSeries,
|
|
484
|
+
unregisterSeries,
|
|
485
|
+
domainMax,
|
|
486
|
+
extentMax,
|
|
487
|
+
reveal,
|
|
488
|
+
activeIndex,
|
|
489
|
+
activeIndexJS,
|
|
490
|
+
setActiveIndexJS: handleActiveIndex,
|
|
491
|
+
}),
|
|
492
|
+
// `plot` is rebuilt every render from `size`, so it is compared by value.
|
|
493
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
494
|
+
[
|
|
495
|
+
data,
|
|
496
|
+
xDataKey,
|
|
497
|
+
plot.width,
|
|
498
|
+
plot.height,
|
|
499
|
+
plot.left,
|
|
500
|
+
plot.top,
|
|
501
|
+
status,
|
|
502
|
+
gutter,
|
|
503
|
+
labelInset,
|
|
504
|
+
labelPlacement,
|
|
505
|
+
barGap,
|
|
506
|
+
barWidth,
|
|
507
|
+
cornerRadius,
|
|
508
|
+
minBarLength,
|
|
509
|
+
fadedOpacity,
|
|
510
|
+
series,
|
|
511
|
+
registerSeries,
|
|
512
|
+
unregisterSeries,
|
|
513
|
+
domainMax,
|
|
514
|
+
extentMax,
|
|
515
|
+
reveal,
|
|
516
|
+
activeIndex,
|
|
517
|
+
activeIndexJS,
|
|
518
|
+
handleActiveIndex,
|
|
519
|
+
]
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
const { svg, overlay, header } = partition(children);
|
|
523
|
+
|
|
524
|
+
/*
|
|
525
|
+
* Two views, because the header is not part of the plot. `aspectRatio` and
|
|
526
|
+
* the layout measurement belong to the drawing area alone — measured on the
|
|
527
|
+
* outer view they would take in the header too, and the plot would lose as
|
|
528
|
+
* much height as the readout took while still claiming the shape asked for.
|
|
529
|
+
*/
|
|
530
|
+
return (
|
|
531
|
+
<PyramidChartContext.Provider value={context}>
|
|
532
|
+
<View {...props} style={props.style} className={cn('w-full', className)}>
|
|
533
|
+
{header}
|
|
534
|
+
<ChartAccessibilityData
|
|
535
|
+
chart="Pyramid chart"
|
|
536
|
+
data={data}
|
|
537
|
+
disabled={accessible === false || loading}
|
|
538
|
+
accessibilityLabel={accessibilityLabel}
|
|
539
|
+
accessibilityHint={accessibilityHint}
|
|
540
|
+
accessibilityLabelForDatum={accessibilityLabelForDatum}
|
|
541
|
+
onAccessibilityDatumPress={onAccessibilityDatumPress}
|
|
542
|
+
valueOf={(datum) => [
|
|
543
|
+
[xDataKey, datum[xDataKey]],
|
|
544
|
+
...series.map((entry) => [entry.key, datum[entry.key]] as [string, unknown]),
|
|
545
|
+
]}
|
|
546
|
+
/>
|
|
547
|
+
<View
|
|
548
|
+
onLayout={onLayout}
|
|
549
|
+
style={{ aspectRatio }}
|
|
550
|
+
className="w-full"
|
|
551
|
+
accessibilityElementsHidden
|
|
552
|
+
importantForAccessibility="no-hide-descendants"
|
|
553
|
+
>
|
|
554
|
+
{plot.width > 0 ? (
|
|
555
|
+
<>
|
|
556
|
+
<Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
|
|
557
|
+
{svg}
|
|
558
|
+
</Svg>
|
|
559
|
+
{overlay}
|
|
560
|
+
</>
|
|
561
|
+
) : null}
|
|
562
|
+
</View>
|
|
563
|
+
</View>
|
|
564
|
+
</PyramidChartContext.Provider>
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
);
|
|
568
|
+
PyramidChartRoot.displayName = 'PyramidChart';
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Where the centre line is, and how much room one wing has. Every part needs
|
|
572
|
+
* both and none of them should work it out twice.
|
|
573
|
+
*/
|
|
574
|
+
function geometry(plot: Plot, gutter: number) {
|
|
575
|
+
'worklet';
|
|
576
|
+
const centre = plot.left + plot.width / 2;
|
|
577
|
+
return {
|
|
578
|
+
centre,
|
|
579
|
+
/** The inner edge of the start wing — where a left-growing bar begins. */
|
|
580
|
+
innerStart: centre - gutter / 2,
|
|
581
|
+
/** The inner edge of the end wing. */
|
|
582
|
+
innerEnd: centre + gutter / 2,
|
|
583
|
+
wing: Math.max((plot.width - gutter) / 2, 0),
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/* -------------------------------------------------------------------------- */
|
|
588
|
+
/* SVG layer */
|
|
589
|
+
/* -------------------------------------------------------------------------- */
|
|
590
|
+
|
|
591
|
+
export interface PyramidChartGridProps {
|
|
592
|
+
/** How many lines to draw per wing, not counting the centre. */
|
|
593
|
+
columns?: number;
|
|
594
|
+
color?: string;
|
|
595
|
+
dashArray?: string;
|
|
596
|
+
opacity?: number;
|
|
597
|
+
/** Draw the solid line down the middle the wings are measured from. */
|
|
598
|
+
centreLine?: boolean;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Lines up the value axis, mirrored, so a bar can be read against a number
|
|
603
|
+
* rather than only against the bar facing it.
|
|
604
|
+
*
|
|
605
|
+
* The centre line is drawn solid and undashed where the others are dashed: it
|
|
606
|
+
* is not a tick, it is the zero both wings are measured from.
|
|
607
|
+
*/
|
|
608
|
+
function PyramidChartGrid({
|
|
609
|
+
columns = 2,
|
|
610
|
+
color,
|
|
611
|
+
dashArray = '4,6',
|
|
612
|
+
opacity = 1,
|
|
613
|
+
centreLine = true,
|
|
614
|
+
}: PyramidChartGridProps) {
|
|
615
|
+
const { plot, gutter } = useChart('PyramidChart.Grid');
|
|
616
|
+
const token = useCSSVariable('--color-border');
|
|
617
|
+
const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');
|
|
618
|
+
const { innerStart, innerEnd, wing, centre } = geometry(plot, gutter);
|
|
619
|
+
|
|
620
|
+
const ticks = Array.from({ length: columns }, (_unused, index) => (index + 1) / columns);
|
|
621
|
+
|
|
622
|
+
return (
|
|
623
|
+
<G opacity={opacity}>
|
|
624
|
+
{ticks.map((fraction) => (
|
|
625
|
+
<G key={fraction}>
|
|
626
|
+
<SvgLine
|
|
627
|
+
x1={innerStart - wing * fraction}
|
|
628
|
+
x2={innerStart - wing * fraction}
|
|
629
|
+
y1={plot.top}
|
|
630
|
+
y2={plot.top + plot.height}
|
|
631
|
+
stroke={stroke}
|
|
632
|
+
strokeDasharray={dashArray}
|
|
633
|
+
strokeWidth={1}
|
|
634
|
+
/>
|
|
635
|
+
<SvgLine
|
|
636
|
+
x1={innerEnd + wing * fraction}
|
|
637
|
+
x2={innerEnd + wing * fraction}
|
|
638
|
+
y1={plot.top}
|
|
639
|
+
y2={plot.top + plot.height}
|
|
640
|
+
stroke={stroke}
|
|
641
|
+
strokeDasharray={dashArray}
|
|
642
|
+
strokeWidth={1}
|
|
643
|
+
/>
|
|
644
|
+
</G>
|
|
645
|
+
))}
|
|
646
|
+
{centreLine ? (
|
|
647
|
+
<SvgLine
|
|
648
|
+
x1={centre}
|
|
649
|
+
x2={centre}
|
|
650
|
+
y1={plot.top}
|
|
651
|
+
y2={plot.top + plot.height}
|
|
652
|
+
stroke={stroke}
|
|
653
|
+
strokeWidth={1}
|
|
654
|
+
/>
|
|
655
|
+
) : null}
|
|
656
|
+
</G>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
PyramidChartGrid.displayName = 'PyramidChart.Grid';
|
|
660
|
+
PyramidChartGrid.layer = 'svg' as Layer;
|
|
661
|
+
|
|
662
|
+
export interface PyramidChartBarProps {
|
|
663
|
+
/** Column in the data holding this series' values. */
|
|
664
|
+
dataKey: string;
|
|
665
|
+
/** Which wing it grows into. */
|
|
666
|
+
side?: PyramidChartSide;
|
|
667
|
+
/** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */
|
|
668
|
+
color?: string;
|
|
669
|
+
/**
|
|
670
|
+
* Which of the five chart tokens to take. Defaults to a different one per
|
|
671
|
+
* side, so two bars declared with nothing but a `dataKey` and a `side` are
|
|
672
|
+
* already told apart.
|
|
673
|
+
*/
|
|
674
|
+
colorIndex?: SeriesColorIndex;
|
|
675
|
+
/** Corner radius, overriding the chart's. */
|
|
676
|
+
cornerRadius?: number;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* One wing.
|
|
681
|
+
*
|
|
682
|
+
* Drawn as two paths rather than one rectangle per row: the row under the
|
|
683
|
+
* finger, and everything else. That is the fewest animated props that can still
|
|
684
|
+
* dim the rest — one path could not, since a path has one opacity, and a view
|
|
685
|
+
* per bar would be one animated prop per bar for the same picture.
|
|
686
|
+
*/
|
|
687
|
+
function PyramidChartBar({
|
|
688
|
+
dataKey,
|
|
689
|
+
side = 'end',
|
|
690
|
+
color,
|
|
691
|
+
colorIndex,
|
|
692
|
+
cornerRadius,
|
|
693
|
+
}: PyramidChartBarProps) {
|
|
694
|
+
const {
|
|
695
|
+
data,
|
|
696
|
+
plot,
|
|
697
|
+
status,
|
|
698
|
+
gutter,
|
|
699
|
+
labelInset,
|
|
700
|
+
barGap,
|
|
701
|
+
barWidth,
|
|
702
|
+
cornerRadius: chartRadius,
|
|
703
|
+
minBarLength,
|
|
704
|
+
fadedOpacity,
|
|
705
|
+
registerSeries,
|
|
706
|
+
unregisterSeries,
|
|
707
|
+
domainMax,
|
|
708
|
+
reveal,
|
|
709
|
+
activeIndex,
|
|
710
|
+
} = useChart('PyramidChart.Bar');
|
|
711
|
+
|
|
712
|
+
const index: SeriesColorIndex = colorIndex ?? (side === 'start' ? 2 : 1);
|
|
713
|
+
const fill = useSeriesColor(color, index);
|
|
714
|
+
const radius = cornerRadius ?? chartRadius;
|
|
715
|
+
|
|
716
|
+
useEffect(() => {
|
|
717
|
+
registerSeries({ key: dataKey, color: fill, side });
|
|
718
|
+
return () => unregisterSeries(dataKey);
|
|
719
|
+
}, [dataKey, fill, side, registerSeries, unregisterSeries]);
|
|
720
|
+
|
|
721
|
+
const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);
|
|
722
|
+
|
|
723
|
+
const loading = status === 'loading';
|
|
724
|
+
const total = data.length;
|
|
725
|
+
const towards = side === 'start' ? 'left' : 'right';
|
|
726
|
+
|
|
727
|
+
/*
|
|
728
|
+
* Both paths come out of one builder, filtered by whether the row is the
|
|
729
|
+
* active one. Two passes over the data a frame is still cheaper than the
|
|
730
|
+
* bookkeeping needed to build both at once, and it keeps the geometry in
|
|
731
|
+
* exactly one place.
|
|
732
|
+
*/
|
|
733
|
+
const build = (wantActive: boolean) => () => {
|
|
734
|
+
'worklet';
|
|
735
|
+
if (!total || plot.width <= 0) {
|
|
736
|
+
return { d: '', opacity: 1 };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const { innerStart, innerEnd, wing } = geometry(plot, gutter);
|
|
740
|
+
const band = plot.height / total;
|
|
741
|
+
// What is left of a band once the name over it has taken its line.
|
|
742
|
+
const usable = Math.max(band - labelInset, 0);
|
|
743
|
+
const thickness = Math.min(barWidth ?? usable * (1 - barGap), usable * (1 - barGap));
|
|
744
|
+
|
|
745
|
+
const max = domainMax.value || 1;
|
|
746
|
+
const grow = reveal.value;
|
|
747
|
+
const active = activeIndex.value;
|
|
748
|
+
|
|
749
|
+
let d = '';
|
|
750
|
+
|
|
751
|
+
for (let i = 0; i < total; i++) {
|
|
752
|
+
if ((i === active) !== wantActive) continue;
|
|
753
|
+
|
|
754
|
+
const value = values[i];
|
|
755
|
+
if (value === null || value === undefined) continue;
|
|
756
|
+
|
|
757
|
+
/*
|
|
758
|
+
* Staggered by row, but every bar still finishes inside the one duration:
|
|
759
|
+
* the window each gets is what is left after the stagger, so a chart of
|
|
760
|
+
* twenty rows does not take twenty times as long to arrive.
|
|
761
|
+
*/
|
|
762
|
+
const start = total > 1 ? (i / total) * 0.45 : 0;
|
|
763
|
+
const eased = Math.max(0, Math.min(1, (grow - start) / 0.55));
|
|
764
|
+
// A length, so a negative number has no direction left to grow in. It is
|
|
765
|
+
// still in the readout; it just cannot be drawn.
|
|
766
|
+
const shown = loading ? 0 : Math.max(value, 0) * eased;
|
|
767
|
+
|
|
768
|
+
let length = (shown / max) * wing;
|
|
769
|
+
if (minBarLength > 0 && shown > 0 && length < minBarLength) length = minBarLength;
|
|
770
|
+
if (length <= 0) continue;
|
|
771
|
+
|
|
772
|
+
const lead = plot.top + i * band + labelInset + (usable - thickness) / 2;
|
|
773
|
+
d +=
|
|
774
|
+
side === 'start'
|
|
775
|
+
? barPath(innerStart - length, lead, length, thickness, radius, towards)
|
|
776
|
+
: barPath(innerEnd, lead, length, thickness, radius, towards);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Dimming only happens while something *is* active; with nothing under the
|
|
780
|
+
// finger every bar is at full ink, which is the resting state.
|
|
781
|
+
const dim = !wantActive && active >= 0 ? fadedOpacity : 1;
|
|
782
|
+
return { d, opacity: dim };
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
const restProps = useAnimatedProps(build(false));
|
|
786
|
+
const activeProps = useAnimatedProps(build(true));
|
|
787
|
+
|
|
788
|
+
return (
|
|
789
|
+
<G>
|
|
790
|
+
<AnimatedPath animatedProps={restProps} fill={fill} />
|
|
791
|
+
<AnimatedPath animatedProps={activeProps} fill={fill} />
|
|
792
|
+
</G>
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
PyramidChartBar.displayName = 'PyramidChart.Bar';
|
|
796
|
+
PyramidChartBar.layer = 'svg' as Layer;
|
|
797
|
+
|
|
798
|
+
/** How much of one wing a placeholder bar takes. */
|
|
799
|
+
const SKELETON_LENGTH = 0.35;
|
|
800
|
+
|
|
801
|
+
/** Rows to draw when there is no data yet to count them from. */
|
|
802
|
+
const SKELETON_ROWS = 5;
|
|
803
|
+
|
|
804
|
+
export interface PyramidChartSkeletonProps {
|
|
805
|
+
/**
|
|
806
|
+
* How many placeholder rows to draw. Defaults to one per row of data, and to
|
|
807
|
+
* five when the data has not arrived — the count is the one thing a loading
|
|
808
|
+
* chart can be honest about only if it already has the rows.
|
|
809
|
+
*/
|
|
810
|
+
rows?: number;
|
|
811
|
+
/** Milliseconds for one pass of the sweep. */
|
|
812
|
+
duration?: number;
|
|
813
|
+
color?: string;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* The loading state: equal stubs either side of the centre, with a highlight
|
|
818
|
+
* travelling across them.
|
|
819
|
+
*
|
|
820
|
+
* Equal on purpose. Placeholder wings of differing lengths are a distribution
|
|
821
|
+
* the reader has no way to tell from the real one until it changes under them,
|
|
822
|
+
* so these say only how many rows there will be and where the centre is.
|
|
823
|
+
*/
|
|
824
|
+
function PyramidChartSkeleton({ rows, duration = 1400, color }: PyramidChartSkeletonProps) {
|
|
825
|
+
const { plot, status, gutter, labelInset, data, barGap, barWidth, cornerRadius } =
|
|
826
|
+
useChart('PyramidChart.Skeleton');
|
|
827
|
+
const token = useCSSVariable('--color-skeleton');
|
|
828
|
+
const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
|
|
829
|
+
const highlight = useSeriesColor(undefined, 1);
|
|
830
|
+
|
|
831
|
+
const sweep = useSharedValue(0);
|
|
832
|
+
const reducedMotion = useReducedMotion();
|
|
833
|
+
const loading = status === 'loading';
|
|
834
|
+
|
|
835
|
+
useEffect(() => {
|
|
836
|
+
if (!loading || reducedMotion) {
|
|
837
|
+
cancelAnimation(sweep);
|
|
838
|
+
sweep.value = 0;
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
sweep.value = 0;
|
|
842
|
+
sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);
|
|
843
|
+
return () => cancelAnimation(sweep);
|
|
844
|
+
}, [loading, reducedMotion, duration, sweep]);
|
|
845
|
+
|
|
846
|
+
// The band travels by moving the gradient's own endpoints, so the whole
|
|
847
|
+
// effect is two numbers changing on the UI thread.
|
|
848
|
+
const animatedProps = useAnimatedProps(() => ({
|
|
849
|
+
x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,
|
|
850
|
+
x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,
|
|
851
|
+
}));
|
|
852
|
+
|
|
853
|
+
const total = Math.max(1, rows ?? (data.length || SKELETON_ROWS));
|
|
854
|
+
|
|
855
|
+
const d = useMemo(() => {
|
|
856
|
+
if (plot.width <= 0 || plot.height <= 0) return '';
|
|
857
|
+
const { innerStart, innerEnd, wing } = geometry(plot, gutter);
|
|
858
|
+
const band = plot.height / total;
|
|
859
|
+
const usable = Math.max(band - labelInset, 0);
|
|
860
|
+
const thickness = Math.min(barWidth ?? usable * (1 - barGap), usable * (1 - barGap));
|
|
861
|
+
const length = wing * SKELETON_LENGTH;
|
|
862
|
+
|
|
863
|
+
let path = '';
|
|
864
|
+
for (let i = 0; i < total; i += 1) {
|
|
865
|
+
const lead = plot.top + i * band + labelInset + (usable - thickness) / 2;
|
|
866
|
+
path += barPath(innerStart - length, lead, length, thickness, cornerRadius, 'left');
|
|
867
|
+
path += barPath(innerEnd, lead, length, thickness, cornerRadius, 'right');
|
|
868
|
+
}
|
|
869
|
+
return path;
|
|
870
|
+
}, [plot, gutter, labelInset, total, barGap, barWidth, cornerRadius]);
|
|
871
|
+
|
|
872
|
+
if (!loading || !d) return null;
|
|
873
|
+
|
|
874
|
+
const gradientId = 'panelui-pyramid-skeleton';
|
|
875
|
+
|
|
876
|
+
return (
|
|
877
|
+
<G>
|
|
878
|
+
<Defs>
|
|
879
|
+
<AnimatedLinearGradient id={gradientId} animatedProps={animatedProps} y1="0" y2="0">
|
|
880
|
+
<Stop offset="0" stopColor={base} />
|
|
881
|
+
<Stop offset="0.5" stopColor={highlight} stopOpacity={0.55} />
|
|
882
|
+
<Stop offset="1" stopColor={base} />
|
|
883
|
+
</AnimatedLinearGradient>
|
|
884
|
+
</Defs>
|
|
885
|
+
<Path d={d} fill={`url(#${gradientId})`} />
|
|
886
|
+
</G>
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
PyramidChartSkeleton.displayName = 'PyramidChart.Skeleton';
|
|
890
|
+
PyramidChartSkeleton.layer = 'svg' as Layer;
|
|
891
|
+
|
|
892
|
+
/* -------------------------------------------------------------------------- */
|
|
893
|
+
/* Overlay layer */
|
|
894
|
+
/* -------------------------------------------------------------------------- */
|
|
895
|
+
|
|
896
|
+
export interface PyramidChartXAxisProps {
|
|
897
|
+
/** How many labels per wing, not counting the zero in the middle. */
|
|
898
|
+
ticks?: number;
|
|
899
|
+
/** Format a value for its label. Defaults to a compact number. */
|
|
900
|
+
format?: (value: number) => string;
|
|
901
|
+
className?: string;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* The value labels along the bottom, mirrored either side of a zero in the
|
|
906
|
+
* middle — the same magnitudes twice, because both wings are read outward from
|
|
907
|
+
* the centre rather than along one continuous axis.
|
|
908
|
+
*
|
|
909
|
+
* Real text rather than SVG text, so the labels follow the theme's font and the
|
|
910
|
+
* platform's text scaling. SVG text does neither.
|
|
911
|
+
*/
|
|
912
|
+
function PyramidChartXAxis({ ticks = 2, format, className }: PyramidChartXAxisProps) {
|
|
913
|
+
const { plot, gutter, extentMax } = useChart('PyramidChart.XAxis');
|
|
914
|
+
const { innerStart, innerEnd, wing, centre } = geometry(plot, gutter);
|
|
915
|
+
const fmt = format ?? compactNumber;
|
|
916
|
+
|
|
917
|
+
const labels = useMemo(() => {
|
|
918
|
+
const out: { key: string; text: string; x: number }[] = [
|
|
919
|
+
{ key: 'centre', text: fmt(0), x: centre },
|
|
920
|
+
];
|
|
921
|
+
for (let index = 1; index <= ticks; index += 1) {
|
|
922
|
+
const fraction = index / ticks;
|
|
923
|
+
const text = fmt(extentMax * fraction);
|
|
924
|
+
out.push({ key: `start-${index}`, text, x: innerStart - wing * fraction });
|
|
925
|
+
out.push({ key: `end-${index}`, text, x: innerEnd + wing * fraction });
|
|
926
|
+
}
|
|
927
|
+
return out;
|
|
928
|
+
}, [ticks, fmt, extentMax, innerStart, innerEnd, wing, centre]);
|
|
929
|
+
|
|
930
|
+
if (wing <= 0) return null;
|
|
931
|
+
|
|
932
|
+
return (
|
|
933
|
+
<View
|
|
934
|
+
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
|
|
935
|
+
className={cn(className)}
|
|
936
|
+
>
|
|
937
|
+
{labels.map((label) => (
|
|
938
|
+
<Text
|
|
939
|
+
key={label.key}
|
|
940
|
+
size="xs"
|
|
941
|
+
muted
|
|
942
|
+
numberOfLines={1}
|
|
943
|
+
style={{
|
|
944
|
+
position: 'absolute',
|
|
945
|
+
bottom: 0,
|
|
946
|
+
// Centred on its tick, then held inside the chart. The outermost
|
|
947
|
+
// tick of each wing is on the plot's own edge, so a box centred
|
|
948
|
+
// there would hang half its width off the side and lose the number.
|
|
949
|
+
left: Math.max(
|
|
950
|
+
0,
|
|
951
|
+
Math.min(
|
|
952
|
+
label.x - AXIS_LABEL_WIDTH / 2,
|
|
953
|
+
plot.left + plot.width + PADDING.right - AXIS_LABEL_WIDTH
|
|
954
|
+
)
|
|
955
|
+
),
|
|
956
|
+
width: AXIS_LABEL_WIDTH,
|
|
957
|
+
textAlign: 'center',
|
|
958
|
+
}}
|
|
959
|
+
>
|
|
960
|
+
{label.text}
|
|
961
|
+
</Text>
|
|
962
|
+
))}
|
|
963
|
+
</View>
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
PyramidChartXAxis.displayName = 'PyramidChart.XAxis';
|
|
967
|
+
PyramidChartXAxis.layer = 'overlay' as Layer;
|
|
968
|
+
|
|
969
|
+
export interface PyramidChartYAxisProps {
|
|
970
|
+
/** Turn a row into its label. Defaults to the value at `xDataKey`. */
|
|
971
|
+
format?: (datum: PyramidChartDatum, index: number) => string;
|
|
972
|
+
className?: string;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* The category names, one per row.
|
|
977
|
+
*
|
|
978
|
+
* `above` puts each name on its own line over the pair of bars it belongs to,
|
|
979
|
+
* which leaves the two wings meeting in the middle — the name is read first and
|
|
980
|
+
* the bars are then read outward from a centre nothing is standing in.
|
|
981
|
+
*
|
|
982
|
+
* One box per band rather than a spaced column, in every placement: a row owns
|
|
983
|
+
* a *band*, so names spread evenly would be half a band out at the top and
|
|
984
|
+
* bottom.
|
|
985
|
+
*/
|
|
986
|
+
function PyramidChartYAxis({ format, className }: PyramidChartYAxisProps) {
|
|
987
|
+
const { data, xDataKey, plot, gutter, labelInset, labelPlacement } =
|
|
988
|
+
useChart('PyramidChart.YAxis');
|
|
989
|
+
const { centre } = geometry(plot, gutter);
|
|
990
|
+
|
|
991
|
+
if (!data.length) return null;
|
|
992
|
+
|
|
993
|
+
const label = (datum: PyramidChartDatum, index: number) =>
|
|
994
|
+
format ? format(datum, index) : String(datum[xDataKey] ?? '');
|
|
995
|
+
|
|
996
|
+
if (labelPlacement === 'above') {
|
|
997
|
+
const band = plot.height / data.length;
|
|
998
|
+
return (
|
|
999
|
+
<View
|
|
1000
|
+
pointerEvents="none"
|
|
1001
|
+
style={{ position: 'absolute', inset: 0 }}
|
|
1002
|
+
className={cn(className)}
|
|
1003
|
+
>
|
|
1004
|
+
{data.map((datum, index) => (
|
|
1005
|
+
<View
|
|
1006
|
+
key={index}
|
|
1007
|
+
style={{
|
|
1008
|
+
position: 'absolute',
|
|
1009
|
+
left: plot.left,
|
|
1010
|
+
width: plot.width,
|
|
1011
|
+
top: plot.top + index * band,
|
|
1012
|
+
height: labelInset,
|
|
1013
|
+
}}
|
|
1014
|
+
className="justify-center"
|
|
1015
|
+
>
|
|
1016
|
+
<Text size="xs" muted numberOfLines={1} style={{ textAlign: 'center' }}>
|
|
1017
|
+
{label(datum, index)}
|
|
1018
|
+
</Text>
|
|
1019
|
+
</View>
|
|
1020
|
+
))}
|
|
1021
|
+
</View>
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const centred = labelPlacement === 'center';
|
|
1026
|
+
return (
|
|
1027
|
+
<View
|
|
1028
|
+
pointerEvents="none"
|
|
1029
|
+
style={{
|
|
1030
|
+
position: 'absolute',
|
|
1031
|
+
left: centred ? centre - CENTRE_GUTTER / 2 : 0,
|
|
1032
|
+
top: plot.top,
|
|
1033
|
+
height: plot.height,
|
|
1034
|
+
width: centred ? CENTRE_GUTTER : Math.max(plot.left - 8, 0),
|
|
1035
|
+
}}
|
|
1036
|
+
className={cn(centred ? 'items-center' : 'items-end', className)}
|
|
1037
|
+
>
|
|
1038
|
+
{data.map((datum, index) => (
|
|
1039
|
+
<View key={index} className="flex-1 justify-center">
|
|
1040
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
1041
|
+
{label(datum, index)}
|
|
1042
|
+
</Text>
|
|
1043
|
+
</View>
|
|
1044
|
+
))}
|
|
1045
|
+
</View>
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
PyramidChartYAxis.displayName = 'PyramidChart.YAxis';
|
|
1049
|
+
PyramidChartYAxis.layer = 'overlay' as Layer;
|
|
1050
|
+
// Read by the root, which has to leave room for the names before it lays the
|
|
1051
|
+
// plot out — a name drawn over a bar is unreadable, and makes the bar it is
|
|
1052
|
+
// drawn over unreadable too.
|
|
1053
|
+
PyramidChartYAxis.axis = 'y' as const;
|
|
1054
|
+
|
|
1055
|
+
export interface PyramidChartTooltipProps {
|
|
1056
|
+
/** Format one series' value. Defaults to a compact number. */
|
|
1057
|
+
formatValue?: (value: number, key: string) => string;
|
|
1058
|
+
/** Format the readout's heading from the row. Defaults to the value at xDataKey. */
|
|
1059
|
+
formatX?: (datum: PyramidChartDatum) => string;
|
|
1060
|
+
className?: string;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* The readout, and the gesture that drives it.
|
|
1065
|
+
*
|
|
1066
|
+
* There is no crosshair. A bar is already the thing being pointed at, so
|
|
1067
|
+
* highlighting its row and dimming the rest says the same thing without drawing
|
|
1068
|
+
* a line through the chart.
|
|
1069
|
+
*
|
|
1070
|
+
* The hit area is the whole plot. A readout you have to land on the bar to
|
|
1071
|
+
* summon is a readout nobody finds.
|
|
1072
|
+
*/
|
|
1073
|
+
function PyramidChartTooltip({
|
|
1074
|
+
formatValue,
|
|
1075
|
+
formatX,
|
|
1076
|
+
className,
|
|
1077
|
+
}: PyramidChartTooltipProps) {
|
|
1078
|
+
const {
|
|
1079
|
+
data,
|
|
1080
|
+
xDataKey,
|
|
1081
|
+
plot,
|
|
1082
|
+
series,
|
|
1083
|
+
activeIndex,
|
|
1084
|
+
activeIndexJS,
|
|
1085
|
+
setActiveIndexJS,
|
|
1086
|
+
status,
|
|
1087
|
+
} = useChart('PyramidChart.Tooltip');
|
|
1088
|
+
|
|
1089
|
+
const total = data.length;
|
|
1090
|
+
const top = plot.top;
|
|
1091
|
+
const height = plot.height;
|
|
1092
|
+
|
|
1093
|
+
/*
|
|
1094
|
+
* The readout's own height, measured rather than assumed. It has to be
|
|
1095
|
+
* clamped inside the plot, and how tall it is depends on how many series are
|
|
1096
|
+
* listed in it — a constant would either let a three-series readout hang off
|
|
1097
|
+
* the bottom or reserve room a one-series readout never uses.
|
|
1098
|
+
*/
|
|
1099
|
+
const labelHeight = useSharedValue(0);
|
|
1100
|
+
|
|
1101
|
+
/*
|
|
1102
|
+
* Declared inside the memo, next to its callers: a worklet may only call
|
|
1103
|
+
* another worklet, and the rule is enforced by crashing rather than warning.
|
|
1104
|
+
*/
|
|
1105
|
+
const pan = useMemo(() => {
|
|
1106
|
+
const resolve = (y: number) => {
|
|
1107
|
+
'worklet';
|
|
1108
|
+
if (!total) return;
|
|
1109
|
+
const offset = (y - top) / (height || 1);
|
|
1110
|
+
// Bands, not points: the finger is inside whichever row it lands on,
|
|
1111
|
+
// which is a floor rather than a round to the nearest centre.
|
|
1112
|
+
const next = Math.max(0, Math.min(total - 1, Math.floor(offset * total)));
|
|
1113
|
+
if (next === activeIndex.value) return;
|
|
1114
|
+
activeIndex.value = next;
|
|
1115
|
+
runOnJS(setActiveIndexJS)(next);
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
return Gesture.Pan()
|
|
1119
|
+
.minDistance(0)
|
|
1120
|
+
.onBegin((event) => {
|
|
1121
|
+
'worklet';
|
|
1122
|
+
resolve(event.y);
|
|
1123
|
+
})
|
|
1124
|
+
.onUpdate((event) => {
|
|
1125
|
+
'worklet';
|
|
1126
|
+
resolve(event.y);
|
|
1127
|
+
})
|
|
1128
|
+
.onFinalize(() => {
|
|
1129
|
+
'worklet';
|
|
1130
|
+
activeIndex.value = -1;
|
|
1131
|
+
runOnJS(setActiveIndexJS)(-1);
|
|
1132
|
+
});
|
|
1133
|
+
}, [total, top, height, activeIndex, setActiveIndexJS]);
|
|
1134
|
+
|
|
1135
|
+
/*
|
|
1136
|
+
* The readout slides down to the row it is describing and sits centred over
|
|
1137
|
+
* the plot. Held at the top instead it would name the row under the finger
|
|
1138
|
+
* while covering the first one, which is the row a reader checks it against.
|
|
1139
|
+
*/
|
|
1140
|
+
const labelStyle = useAnimatedStyle(() => {
|
|
1141
|
+
const index = activeIndex.value;
|
|
1142
|
+
if (index < 0 || !total) return { opacity: 0 };
|
|
1143
|
+
const band = plot.height / total;
|
|
1144
|
+
const centre = plot.top + band * (index + 0.5);
|
|
1145
|
+
const x = plot.left + plot.width / 2 - LABEL_WIDTH / 2;
|
|
1146
|
+
|
|
1147
|
+
// Until the first measurement lands the height is zero, which clamps to the
|
|
1148
|
+
// top of the plot — the same place it used to sit, rather than a jump from
|
|
1149
|
+
// somewhere it never was.
|
|
1150
|
+
const tall = labelHeight.value;
|
|
1151
|
+
const y = Math.min(
|
|
1152
|
+
plot.top + Math.max(plot.height - tall, 0),
|
|
1153
|
+
Math.max(plot.top, centre - tall / 2)
|
|
1154
|
+
);
|
|
1155
|
+
return { opacity: 1, transform: [{ translateX: x }, { translateY: y }] };
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;
|
|
1159
|
+
const fmtValue = formatValue ?? ((value: number) => compactNumber(value));
|
|
1160
|
+
const fmtX = formatX ?? ((datum: PyramidChartDatum) => String(datum[xDataKey] ?? ''));
|
|
1161
|
+
|
|
1162
|
+
if (status === 'loading') return null;
|
|
1163
|
+
|
|
1164
|
+
return (
|
|
1165
|
+
<GestureDetector gesture={pan}>
|
|
1166
|
+
<View style={StyleSheet.absoluteFill}>
|
|
1167
|
+
<Animated.View
|
|
1168
|
+
pointerEvents="none"
|
|
1169
|
+
style={[
|
|
1170
|
+
{ position: 'absolute', left: 0, top: 0, width: LABEL_WIDTH },
|
|
1171
|
+
labelStyle,
|
|
1172
|
+
]}
|
|
1173
|
+
>
|
|
1174
|
+
{active ? (
|
|
1175
|
+
<View
|
|
1176
|
+
onLayout={(event) => {
|
|
1177
|
+
labelHeight.value = event.nativeEvent.layout.height;
|
|
1178
|
+
}}
|
|
1179
|
+
className={cn(
|
|
1180
|
+
'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',
|
|
1181
|
+
className
|
|
1182
|
+
)}
|
|
1183
|
+
>
|
|
1184
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
1185
|
+
{fmtX(active)}
|
|
1186
|
+
</Text>
|
|
1187
|
+
{series.map((entry) => {
|
|
1188
|
+
const value = active[entry.key];
|
|
1189
|
+
if (typeof value !== 'number') return null;
|
|
1190
|
+
return (
|
|
1191
|
+
<View key={entry.key} className="flex-row items-center gap-1.5">
|
|
1192
|
+
<View
|
|
1193
|
+
style={{
|
|
1194
|
+
width: 6,
|
|
1195
|
+
height: 6,
|
|
1196
|
+
borderRadius: 3,
|
|
1197
|
+
backgroundColor: entry.color,
|
|
1198
|
+
}}
|
|
1199
|
+
/>
|
|
1200
|
+
<Text size="xs" weight="medium">
|
|
1201
|
+
{fmtValue(value, entry.key)}
|
|
1202
|
+
</Text>
|
|
1203
|
+
</View>
|
|
1204
|
+
);
|
|
1205
|
+
})}
|
|
1206
|
+
</View>
|
|
1207
|
+
) : null}
|
|
1208
|
+
</Animated.View>
|
|
1209
|
+
</View>
|
|
1210
|
+
</GestureDetector>
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
PyramidChartTooltip.displayName = 'PyramidChart.Tooltip';
|
|
1214
|
+
PyramidChartTooltip.layer = 'overlay' as Layer;
|
|
1215
|
+
|
|
1216
|
+
export interface PyramidChartLegendProps extends ViewProps {
|
|
1217
|
+
className?: string;
|
|
1218
|
+
/** Prettier names for the series keys. */
|
|
1219
|
+
labels?: Record<string, string>;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** One series' colour and name. Shared by the legend and the header. */
|
|
1223
|
+
function SeriesSwatch({ color, label }: { color: string; label: string }) {
|
|
1224
|
+
return (
|
|
1225
|
+
<View className="flex-row items-center gap-1.5">
|
|
1226
|
+
<View style={{ backgroundColor: color }} className="h-2 w-2 rounded-full" />
|
|
1227
|
+
<Text size="xs" muted>
|
|
1228
|
+
{label}
|
|
1229
|
+
</Text>
|
|
1230
|
+
</View>
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/** A swatch and a name per series, in the order the series were declared. */
|
|
1235
|
+
function PyramidChartLegend({ className, labels, ...props }: PyramidChartLegendProps) {
|
|
1236
|
+
const { series } = useChart('PyramidChart.Legend');
|
|
1237
|
+
if (!series.length) return null;
|
|
1238
|
+
|
|
1239
|
+
return (
|
|
1240
|
+
<View
|
|
1241
|
+
{...props}
|
|
1242
|
+
style={[{ pointerEvents: 'none' }, props.style]}
|
|
1243
|
+
className={cn('absolute right-2 top-1 flex-row gap-3', className)}
|
|
1244
|
+
>
|
|
1245
|
+
{series.map((entry) => (
|
|
1246
|
+
<SeriesSwatch
|
|
1247
|
+
key={entry.key}
|
|
1248
|
+
color={entry.color}
|
|
1249
|
+
label={labels?.[entry.key] ?? entry.key}
|
|
1250
|
+
/>
|
|
1251
|
+
))}
|
|
1252
|
+
</View>
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
PyramidChartLegend.displayName = 'PyramidChart.Legend';
|
|
1256
|
+
PyramidChartLegend.layer = 'overlay' as Layer;
|
|
1257
|
+
|
|
1258
|
+
/* -------------------------------------------------------------------------- */
|
|
1259
|
+
/* Header layer */
|
|
1260
|
+
/* -------------------------------------------------------------------------- */
|
|
1261
|
+
|
|
1262
|
+
export interface PyramidChartHeaderProps extends ViewProps {
|
|
1263
|
+
className?: string;
|
|
1264
|
+
/** Small line above the value — what the chart is of. */
|
|
1265
|
+
title?: string;
|
|
1266
|
+
/** The readout. The largest thing on the card, and the first thing read. */
|
|
1267
|
+
value?: string;
|
|
1268
|
+
/** One muted line under the value — a period, a comparison, a total. */
|
|
1269
|
+
caption?: string;
|
|
1270
|
+
/** Prettier names for the series keys, as the legend takes. */
|
|
1271
|
+
labels?: Record<string, string>;
|
|
1272
|
+
/**
|
|
1273
|
+
* Draw a swatch and a name per series along the trailing edge. Prefer this to
|
|
1274
|
+
* `PyramidChart.Legend` on a chart that has a header: the legend floats over
|
|
1275
|
+
* the plot, where it competes with the bars for the same corner.
|
|
1276
|
+
*/
|
|
1277
|
+
legend?: boolean;
|
|
1278
|
+
/** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */
|
|
1279
|
+
children?: ReactNode;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* The strip above the plot: what the chart is of, what it currently reads, and
|
|
1284
|
+
* what the two colours mean.
|
|
1285
|
+
*
|
|
1286
|
+
* It belongs to the chart rather than to the card around it because it is about
|
|
1287
|
+
* the *plot* — the number changes as a finger moves down the rows, and the
|
|
1288
|
+
* legend is the series list the chart itself is holding.
|
|
1289
|
+
*
|
|
1290
|
+
* The value is not derived here. A readout that follows the finger belongs to
|
|
1291
|
+
* whoever owns the data — take it from `onActiveIndexChange` and pass the
|
|
1292
|
+
* formatted string down.
|
|
1293
|
+
*/
|
|
1294
|
+
function PyramidChartHeader({
|
|
1295
|
+
className,
|
|
1296
|
+
title,
|
|
1297
|
+
value,
|
|
1298
|
+
caption,
|
|
1299
|
+
labels,
|
|
1300
|
+
legend = false,
|
|
1301
|
+
children,
|
|
1302
|
+
...props
|
|
1303
|
+
}: PyramidChartHeaderProps) {
|
|
1304
|
+
const { series } = useChart('PyramidChart.Header');
|
|
1305
|
+
const trailing =
|
|
1306
|
+
children ??
|
|
1307
|
+
(legend && series.length ? (
|
|
1308
|
+
<View className="flex-row flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
|
1309
|
+
{series.map((entry) => (
|
|
1310
|
+
<SeriesSwatch
|
|
1311
|
+
key={entry.key}
|
|
1312
|
+
color={entry.color}
|
|
1313
|
+
label={labels?.[entry.key] ?? entry.key}
|
|
1314
|
+
/>
|
|
1315
|
+
))}
|
|
1316
|
+
</View>
|
|
1317
|
+
) : null);
|
|
1318
|
+
|
|
1319
|
+
return (
|
|
1320
|
+
<View
|
|
1321
|
+
{...props}
|
|
1322
|
+
className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
|
|
1323
|
+
>
|
|
1324
|
+
<View className="flex-1 gap-0.5">
|
|
1325
|
+
{title ? (
|
|
1326
|
+
<Text size="xs" muted>
|
|
1327
|
+
{title}
|
|
1328
|
+
</Text>
|
|
1329
|
+
) : null}
|
|
1330
|
+
{value ? (
|
|
1331
|
+
<Text size="xl" weight="bold">
|
|
1332
|
+
{value}
|
|
1333
|
+
</Text>
|
|
1334
|
+
) : null}
|
|
1335
|
+
{caption ? (
|
|
1336
|
+
<Text size="xs" muted>
|
|
1337
|
+
{caption}
|
|
1338
|
+
</Text>
|
|
1339
|
+
) : null}
|
|
1340
|
+
</View>
|
|
1341
|
+
{/* Shrinkable, unlike a view's default in React Native. Held rigid, a
|
|
1342
|
+
two-series key takes the width it wants and the caption underneath the
|
|
1343
|
+
value wraps to two lines to make room for it. */}
|
|
1344
|
+
{trailing ? <View className="shrink pt-1">{trailing}</View> : null}
|
|
1345
|
+
</View>
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
PyramidChartHeader.displayName = 'PyramidChart.Header';
|
|
1349
|
+
PyramidChartHeader.layer = 'header' as Layer;
|
|
1350
|
+
|
|
1351
|
+
export const PyramidChart = Object.assign(PyramidChartRoot, {
|
|
1352
|
+
Header: PyramidChartHeader,
|
|
1353
|
+
Grid: PyramidChartGrid,
|
|
1354
|
+
Bar: PyramidChartBar,
|
|
1355
|
+
Skeleton: PyramidChartSkeleton,
|
|
1356
|
+
XAxis: PyramidChartXAxis,
|
|
1357
|
+
YAxis: PyramidChartYAxis,
|
|
1358
|
+
Tooltip: PyramidChartTooltip,
|
|
1359
|
+
Legend: PyramidChartLegend,
|
|
1360
|
+
});
|