panelui-native 0.99.0 → 0.101.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 +2 -0
- package/lib/module/components/animated-badge/index.js +24 -2
- package/lib/module/components/animated-badge/index.js.map +1 -1
- package/lib/module/components/bump-chart/index.js +1236 -0
- package/lib/module/components/bump-chart/index.js.map +1 -0
- package/lib/module/components/compare/index.js +576 -0
- package/lib/module/components/compare/index.js.map +1 -0
- package/lib/module/components/mirror-area-chart/index.js +1113 -0
- package/lib/module/components/mirror-area-chart/index.js.map +1 -0
- package/lib/module/components/sankey-chart/index.js +933 -0
- package/lib/module/components/sankey-chart/index.js.map +1 -0
- package/lib/module/components/sankey-chart/sankey-layout.js +530 -0
- package/lib/module/components/sankey-chart/sankey-layout.js.map +1 -0
- package/lib/module/icons/index.js +15 -3
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/chart.js +101 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/animated-badge/index.d.ts +5 -0
- package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bump-chart/index.d.ts +267 -0
- package/lib/typescript/src/components/bump-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/compare/index.d.ts +183 -0
- package/lib/typescript/src/components/compare/index.d.ts.map +1 -0
- package/lib/typescript/src/components/mirror-area-chart/index.d.ts +263 -0
- package/lib/typescript/src/components/mirror-area-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/sankey-chart/index.d.ts +307 -0
- package/lib/typescript/src/components/sankey-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts +107 -0
- package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +4 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +40 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/animated-badge/index.tsx +35 -2
- package/src/components/bump-chart/index.tsx +1455 -0
- package/src/components/compare/index.tsx +650 -0
- package/src/components/mirror-area-chart/index.tsx +1333 -0
- package/src/components/sankey-chart/index.tsx +1165 -0
- package/src/components/sankey-chart/sankey-layout.ts +645 -0
- package/src/icons/index.tsx +20 -6
- package/src/index.ts +62 -0
- package/src/utils/chart.ts +116 -0
|
@@ -0,0 +1,1333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MirrorAreaChart — two readings of one timeline, one above a baseline and one
|
|
3
|
+
* below it.
|
|
4
|
+
*
|
|
5
|
+
* ```tsx
|
|
6
|
+
* <MirrorAreaChart data={load} xDataKey="time">
|
|
7
|
+
* <MirrorAreaChart.Grid />
|
|
8
|
+
* <MirrorAreaChart.Area dataKey="jobs" label="jobs" muted />
|
|
9
|
+
* <MirrorAreaChart.Area dataKey="high" label="high priority" />
|
|
10
|
+
* <MirrorAreaChart.Area dataKey="share" label="% high" side="bottom" />
|
|
11
|
+
* <MirrorAreaChart.Baseline />
|
|
12
|
+
* <MirrorAreaChart.Tooltip />
|
|
13
|
+
* </MirrorAreaChart>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* ## Two scales, one timeline
|
|
17
|
+
*
|
|
18
|
+
* Each half has its own scale, because the two readings are usually in
|
|
19
|
+
* different units — a count above and a percentage below, or requests above
|
|
20
|
+
* and errors below. Both grow away from the baseline, so a peak in one lines
|
|
21
|
+
* up with whatever the other did at the same moment, and neither is squeezed
|
|
22
|
+
* into the other's range.
|
|
23
|
+
*
|
|
24
|
+
* Areas on the same side overlay in the order they are declared. Declare the
|
|
25
|
+
* whole first and the part of it second, so the part is drawn inside the whole.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
Children,
|
|
29
|
+
createContext,
|
|
30
|
+
forwardRef,
|
|
31
|
+
isValidElement,
|
|
32
|
+
useContext,
|
|
33
|
+
useEffect,
|
|
34
|
+
useId,
|
|
35
|
+
useImperativeHandle,
|
|
36
|
+
useMemo,
|
|
37
|
+
useRef,
|
|
38
|
+
useState,
|
|
39
|
+
type ReactNode,
|
|
40
|
+
} from 'react';
|
|
41
|
+
import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
42
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
43
|
+
import Animated, {
|
|
44
|
+
Easing,
|
|
45
|
+
cancelAnimation,
|
|
46
|
+
runOnJS,
|
|
47
|
+
useAnimatedProps,
|
|
48
|
+
useAnimatedStyle,
|
|
49
|
+
useReducedMotion,
|
|
50
|
+
useSharedValue,
|
|
51
|
+
withRepeat,
|
|
52
|
+
withTiming,
|
|
53
|
+
type SharedValue,
|
|
54
|
+
} from 'react-native-reanimated';
|
|
55
|
+
import Svg, {
|
|
56
|
+
Circle,
|
|
57
|
+
Defs,
|
|
58
|
+
G,
|
|
59
|
+
Line as SvgLine,
|
|
60
|
+
LinearGradient,
|
|
61
|
+
Path,
|
|
62
|
+
Rect,
|
|
63
|
+
Stop,
|
|
64
|
+
} from 'react-native-svg';
|
|
65
|
+
import { useCSSVariable } from 'uniwind';
|
|
66
|
+
import { Text } from '../../primitives/text';
|
|
67
|
+
import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility';
|
|
68
|
+
import { finiteChartDomain } from '../../primitives/finite-chart';
|
|
69
|
+
import {
|
|
70
|
+
colorStop,
|
|
71
|
+
columnValues,
|
|
72
|
+
compactNumber,
|
|
73
|
+
dotsPath,
|
|
74
|
+
segment,
|
|
75
|
+
useSeriesColor,
|
|
76
|
+
xOf,
|
|
77
|
+
type ChartCurve,
|
|
78
|
+
type ChartPoint,
|
|
79
|
+
type Plot,
|
|
80
|
+
type SeriesColorIndex,
|
|
81
|
+
} from '../../utils/chart';
|
|
82
|
+
import { cn } from '../../utils/cn';
|
|
83
|
+
|
|
84
|
+
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
85
|
+
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
|
86
|
+
const AnimatedLine = Animated.createAnimatedComponent(SvgLine);
|
|
87
|
+
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
|
|
88
|
+
|
|
89
|
+
const PADDING = { top: 12, right: 10, bottom: 30, left: 10 };
|
|
90
|
+
|
|
91
|
+
/** Left gutter reserved when a `YAxis` is present. */
|
|
92
|
+
const Y_AXIS_WIDTH = 40;
|
|
93
|
+
|
|
94
|
+
/** Gap between the value labels and the plot. */
|
|
95
|
+
const Y_AXIS_GUTTER = 6;
|
|
96
|
+
|
|
97
|
+
/** Box each x label is centred in. */
|
|
98
|
+
const POINT_LABEL_WIDTH = 56;
|
|
99
|
+
|
|
100
|
+
/** Line height of an `xs` label, for centring one on the line it names. */
|
|
101
|
+
const AXIS_LABEL_HEIGHT = 16;
|
|
102
|
+
|
|
103
|
+
/** Width of the scrub readout. */
|
|
104
|
+
const READOUT_WIDTH = 168;
|
|
105
|
+
|
|
106
|
+
/** Diameter of the dot riding the top edge under the crosshair. */
|
|
107
|
+
const DOT = 9;
|
|
108
|
+
|
|
109
|
+
type Layer = 'svg' | 'series' | 'overlay' | 'header';
|
|
110
|
+
|
|
111
|
+
export type MirrorAreaChartStatus = 'loading' | 'ready';
|
|
112
|
+
export type MirrorAreaChartSide = 'top' | 'bottom';
|
|
113
|
+
export type MirrorAreaChartDatum = Record<string, string | number | null | undefined>;
|
|
114
|
+
|
|
115
|
+
interface MirrorSeries {
|
|
116
|
+
key: string;
|
|
117
|
+
color: string;
|
|
118
|
+
label: string;
|
|
119
|
+
side: MirrorAreaChartSide;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface MirrorAreaChartContextValue {
|
|
123
|
+
data: MirrorAreaChartDatum[];
|
|
124
|
+
xDataKey: string;
|
|
125
|
+
plot: Plot;
|
|
126
|
+
pad: typeof PADDING;
|
|
127
|
+
/** The y of the line both halves grow away from. */
|
|
128
|
+
baseline: number;
|
|
129
|
+
status: MirrorAreaChartStatus;
|
|
130
|
+
curve: ChartCurve;
|
|
131
|
+
series: MirrorSeries[];
|
|
132
|
+
registerSeries: (series: MirrorSeries) => void;
|
|
133
|
+
unregisterSeries: (key: string) => void;
|
|
134
|
+
/** The value drawn at the top and bottom edges of the plot, as they tween. */
|
|
135
|
+
topMax: SharedValue<number>;
|
|
136
|
+
bottomMax: SharedValue<number>;
|
|
137
|
+
/** The same two, settled, for labels that should not count up. */
|
|
138
|
+
topExtent: number;
|
|
139
|
+
bottomExtent: number;
|
|
140
|
+
/** `0` flat on the baseline, `1` drawn in full. */
|
|
141
|
+
grow: SharedValue<number>;
|
|
142
|
+
activeIndex: SharedValue<number>;
|
|
143
|
+
activeIndexJS: number;
|
|
144
|
+
setActiveIndexJS: (index: number) => void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const MirrorAreaChartContext = createContext<MirrorAreaChartContextValue | null>(null);
|
|
148
|
+
|
|
149
|
+
function useChart(component: string): MirrorAreaChartContextValue {
|
|
150
|
+
const context = useContext(MirrorAreaChartContext);
|
|
151
|
+
if (!context) {
|
|
152
|
+
throw new Error(`${component} must be used within a <MirrorAreaChart>`);
|
|
153
|
+
}
|
|
154
|
+
return context;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The row under the crosshair, for a header or a readout outside the chart. */
|
|
158
|
+
export function useMirrorAreaChart() {
|
|
159
|
+
const { data, activeIndexJS, xDataKey } = useChart('useMirrorAreaChart');
|
|
160
|
+
return {
|
|
161
|
+
activeIndex: activeIndexJS,
|
|
162
|
+
activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,
|
|
163
|
+
xDataKey,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A series' edge, `grow` of the way out from the baseline. `direction` is `-1`
|
|
169
|
+
* for the top half and `1` for the bottom. A negative value is drawn on the
|
|
170
|
+
* baseline: each half only grows away from it.
|
|
171
|
+
*/
|
|
172
|
+
function edgeRuns(
|
|
173
|
+
values: (number | null)[],
|
|
174
|
+
plot: Plot,
|
|
175
|
+
baseline: number,
|
|
176
|
+
height: number,
|
|
177
|
+
max: number,
|
|
178
|
+
grow: number,
|
|
179
|
+
direction: number
|
|
180
|
+
): ChartPoint[][] {
|
|
181
|
+
'worklet';
|
|
182
|
+
const runs: ChartPoint[][] = [];
|
|
183
|
+
let run: ChartPoint[] = [];
|
|
184
|
+
const span = max > 0 ? max : 1;
|
|
185
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
186
|
+
const value = values[i];
|
|
187
|
+
if (value === null || value === undefined) {
|
|
188
|
+
if (run.length) runs.push(run);
|
|
189
|
+
run = [];
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const reach = (Math.max(value, 0) / span) * height * grow;
|
|
193
|
+
run.push({ x: xOf(i, values.length, plot), y: baseline + direction * reach });
|
|
194
|
+
}
|
|
195
|
+
if (run.length) runs.push(run);
|
|
196
|
+
return runs;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function edgePath(runs: ChartPoint[][], curve: ChartCurve): string {
|
|
200
|
+
'worklet';
|
|
201
|
+
let d = '';
|
|
202
|
+
for (const run of runs) d += `${segment(run, curve)} `;
|
|
203
|
+
return d;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function fillPath(runs: ChartPoint[][], curve: ChartCurve, baseline: number): string {
|
|
207
|
+
'worklet';
|
|
208
|
+
let d = '';
|
|
209
|
+
for (const run of runs) {
|
|
210
|
+
const edge = segment(run, curve);
|
|
211
|
+
if (!edge) continue;
|
|
212
|
+
const first = run[0]!;
|
|
213
|
+
const last = run[run.length - 1]!;
|
|
214
|
+
d += `${edge} L${last.x},${baseline} L${first.x},${baseline} Z `;
|
|
215
|
+
}
|
|
216
|
+
return d;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The largest value on one side, padded so a peak does not touch the edge. */
|
|
220
|
+
function sideExtent(
|
|
221
|
+
data: MirrorAreaChartDatum[],
|
|
222
|
+
keys: string[],
|
|
223
|
+
explicit: [number, number] | undefined
|
|
224
|
+
): number {
|
|
225
|
+
if (explicit) return Math.max(explicit[1], 0) || 1;
|
|
226
|
+
let max = 0;
|
|
227
|
+
for (const row of data) {
|
|
228
|
+
for (const key of keys) {
|
|
229
|
+
const value = row[key];
|
|
230
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > max) max = value;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return max > 0 ? max * 1.08 : 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface MirrorAreaChartProps
|
|
237
|
+
extends ViewProps,
|
|
238
|
+
ChartAccessibilityProps<MirrorAreaChartDatum> {
|
|
239
|
+
className?: string;
|
|
240
|
+
/** The rows. Each one is a point along the x-axis. */
|
|
241
|
+
data: MirrorAreaChartDatum[];
|
|
242
|
+
/** Key holding the x label. Used by the axis and the readout. */
|
|
243
|
+
xDataKey?: string;
|
|
244
|
+
/**
|
|
245
|
+
* How much of the plot's height sits above the baseline, from `0` to `1`.
|
|
246
|
+
* Give the half you want read first the larger share.
|
|
247
|
+
*/
|
|
248
|
+
split?: number;
|
|
249
|
+
/**
|
|
250
|
+
* Fix the top half's scale instead of deriving it from the data. Only the
|
|
251
|
+
* upper end is used: the baseline is always zero.
|
|
252
|
+
*/
|
|
253
|
+
topDomain?: [number, number];
|
|
254
|
+
/** Fix the bottom half's scale. Only the upper end is used. */
|
|
255
|
+
bottomDomain?: [number, number];
|
|
256
|
+
/** `monotone` never overshoots between points; `linear` joins them straight. */
|
|
257
|
+
curve?: ChartCurve;
|
|
258
|
+
/**
|
|
259
|
+
* `loading` holds the bands flat on the baseline and grows them out when it
|
|
260
|
+
* turns `ready`. Add a `MirrorAreaChart.Skeleton` to show something meanwhile.
|
|
261
|
+
*/
|
|
262
|
+
status?: MirrorAreaChartStatus;
|
|
263
|
+
/** Width ÷ height. */
|
|
264
|
+
aspectRatio?: number;
|
|
265
|
+
/** Milliseconds for the bands to grow out on mount. */
|
|
266
|
+
animationDuration?: number;
|
|
267
|
+
/** Milliseconds for either scale to settle after the data changes. */
|
|
268
|
+
domainDuration?: number;
|
|
269
|
+
/**
|
|
270
|
+
* The point under the crosshair as it moves, and `-1`/`null` when it lifts.
|
|
271
|
+
* Fires when the index changes, not per frame.
|
|
272
|
+
*/
|
|
273
|
+
onActiveIndexChange?: (index: number, datum: MirrorAreaChartDatum | null) => void;
|
|
274
|
+
/** Drop the axis padding so the bands reach the edges, for a sparkline. */
|
|
275
|
+
compact?: boolean;
|
|
276
|
+
children?: ReactNode;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Imperative handle: re-run the reveal on demand, for a "replay" control. */
|
|
280
|
+
export interface MirrorAreaChartHandle {
|
|
281
|
+
replay: () => void;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function ChildSlot({ children }: { children: ReactNode }) {
|
|
285
|
+
return <>{children}</>;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function partition(children: ReactNode) {
|
|
289
|
+
const svg: ReactNode[] = [];
|
|
290
|
+
const series: ReactNode[] = [];
|
|
291
|
+
const overlay: ReactNode[] = [];
|
|
292
|
+
const header: ReactNode[] = [];
|
|
293
|
+
Children.forEach(children, (child, index) => {
|
|
294
|
+
if (!isValidElement(child)) return;
|
|
295
|
+
const layer = (child.type as { layer?: Layer }).layer ?? 'svg';
|
|
296
|
+
const slot = <ChildSlot key={index}>{child}</ChildSlot>;
|
|
297
|
+
const bucket =
|
|
298
|
+
layer === 'header'
|
|
299
|
+
? header
|
|
300
|
+
: layer === 'overlay'
|
|
301
|
+
? overlay
|
|
302
|
+
: layer === 'series'
|
|
303
|
+
? series
|
|
304
|
+
: svg;
|
|
305
|
+
bucket.push(slot);
|
|
306
|
+
});
|
|
307
|
+
return { svg, series, overlay, header };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const MirrorAreaChartRoot = forwardRef<MirrorAreaChartHandle, MirrorAreaChartProps>(
|
|
311
|
+
function MirrorAreaChartRoot(
|
|
312
|
+
{
|
|
313
|
+
className,
|
|
314
|
+
data,
|
|
315
|
+
xDataKey = 'date',
|
|
316
|
+
split = 0.55,
|
|
317
|
+
topDomain,
|
|
318
|
+
bottomDomain,
|
|
319
|
+
curve = 'monotone',
|
|
320
|
+
status = 'ready',
|
|
321
|
+
aspectRatio = 1.6,
|
|
322
|
+
animationDuration = 700,
|
|
323
|
+
domainDuration = 500,
|
|
324
|
+
onActiveIndexChange,
|
|
325
|
+
accessible,
|
|
326
|
+
accessibilityLabel,
|
|
327
|
+
accessibilityHint,
|
|
328
|
+
accessibilityLabelForDatum,
|
|
329
|
+
onAccessibilityDatumPress,
|
|
330
|
+
compact = false,
|
|
331
|
+
children,
|
|
332
|
+
...props
|
|
333
|
+
},
|
|
334
|
+
ref
|
|
335
|
+
) {
|
|
336
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
337
|
+
const [registered, setRegistered] = useState<MirrorSeries[]>([]);
|
|
338
|
+
const [activeIndexJS, setActiveIndexJS] = useState(-1);
|
|
339
|
+
|
|
340
|
+
const grow = useSharedValue(0);
|
|
341
|
+
const topMax = useSharedValue(0);
|
|
342
|
+
const bottomMax = useSharedValue(0);
|
|
343
|
+
const activeIndex = useSharedValue(-1);
|
|
344
|
+
const reducedMotion = useReducedMotion();
|
|
345
|
+
|
|
346
|
+
const registerSeries = useMemo(
|
|
347
|
+
() => (entry: MirrorSeries) =>
|
|
348
|
+
setRegistered((current) => {
|
|
349
|
+
const existing = current.find((item) => item.key === entry.key);
|
|
350
|
+
if (
|
|
351
|
+
existing &&
|
|
352
|
+
existing.color === entry.color &&
|
|
353
|
+
existing.label === entry.label &&
|
|
354
|
+
existing.side === entry.side
|
|
355
|
+
) {
|
|
356
|
+
return current;
|
|
357
|
+
}
|
|
358
|
+
return [...current.filter((item) => item.key !== entry.key), entry];
|
|
359
|
+
}),
|
|
360
|
+
[]
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const unregisterSeries = useMemo(
|
|
364
|
+
() => (key: string) =>
|
|
365
|
+
setRegistered((current) => current.filter((item) => item.key !== key)),
|
|
366
|
+
[]
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
/*
|
|
370
|
+
* The order the areas were declared in, read from the children rather than
|
|
371
|
+
* from registration: an area re-registers when its colour resolves, and
|
|
372
|
+
* appending it again would reorder the legend and the readout.
|
|
373
|
+
*/
|
|
374
|
+
const { hasYAxis, order } = useMemo(() => {
|
|
375
|
+
let axis = false;
|
|
376
|
+
const keys: string[] = [];
|
|
377
|
+
Children.forEach(children, (child) => {
|
|
378
|
+
if (!isValidElement(child)) return;
|
|
379
|
+
const type = child.type as { axis?: string; area?: boolean };
|
|
380
|
+
const childProps = child.props as { dataKey?: string };
|
|
381
|
+
if (type.axis === 'y') axis = true;
|
|
382
|
+
if (type.area && typeof childProps.dataKey === 'string') keys.push(childProps.dataKey);
|
|
383
|
+
});
|
|
384
|
+
return { hasYAxis: axis, order: keys.join('|') };
|
|
385
|
+
}, [children]);
|
|
386
|
+
|
|
387
|
+
const series = useMemo(() => {
|
|
388
|
+
const declared = order ? order.split('|') : [];
|
|
389
|
+
const position = (key: string) => {
|
|
390
|
+
const index = declared.indexOf(key);
|
|
391
|
+
return index === -1 ? declared.length : index;
|
|
392
|
+
};
|
|
393
|
+
return [...registered].sort((a, b) => position(a.key) - position(b.key));
|
|
394
|
+
}, [registered, order]);
|
|
395
|
+
|
|
396
|
+
const pad = compact
|
|
397
|
+
? { top: 2, right: 1, bottom: 2, left: 1 }
|
|
398
|
+
: { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };
|
|
399
|
+
const plot: Plot = {
|
|
400
|
+
left: pad.left,
|
|
401
|
+
top: pad.top,
|
|
402
|
+
width: Math.max(size.width - pad.left - pad.right, 0),
|
|
403
|
+
height: Math.max(size.height - pad.top - pad.bottom, 0),
|
|
404
|
+
};
|
|
405
|
+
const share = Math.min(Math.max(Number.isFinite(split) ? split : 0.55, 0.1), 0.9);
|
|
406
|
+
const baseline = plot.top + plot.height * share;
|
|
407
|
+
|
|
408
|
+
const topKeys = series.filter((item) => item.side === 'top').map((item) => item.key).join('|');
|
|
409
|
+
const bottomKeys = series
|
|
410
|
+
.filter((item) => item.side === 'bottom')
|
|
411
|
+
.map((item) => item.key)
|
|
412
|
+
.join('|');
|
|
413
|
+
const safeTop = finiteChartDomain(topDomain);
|
|
414
|
+
const safeBottom = finiteChartDomain(bottomDomain);
|
|
415
|
+
const topExtent = useMemo(
|
|
416
|
+
() => sideExtent(data, topKeys ? topKeys.split('|') : [], safeTop),
|
|
417
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
418
|
+
[data, topKeys, safeTop?.[0], safeTop?.[1]]
|
|
419
|
+
);
|
|
420
|
+
const bottomExtent = useMemo(
|
|
421
|
+
() => sideExtent(data, bottomKeys ? bottomKeys.split('|') : [], safeBottom),
|
|
422
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
423
|
+
[data, bottomKeys, safeBottom?.[0], safeBottom?.[1]]
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
const loading = status === 'loading';
|
|
427
|
+
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
if (loading) return;
|
|
430
|
+
const first = topMax.value === 0 && bottomMax.value === 0;
|
|
431
|
+
if (first || reducedMotion) {
|
|
432
|
+
topMax.value = topExtent;
|
|
433
|
+
bottomMax.value = bottomExtent;
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
topMax.value = withTiming(topExtent, { duration: domainDuration });
|
|
437
|
+
bottomMax.value = withTiming(bottomExtent, { duration: domainDuration });
|
|
438
|
+
}, [topExtent, bottomExtent, loading, reducedMotion, domainDuration, topMax, bottomMax]);
|
|
439
|
+
|
|
440
|
+
const revealed = useRef(false);
|
|
441
|
+
const playReveal = useMemo(
|
|
442
|
+
() => () => {
|
|
443
|
+
if (reducedMotion) {
|
|
444
|
+
grow.value = 1;
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
grow.value = 0;
|
|
448
|
+
grow.value = withTiming(1, {
|
|
449
|
+
duration: animationDuration,
|
|
450
|
+
easing: Easing.out(Easing.cubic),
|
|
451
|
+
});
|
|
452
|
+
},
|
|
453
|
+
[reducedMotion, animationDuration, grow]
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
useEffect(() => {
|
|
457
|
+
// Going back to `loading` lays the bands flat again, so a refetch grows
|
|
458
|
+
// them out from the baseline instead of appearing complete.
|
|
459
|
+
if (loading) {
|
|
460
|
+
revealed.current = false;
|
|
461
|
+
cancelAnimation(grow);
|
|
462
|
+
grow.value = 0;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (revealed.current || plot.width <= 0 || !data.length) return;
|
|
466
|
+
revealed.current = true;
|
|
467
|
+
playReveal();
|
|
468
|
+
}, [loading, plot.width, data.length, playReveal, grow]);
|
|
469
|
+
|
|
470
|
+
useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
|
|
471
|
+
|
|
472
|
+
const handleActiveIndex = useMemo(
|
|
473
|
+
() => (index: number) => {
|
|
474
|
+
setActiveIndexJS(index);
|
|
475
|
+
onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);
|
|
476
|
+
},
|
|
477
|
+
[onActiveIndexChange, data]
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
481
|
+
const { width, height } = event.nativeEvent.layout;
|
|
482
|
+
setSize((current) =>
|
|
483
|
+
Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
|
|
484
|
+
? current
|
|
485
|
+
: { width, height }
|
|
486
|
+
);
|
|
487
|
+
props.onLayout?.(event);
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const context = useMemo<MirrorAreaChartContextValue>(
|
|
491
|
+
() => ({
|
|
492
|
+
data,
|
|
493
|
+
xDataKey,
|
|
494
|
+
plot,
|
|
495
|
+
pad,
|
|
496
|
+
baseline,
|
|
497
|
+
status,
|
|
498
|
+
curve,
|
|
499
|
+
series,
|
|
500
|
+
registerSeries,
|
|
501
|
+
unregisterSeries,
|
|
502
|
+
topMax,
|
|
503
|
+
bottomMax,
|
|
504
|
+
topExtent,
|
|
505
|
+
bottomExtent,
|
|
506
|
+
grow,
|
|
507
|
+
activeIndex,
|
|
508
|
+
activeIndexJS,
|
|
509
|
+
setActiveIndexJS: handleActiveIndex,
|
|
510
|
+
}),
|
|
511
|
+
// `plot` and `pad` are rebuilt every render from `size`, so they are
|
|
512
|
+
// compared by value.
|
|
513
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
514
|
+
[
|
|
515
|
+
data,
|
|
516
|
+
xDataKey,
|
|
517
|
+
plot.width,
|
|
518
|
+
plot.height,
|
|
519
|
+
plot.left,
|
|
520
|
+
plot.top,
|
|
521
|
+
pad.right,
|
|
522
|
+
baseline,
|
|
523
|
+
status,
|
|
524
|
+
curve,
|
|
525
|
+
series,
|
|
526
|
+
registerSeries,
|
|
527
|
+
unregisterSeries,
|
|
528
|
+
topMax,
|
|
529
|
+
bottomMax,
|
|
530
|
+
topExtent,
|
|
531
|
+
bottomExtent,
|
|
532
|
+
grow,
|
|
533
|
+
activeIndex,
|
|
534
|
+
activeIndexJS,
|
|
535
|
+
handleActiveIndex,
|
|
536
|
+
]
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
const { svg, series: seriesLayer, overlay, header } = partition(children);
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<MirrorAreaChartContext.Provider value={context}>
|
|
543
|
+
<View {...props} style={props.style} className={cn('w-full', className)}>
|
|
544
|
+
{header}
|
|
545
|
+
<ChartAccessibilityData
|
|
546
|
+
chart="Mirror area chart"
|
|
547
|
+
data={data}
|
|
548
|
+
disabled={accessible === false || loading}
|
|
549
|
+
accessibilityLabel={accessibilityLabel}
|
|
550
|
+
accessibilityHint={accessibilityHint}
|
|
551
|
+
accessibilityLabelForDatum={accessibilityLabelForDatum}
|
|
552
|
+
onAccessibilityDatumPress={onAccessibilityDatumPress}
|
|
553
|
+
valueOf={(datum) => [
|
|
554
|
+
[xDataKey, datum[xDataKey]],
|
|
555
|
+
...series.map((item) => [item.label, datum[item.key]] as [string, unknown]),
|
|
556
|
+
]}
|
|
557
|
+
/>
|
|
558
|
+
<View
|
|
559
|
+
onLayout={onLayout}
|
|
560
|
+
style={{ aspectRatio }}
|
|
561
|
+
className="w-full"
|
|
562
|
+
accessibilityElementsHidden
|
|
563
|
+
importantForAccessibility="no-hide-descendants"
|
|
564
|
+
>
|
|
565
|
+
{plot.width > 0 ? (
|
|
566
|
+
<>
|
|
567
|
+
{/*
|
|
568
|
+
* One drawing, with the bands in their own group above the grid.
|
|
569
|
+
* The bands grow out from the baseline by redrawing their paths
|
|
570
|
+
* on the UI thread, so there is no clip to animate — and so
|
|
571
|
+
* nothing that Android's SVG clip handling can drop.
|
|
572
|
+
*/}
|
|
573
|
+
<Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
|
|
574
|
+
{svg}
|
|
575
|
+
<G>{seriesLayer}</G>
|
|
576
|
+
</Svg>
|
|
577
|
+
{overlay}
|
|
578
|
+
</>
|
|
579
|
+
) : null}
|
|
580
|
+
</View>
|
|
581
|
+
</View>
|
|
582
|
+
</MirrorAreaChartContext.Provider>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
);
|
|
586
|
+
MirrorAreaChartRoot.displayName = 'MirrorAreaChart';
|
|
587
|
+
|
|
588
|
+
/* -------------------------------------------------------------------------- */
|
|
589
|
+
/* SVG layer */
|
|
590
|
+
/* -------------------------------------------------------------------------- */
|
|
591
|
+
|
|
592
|
+
export interface MirrorAreaChartGridProps {
|
|
593
|
+
/** `dots` is a field of points; `lines` is dashed rules across each half. */
|
|
594
|
+
variant?: 'dots' | 'lines';
|
|
595
|
+
/** Distance between dots, or how many rules each half gets. */
|
|
596
|
+
spacing?: number;
|
|
597
|
+
color?: string;
|
|
598
|
+
opacity?: number;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* The texture behind the bands. Dots by default, which mark the plot's extent
|
|
603
|
+
* without drawing lines that compete with the baseline.
|
|
604
|
+
*/
|
|
605
|
+
function MirrorAreaChartGrid({ variant = 'dots', spacing, color, opacity = 1 }: MirrorAreaChartGridProps) {
|
|
606
|
+
const { plot, baseline } = useChart('MirrorAreaChart.Grid');
|
|
607
|
+
const token = useCSSVariable('--color-border');
|
|
608
|
+
const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');
|
|
609
|
+
|
|
610
|
+
const dots = useMemo(() => {
|
|
611
|
+
if (variant !== 'dots') return '';
|
|
612
|
+
const gap = Math.max(spacing ?? 12, 4);
|
|
613
|
+
const points: ChartPoint[] = [];
|
|
614
|
+
const columns = Math.floor(plot.width / gap);
|
|
615
|
+
const offsetX = plot.left + (plot.width - columns * gap) / 2;
|
|
616
|
+
// Rows are laid out from the baseline in both directions, so one row
|
|
617
|
+
// always lies on it and the pattern is symmetrical about it.
|
|
618
|
+
for (let y = baseline; y >= plot.top - 0.5; y -= gap) {
|
|
619
|
+
for (let c = 0; c <= columns; c += 1) points.push({ x: offsetX + c * gap, y });
|
|
620
|
+
}
|
|
621
|
+
for (let y = baseline + gap; y <= plot.top + plot.height + 0.5; y += gap) {
|
|
622
|
+
for (let c = 0; c <= columns; c += 1) points.push({ x: offsetX + c * gap, y });
|
|
623
|
+
}
|
|
624
|
+
return dotsPath(points, 0.9);
|
|
625
|
+
}, [variant, spacing, plot.left, plot.top, plot.width, plot.height, baseline]);
|
|
626
|
+
|
|
627
|
+
if (variant === 'dots') {
|
|
628
|
+
return <Path d={dots} fill={stroke} opacity={opacity} />;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const rules = Math.max(Math.round(spacing ?? 2), 1);
|
|
632
|
+
const lines: number[] = [];
|
|
633
|
+
for (let i = 1; i <= rules; i += 1) {
|
|
634
|
+
lines.push(baseline - ((baseline - plot.top) * i) / rules);
|
|
635
|
+
lines.push(baseline + ((plot.top + plot.height - baseline) * i) / rules);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return (
|
|
639
|
+
<G opacity={opacity}>
|
|
640
|
+
{lines.map((y) => (
|
|
641
|
+
<SvgLine
|
|
642
|
+
key={y}
|
|
643
|
+
x1={plot.left}
|
|
644
|
+
x2={plot.left + plot.width}
|
|
645
|
+
y1={y}
|
|
646
|
+
y2={y}
|
|
647
|
+
stroke={stroke}
|
|
648
|
+
strokeDasharray="4,6"
|
|
649
|
+
strokeWidth={1}
|
|
650
|
+
/>
|
|
651
|
+
))}
|
|
652
|
+
</G>
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
MirrorAreaChartGrid.displayName = 'MirrorAreaChart.Grid';
|
|
656
|
+
MirrorAreaChartGrid.layer = 'svg' as Layer;
|
|
657
|
+
|
|
658
|
+
export interface MirrorAreaChartBaselineProps {
|
|
659
|
+
color?: string;
|
|
660
|
+
strokeWidth?: number;
|
|
661
|
+
opacity?: number;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The line both halves grow away from. Declare it after the areas so it is
|
|
666
|
+
* drawn over their bases.
|
|
667
|
+
*/
|
|
668
|
+
function MirrorAreaChartBaseline({ color, strokeWidth = 1, opacity = 0.5 }: MirrorAreaChartBaselineProps) {
|
|
669
|
+
const { plot, baseline } = useChart('MirrorAreaChart.Baseline');
|
|
670
|
+
const token = useCSSVariable('--color-muted-foreground');
|
|
671
|
+
const stroke = color ?? (typeof token === 'string' ? token : '#737373');
|
|
672
|
+
|
|
673
|
+
return (
|
|
674
|
+
<SvgLine
|
|
675
|
+
x1={plot.left}
|
|
676
|
+
x2={plot.left + plot.width}
|
|
677
|
+
y1={baseline}
|
|
678
|
+
y2={baseline}
|
|
679
|
+
stroke={stroke}
|
|
680
|
+
strokeWidth={strokeWidth}
|
|
681
|
+
opacity={opacity}
|
|
682
|
+
/>
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
MirrorAreaChartBaseline.displayName = 'MirrorAreaChart.Baseline';
|
|
686
|
+
MirrorAreaChartBaseline.layer = 'series' as Layer;
|
|
687
|
+
|
|
688
|
+
export interface MirrorAreaChartAreaProps {
|
|
689
|
+
/** Column in the data holding this series' values. */
|
|
690
|
+
dataKey: string;
|
|
691
|
+
/** Which half it is drawn in. */
|
|
692
|
+
side?: MirrorAreaChartSide;
|
|
693
|
+
/** The name shown by the readout and the legend. Defaults to `dataKey`. */
|
|
694
|
+
label?: string;
|
|
695
|
+
/** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */
|
|
696
|
+
color?: string;
|
|
697
|
+
/** Which of the five chart tokens to take. */
|
|
698
|
+
colorIndex?: SeriesColorIndex;
|
|
699
|
+
/**
|
|
700
|
+
* Draw it in the muted foreground colour instead, for a total that the
|
|
701
|
+
* coloured areas are parts of.
|
|
702
|
+
*/
|
|
703
|
+
muted?: boolean;
|
|
704
|
+
/** Opacity of the fill at the band's outer edge. */
|
|
705
|
+
fillOpacity?: number;
|
|
706
|
+
/** Opacity of the fill at the baseline. Defaults to `fillOpacity`, a flat fill. */
|
|
707
|
+
gradientToOpacity?: number;
|
|
708
|
+
/** Draw the line along the band's outer edge. */
|
|
709
|
+
showLine?: boolean;
|
|
710
|
+
/** Thickness of that line. */
|
|
711
|
+
strokeWidth?: number;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* One band, growing away from the baseline on its side. Areas on one side
|
|
716
|
+
* overlay in declaration order, so a whole declared first is drawn behind a
|
|
717
|
+
* part of it declared second.
|
|
718
|
+
*/
|
|
719
|
+
function MirrorAreaChartArea({
|
|
720
|
+
dataKey,
|
|
721
|
+
side = 'top',
|
|
722
|
+
label,
|
|
723
|
+
color,
|
|
724
|
+
colorIndex = 1,
|
|
725
|
+
muted = false,
|
|
726
|
+
fillOpacity,
|
|
727
|
+
gradientToOpacity,
|
|
728
|
+
showLine = true,
|
|
729
|
+
strokeWidth = 1.5,
|
|
730
|
+
}: MirrorAreaChartAreaProps) {
|
|
731
|
+
const {
|
|
732
|
+
data,
|
|
733
|
+
plot,
|
|
734
|
+
baseline,
|
|
735
|
+
curve,
|
|
736
|
+
registerSeries,
|
|
737
|
+
unregisterSeries,
|
|
738
|
+
topMax,
|
|
739
|
+
bottomMax,
|
|
740
|
+
grow,
|
|
741
|
+
} = useChart('MirrorAreaChart.Area');
|
|
742
|
+
|
|
743
|
+
const seriesColor = useSeriesColor(color, colorIndex);
|
|
744
|
+
const mutedToken = useCSSVariable('--color-muted-foreground');
|
|
745
|
+
const stroke = muted && !color ? (typeof mutedToken === 'string' ? mutedToken : '#737373') : seriesColor;
|
|
746
|
+
const name = label ?? dataKey;
|
|
747
|
+
const gradientId = `panelui-mirror-fill-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
748
|
+
|
|
749
|
+
const outer = fillOpacity ?? (muted ? 0.22 : 0.3);
|
|
750
|
+
const inner = gradientToOpacity ?? outer;
|
|
751
|
+
|
|
752
|
+
useEffect(() => {
|
|
753
|
+
registerSeries({ key: dataKey, color: stroke, label: name, side });
|
|
754
|
+
}, [dataKey, stroke, name, side, registerSeries]);
|
|
755
|
+
|
|
756
|
+
useEffect(() => () => unregisterSeries(dataKey), [dataKey, unregisterSeries]);
|
|
757
|
+
|
|
758
|
+
const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);
|
|
759
|
+
const top = side === 'top';
|
|
760
|
+
const height = top ? baseline - plot.top : plot.top + plot.height - baseline;
|
|
761
|
+
|
|
762
|
+
const fillProps = useAnimatedProps(() => {
|
|
763
|
+
const runs = edgeRuns(
|
|
764
|
+
values,
|
|
765
|
+
plot,
|
|
766
|
+
baseline,
|
|
767
|
+
height,
|
|
768
|
+
top ? topMax.value : bottomMax.value,
|
|
769
|
+
grow.value,
|
|
770
|
+
top ? -1 : 1
|
|
771
|
+
);
|
|
772
|
+
return { d: fillPath(runs, curve, baseline) };
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
const lineProps = useAnimatedProps(() => {
|
|
776
|
+
const runs = edgeRuns(
|
|
777
|
+
values,
|
|
778
|
+
plot,
|
|
779
|
+
baseline,
|
|
780
|
+
height,
|
|
781
|
+
top ? topMax.value : bottomMax.value,
|
|
782
|
+
grow.value,
|
|
783
|
+
top ? -1 : 1
|
|
784
|
+
);
|
|
785
|
+
// Hidden while the band is flat, or the edge lines of every area are
|
|
786
|
+
// drawn along the baseline over the skeleton while the chart waits.
|
|
787
|
+
return { d: edgePath(runs, curve), opacity: grow.value > 0.001 ? 1 : 0 };
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
return (
|
|
791
|
+
<G>
|
|
792
|
+
<Defs>
|
|
793
|
+
{/* Top to bottom in the band's own box: the outer edge is at the top
|
|
794
|
+
of a top band and at the bottom of a bottom one. */}
|
|
795
|
+
<LinearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
796
|
+
<Stop offset="0" stopColor={stroke} stopOpacity={top ? outer : inner} />
|
|
797
|
+
<Stop offset="1" stopColor={stroke} stopOpacity={top ? inner : outer} />
|
|
798
|
+
</LinearGradient>
|
|
799
|
+
</Defs>
|
|
800
|
+
<AnimatedPath animatedProps={fillProps} fill={`url(#${gradientId})`} />
|
|
801
|
+
{showLine ? (
|
|
802
|
+
<AnimatedPath
|
|
803
|
+
animatedProps={lineProps}
|
|
804
|
+
stroke={stroke}
|
|
805
|
+
strokeWidth={strokeWidth}
|
|
806
|
+
strokeLinecap="round"
|
|
807
|
+
strokeLinejoin="round"
|
|
808
|
+
fill="none"
|
|
809
|
+
/>
|
|
810
|
+
) : null}
|
|
811
|
+
</G>
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
MirrorAreaChartArea.displayName = 'MirrorAreaChart.Area';
|
|
815
|
+
MirrorAreaChartArea.layer = 'series' as Layer;
|
|
816
|
+
// Read by the root, for the order the areas were declared in.
|
|
817
|
+
MirrorAreaChartArea.area = true as const;
|
|
818
|
+
|
|
819
|
+
export interface MirrorAreaChartSkeletonProps {
|
|
820
|
+
/** Milliseconds for one pass of the sweep. */
|
|
821
|
+
duration?: number;
|
|
822
|
+
color?: string;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* The loading state: a low band either side of the baseline with a highlight
|
|
827
|
+
* travelling across it. Flat, so it says where the bands will be and nothing
|
|
828
|
+
* about their shape.
|
|
829
|
+
*/
|
|
830
|
+
function MirrorAreaChartSkeleton({ duration = 1400, color }: MirrorAreaChartSkeletonProps) {
|
|
831
|
+
const { plot, baseline, status } = useChart('MirrorAreaChart.Skeleton');
|
|
832
|
+
const token = useCSSVariable('--color-skeleton');
|
|
833
|
+
const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
|
|
834
|
+
const highlight = useSeriesColor(undefined, 1);
|
|
835
|
+
|
|
836
|
+
const sweep = useSharedValue(0);
|
|
837
|
+
const reducedMotion = useReducedMotion();
|
|
838
|
+
const loading = status === 'loading';
|
|
839
|
+
|
|
840
|
+
useEffect(() => {
|
|
841
|
+
if (!loading || reducedMotion) {
|
|
842
|
+
cancelAnimation(sweep);
|
|
843
|
+
sweep.value = 0;
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
sweep.value = 0;
|
|
847
|
+
sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);
|
|
848
|
+
return () => cancelAnimation(sweep);
|
|
849
|
+
}, [loading, reducedMotion, duration, sweep]);
|
|
850
|
+
|
|
851
|
+
const animatedProps = useAnimatedProps(() => ({
|
|
852
|
+
x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,
|
|
853
|
+
x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,
|
|
854
|
+
}));
|
|
855
|
+
|
|
856
|
+
if (!loading) return null;
|
|
857
|
+
|
|
858
|
+
const above = (baseline - plot.top) * 0.18;
|
|
859
|
+
const below = (plot.top + plot.height - baseline) * 0.18;
|
|
860
|
+
const gradientId = 'panelui-mirror-skeleton';
|
|
861
|
+
|
|
862
|
+
return (
|
|
863
|
+
<G>
|
|
864
|
+
<Defs>
|
|
865
|
+
<AnimatedLinearGradient id={gradientId} animatedProps={animatedProps} y1="0" y2="0">
|
|
866
|
+
<Stop offset="0" {...colorStop(base)} />
|
|
867
|
+
<Stop offset="0.5" stopColor={highlight} stopOpacity={0.3} />
|
|
868
|
+
<Stop offset="1" {...colorStop(base)} />
|
|
869
|
+
</AnimatedLinearGradient>
|
|
870
|
+
</Defs>
|
|
871
|
+
<Rect
|
|
872
|
+
x={plot.left}
|
|
873
|
+
y={baseline - above}
|
|
874
|
+
width={plot.width}
|
|
875
|
+
height={above + below}
|
|
876
|
+
fill={`url(#${gradientId})`}
|
|
877
|
+
/>
|
|
878
|
+
</G>
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
MirrorAreaChartSkeleton.displayName = 'MirrorAreaChart.Skeleton';
|
|
882
|
+
MirrorAreaChartSkeleton.layer = 'svg' as Layer;
|
|
883
|
+
|
|
884
|
+
/* -------------------------------------------------------------------------- */
|
|
885
|
+
/* Overlay layer */
|
|
886
|
+
/* -------------------------------------------------------------------------- */
|
|
887
|
+
|
|
888
|
+
export interface MirrorAreaChartXAxisProps {
|
|
889
|
+
ticks?: number;
|
|
890
|
+
format?: (datum: MirrorAreaChartDatum, index: number) => string;
|
|
891
|
+
className?: string;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** The x labels. Real text, so they follow the theme's font and text scaling. */
|
|
895
|
+
function MirrorAreaChartXAxis({ ticks = 4, format, className }: MirrorAreaChartXAxisProps) {
|
|
896
|
+
const { data, xDataKey, plot, pad } = useChart('MirrorAreaChart.XAxis');
|
|
897
|
+
|
|
898
|
+
const labels = useMemo(() => {
|
|
899
|
+
if (!data.length) return [];
|
|
900
|
+
const count = Math.min(ticks, data.length);
|
|
901
|
+
const step = count > 1 ? (data.length - 1) / (count - 1) : 0;
|
|
902
|
+
return Array.from({ length: count }, (_unused, index) => {
|
|
903
|
+
const dataIndex = Math.round(index * step);
|
|
904
|
+
const datum = data[dataIndex];
|
|
905
|
+
if (!datum) return null;
|
|
906
|
+
return {
|
|
907
|
+
key: dataIndex,
|
|
908
|
+
text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),
|
|
909
|
+
};
|
|
910
|
+
}).filter((label): label is { key: number; text: string } => label !== null);
|
|
911
|
+
}, [data, ticks, format, xDataKey]);
|
|
912
|
+
|
|
913
|
+
return (
|
|
914
|
+
<View
|
|
915
|
+
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
|
|
916
|
+
className={cn(className)}
|
|
917
|
+
>
|
|
918
|
+
{labels.map((label) => (
|
|
919
|
+
<Text
|
|
920
|
+
key={label.key}
|
|
921
|
+
size="xs"
|
|
922
|
+
muted
|
|
923
|
+
numberOfLines={1}
|
|
924
|
+
style={{
|
|
925
|
+
position: 'absolute',
|
|
926
|
+
bottom: 0,
|
|
927
|
+
left: Math.max(
|
|
928
|
+
0,
|
|
929
|
+
Math.min(
|
|
930
|
+
xOf(label.key, data.length, plot) - POINT_LABEL_WIDTH / 2,
|
|
931
|
+
plot.left + plot.width + pad.right - POINT_LABEL_WIDTH
|
|
932
|
+
)
|
|
933
|
+
),
|
|
934
|
+
width: POINT_LABEL_WIDTH,
|
|
935
|
+
textAlign: 'center',
|
|
936
|
+
}}
|
|
937
|
+
>
|
|
938
|
+
{label.text}
|
|
939
|
+
</Text>
|
|
940
|
+
))}
|
|
941
|
+
</View>
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
MirrorAreaChartXAxis.displayName = 'MirrorAreaChart.XAxis';
|
|
945
|
+
MirrorAreaChartXAxis.layer = 'overlay' as Layer;
|
|
946
|
+
|
|
947
|
+
export interface MirrorAreaChartYAxisProps {
|
|
948
|
+
/** Format a value in the top half. */
|
|
949
|
+
formatTop?: (value: number) => string;
|
|
950
|
+
/** Format a value in the bottom half. */
|
|
951
|
+
formatBottom?: (value: number) => string;
|
|
952
|
+
className?: string;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Three labels down the side: the top half's maximum, zero on the baseline,
|
|
957
|
+
* and the bottom half's maximum. Two scales need two labels, and a column of
|
|
958
|
+
* evenly spaced numbers would suggest one.
|
|
959
|
+
*/
|
|
960
|
+
function MirrorAreaChartYAxis({ formatTop, formatBottom, className }: MirrorAreaChartYAxisProps) {
|
|
961
|
+
const { plot, baseline, topExtent, bottomExtent } = useChart('MirrorAreaChart.YAxis');
|
|
962
|
+
const width = Math.max(plot.left - Y_AXIS_GUTTER, 0);
|
|
963
|
+
const fmtTop = formatTop ?? compactNumber;
|
|
964
|
+
const fmtBottom = formatBottom ?? compactNumber;
|
|
965
|
+
|
|
966
|
+
const labels = [
|
|
967
|
+
{ key: 'top', y: plot.top, text: fmtTop(topExtent) },
|
|
968
|
+
{ key: 'zero', y: baseline, text: '0' },
|
|
969
|
+
{ key: 'bottom', y: plot.top + plot.height, text: fmtBottom(bottomExtent) },
|
|
970
|
+
];
|
|
971
|
+
|
|
972
|
+
return (
|
|
973
|
+
<View style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} className={cn(className)}>
|
|
974
|
+
{labels.map((label) => (
|
|
975
|
+
<Text
|
|
976
|
+
key={label.key}
|
|
977
|
+
size="xs"
|
|
978
|
+
muted
|
|
979
|
+
numberOfLines={1}
|
|
980
|
+
style={{
|
|
981
|
+
position: 'absolute',
|
|
982
|
+
left: 0,
|
|
983
|
+
top: label.y - AXIS_LABEL_HEIGHT / 2,
|
|
984
|
+
width,
|
|
985
|
+
textAlign: 'right',
|
|
986
|
+
}}
|
|
987
|
+
>
|
|
988
|
+
{label.text}
|
|
989
|
+
</Text>
|
|
990
|
+
))}
|
|
991
|
+
</View>
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
MirrorAreaChartYAxis.displayName = 'MirrorAreaChart.YAxis';
|
|
995
|
+
MirrorAreaChartYAxis.layer = 'overlay' as Layer;
|
|
996
|
+
// Read by the root, which leaves room for the labels before laying the plot out.
|
|
997
|
+
MirrorAreaChartYAxis.axis = 'y' as const;
|
|
998
|
+
|
|
999
|
+
export interface MirrorAreaChartTooltipProps {
|
|
1000
|
+
/** Colour of the crosshair and the dot. Defaults to the foreground token. */
|
|
1001
|
+
color?: string;
|
|
1002
|
+
/** Format one series' value. Defaults to a compact number. */
|
|
1003
|
+
formatValue?: (value: number, key: string) => string;
|
|
1004
|
+
/** Format the readout's heading from the row. Defaults to the value at xDataKey. */
|
|
1005
|
+
formatX?: (datum: MirrorAreaChartDatum) => string;
|
|
1006
|
+
/**
|
|
1007
|
+
* One line to show instead of a row per series — "1,120 jobs · 78% high".
|
|
1008
|
+
* Use it when the series only make sense read together.
|
|
1009
|
+
*/
|
|
1010
|
+
formatSummary?: (datum: MirrorAreaChartDatum) => string;
|
|
1011
|
+
className?: string;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* A dashed crosshair, a dot on the outer edge of the top half, and a readout
|
|
1016
|
+
* beside them. The hit area is the whole plot.
|
|
1017
|
+
*/
|
|
1018
|
+
function MirrorAreaChartTooltip({
|
|
1019
|
+
color,
|
|
1020
|
+
formatValue,
|
|
1021
|
+
formatX,
|
|
1022
|
+
formatSummary,
|
|
1023
|
+
className,
|
|
1024
|
+
}: MirrorAreaChartTooltipProps) {
|
|
1025
|
+
const {
|
|
1026
|
+
data,
|
|
1027
|
+
xDataKey,
|
|
1028
|
+
plot,
|
|
1029
|
+
baseline,
|
|
1030
|
+
series,
|
|
1031
|
+
topMax,
|
|
1032
|
+
bottomMax,
|
|
1033
|
+
grow,
|
|
1034
|
+
activeIndex,
|
|
1035
|
+
activeIndexJS,
|
|
1036
|
+
setActiveIndexJS,
|
|
1037
|
+
status,
|
|
1038
|
+
} = useChart('MirrorAreaChart.Tooltip');
|
|
1039
|
+
|
|
1040
|
+
const token = useCSSVariable('--color-foreground');
|
|
1041
|
+
const stroke = color ?? (typeof token === 'string' ? token : '#888888');
|
|
1042
|
+
const background = useCSSVariable('--color-background');
|
|
1043
|
+
const ring = typeof background === 'string' ? background : 'white';
|
|
1044
|
+
|
|
1045
|
+
const total = data.length;
|
|
1046
|
+
const left = plot.left;
|
|
1047
|
+
const width = plot.width;
|
|
1048
|
+
|
|
1049
|
+
// Declared inside the memo, next to its callers: a worklet may only call
|
|
1050
|
+
// another worklet.
|
|
1051
|
+
const pan = useMemo(() => {
|
|
1052
|
+
const resolve = (x: number) => {
|
|
1053
|
+
'worklet';
|
|
1054
|
+
if (total < 1 || width <= 0) return;
|
|
1055
|
+
const ratio = total < 2 ? 0 : (x - left) / width;
|
|
1056
|
+
const next = Math.round(Math.min(1, Math.max(0, ratio)) * (total - 1));
|
|
1057
|
+
if (next === activeIndex.value) return;
|
|
1058
|
+
activeIndex.value = next;
|
|
1059
|
+
runOnJS(setActiveIndexJS)(next);
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
return Gesture.Pan()
|
|
1063
|
+
.minDistance(0)
|
|
1064
|
+
.onBegin((event) => {
|
|
1065
|
+
'worklet';
|
|
1066
|
+
resolve(event.x);
|
|
1067
|
+
})
|
|
1068
|
+
.onUpdate((event) => {
|
|
1069
|
+
'worklet';
|
|
1070
|
+
resolve(event.x);
|
|
1071
|
+
})
|
|
1072
|
+
.onFinalize(() => {
|
|
1073
|
+
'worklet';
|
|
1074
|
+
activeIndex.value = -1;
|
|
1075
|
+
runOnJS(setActiveIndexJS)(-1);
|
|
1076
|
+
});
|
|
1077
|
+
}, [total, left, width, activeIndex, setActiveIndexJS]);
|
|
1078
|
+
|
|
1079
|
+
/*
|
|
1080
|
+
* The dot sits on the outermost edge on the top half — the largest value of
|
|
1081
|
+
* any top area at that point — or on the bottom half's when there is no top.
|
|
1082
|
+
*/
|
|
1083
|
+
const topSeries = series.filter((item) => item.side === 'top');
|
|
1084
|
+
const dotSide: MirrorAreaChartSide = topSeries.length ? 'top' : 'bottom';
|
|
1085
|
+
const dotValues = useMemo(() => {
|
|
1086
|
+
const keys = series.filter((item) => item.side === dotSide).map((item) => item.key);
|
|
1087
|
+
return data.map((row) => {
|
|
1088
|
+
let most: number | null = null;
|
|
1089
|
+
for (const key of keys) {
|
|
1090
|
+
const value = row[key];
|
|
1091
|
+
if (typeof value === 'number' && Number.isFinite(value) && (most === null || value > most)) {
|
|
1092
|
+
most = value;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return most;
|
|
1096
|
+
});
|
|
1097
|
+
}, [data, series, dotSide]);
|
|
1098
|
+
|
|
1099
|
+
const topHeight = baseline - plot.top;
|
|
1100
|
+
const bottomHeight = plot.top + plot.height - baseline;
|
|
1101
|
+
|
|
1102
|
+
const dotProps = useAnimatedProps(() => {
|
|
1103
|
+
const index = activeIndex.value;
|
|
1104
|
+
const value = index >= 0 ? dotValues[index] : null;
|
|
1105
|
+
if (index < 0 || value === null || value === undefined) return { opacity: 0, cx: 0, cy: 0 };
|
|
1106
|
+
const up = dotSide === 'top';
|
|
1107
|
+
const max = up ? topMax.value : bottomMax.value;
|
|
1108
|
+
const reach = (Math.max(value, 0) / (max > 0 ? max : 1)) * (up ? topHeight : bottomHeight) * grow.value;
|
|
1109
|
+
return { opacity: 1, cx: xOf(index, total, plot), cy: baseline + (up ? -reach : reach) };
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
const crosshairProps = useAnimatedProps(() => {
|
|
1113
|
+
const index = activeIndex.value;
|
|
1114
|
+
const x = index < 0 ? 0 : xOf(index, total, plot);
|
|
1115
|
+
return { x1: x, x2: x, opacity: index < 0 ? 0 : 0.6 };
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
// Beside the crosshair, on whichever side has room, so it never covers the
|
|
1119
|
+
// point it describes.
|
|
1120
|
+
const readoutStyle = useAnimatedStyle(() => {
|
|
1121
|
+
const index = activeIndex.value;
|
|
1122
|
+
if (index < 0) return { opacity: 0 };
|
|
1123
|
+
const x = xOf(index, total, plot);
|
|
1124
|
+
// Clear of the dot's radius and its ring, not only of the crosshair.
|
|
1125
|
+
const gap = DOT / 2 + 10;
|
|
1126
|
+
const fitsLeft = x - gap - READOUT_WIDTH >= plot.left;
|
|
1127
|
+
const at = fitsLeft ? x - gap - READOUT_WIDTH : Math.min(x + gap, plot.left + plot.width - READOUT_WIDTH);
|
|
1128
|
+
return { opacity: 1, transform: [{ translateX: at }] };
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;
|
|
1132
|
+
const fmtValue = formatValue ?? ((value: number) => compactNumber(value));
|
|
1133
|
+
const fmtX = formatX ?? ((datum: MirrorAreaChartDatum) => String(datum[xDataKey] ?? ''));
|
|
1134
|
+
|
|
1135
|
+
if (status === 'loading') return null;
|
|
1136
|
+
|
|
1137
|
+
return (
|
|
1138
|
+
<GestureDetector gesture={pan}>
|
|
1139
|
+
<View style={StyleSheet.absoluteFill}>
|
|
1140
|
+
<Svg width="100%" height="100%" style={StyleSheet.absoluteFill} pointerEvents="none">
|
|
1141
|
+
<AnimatedLine
|
|
1142
|
+
animatedProps={crosshairProps}
|
|
1143
|
+
y1={plot.top}
|
|
1144
|
+
y2={plot.top + plot.height}
|
|
1145
|
+
stroke={stroke}
|
|
1146
|
+
strokeWidth={1}
|
|
1147
|
+
strokeDasharray="3,4"
|
|
1148
|
+
/>
|
|
1149
|
+
<AnimatedCircle
|
|
1150
|
+
animatedProps={dotProps}
|
|
1151
|
+
r={DOT / 2}
|
|
1152
|
+
fill={stroke}
|
|
1153
|
+
stroke={ring}
|
|
1154
|
+
strokeWidth={2}
|
|
1155
|
+
/>
|
|
1156
|
+
</Svg>
|
|
1157
|
+
<Animated.View
|
|
1158
|
+
pointerEvents="none"
|
|
1159
|
+
style={[
|
|
1160
|
+
{ position: 'absolute', left: 0, top: plot.top, width: READOUT_WIDTH },
|
|
1161
|
+
readoutStyle,
|
|
1162
|
+
]}
|
|
1163
|
+
>
|
|
1164
|
+
{active ? (
|
|
1165
|
+
<View
|
|
1166
|
+
className={cn(
|
|
1167
|
+
'gap-0.5 rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',
|
|
1168
|
+
className
|
|
1169
|
+
)}
|
|
1170
|
+
>
|
|
1171
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
1172
|
+
{fmtX(active)}
|
|
1173
|
+
</Text>
|
|
1174
|
+
{formatSummary ? (
|
|
1175
|
+
<Text size="xs" weight="semibold" numberOfLines={2}>
|
|
1176
|
+
{formatSummary(active)}
|
|
1177
|
+
</Text>
|
|
1178
|
+
) : (
|
|
1179
|
+
series.map((item) => {
|
|
1180
|
+
const value = active[item.key];
|
|
1181
|
+
if (typeof value !== 'number') return null;
|
|
1182
|
+
return (
|
|
1183
|
+
<View key={item.key} className="flex-row items-center gap-1.5">
|
|
1184
|
+
<View
|
|
1185
|
+
style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: item.color }}
|
|
1186
|
+
/>
|
|
1187
|
+
<Text size="xs" weight="medium">
|
|
1188
|
+
{fmtValue(value, item.key)}
|
|
1189
|
+
</Text>
|
|
1190
|
+
<Text size="xs" muted numberOfLines={1} className="shrink">
|
|
1191
|
+
{item.label}
|
|
1192
|
+
</Text>
|
|
1193
|
+
</View>
|
|
1194
|
+
);
|
|
1195
|
+
})
|
|
1196
|
+
)}
|
|
1197
|
+
</View>
|
|
1198
|
+
) : null}
|
|
1199
|
+
</Animated.View>
|
|
1200
|
+
</View>
|
|
1201
|
+
</GestureDetector>
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
MirrorAreaChartTooltip.displayName = 'MirrorAreaChart.Tooltip';
|
|
1205
|
+
MirrorAreaChartTooltip.layer = 'overlay' as Layer;
|
|
1206
|
+
|
|
1207
|
+
export interface MirrorAreaChartLegendProps extends ViewProps {
|
|
1208
|
+
className?: string;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** A swatch and a name per area, top half first. */
|
|
1212
|
+
function MirrorAreaChartLegend({ className, ...props }: MirrorAreaChartLegendProps) {
|
|
1213
|
+
const { series } = useChart('MirrorAreaChart.Legend');
|
|
1214
|
+
if (!series.length) return null;
|
|
1215
|
+
|
|
1216
|
+
return (
|
|
1217
|
+
<View
|
|
1218
|
+
{...props}
|
|
1219
|
+
style={[{ pointerEvents: 'none' }, props.style]}
|
|
1220
|
+
className={cn('absolute right-2 top-0 flex-row flex-wrap gap-3', className)}
|
|
1221
|
+
>
|
|
1222
|
+
{sidesInOrder(series).map((item) => (
|
|
1223
|
+
<SeriesSwatch key={item.key} color={item.color} label={item.label} />
|
|
1224
|
+
))}
|
|
1225
|
+
</View>
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
MirrorAreaChartLegend.displayName = 'MirrorAreaChart.Legend';
|
|
1229
|
+
MirrorAreaChartLegend.layer = 'overlay' as Layer;
|
|
1230
|
+
|
|
1231
|
+
/** Top-half areas first, then the bottom half's, each in declaration order. */
|
|
1232
|
+
function sidesInOrder(series: MirrorSeries[]): MirrorSeries[] {
|
|
1233
|
+
return [
|
|
1234
|
+
...series.filter((item) => item.side === 'top'),
|
|
1235
|
+
...series.filter((item) => item.side === 'bottom'),
|
|
1236
|
+
];
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** One series' colour and name. Shared by the legend and the header. */
|
|
1240
|
+
function SeriesSwatch({ color, label }: { color: string; label: string }) {
|
|
1241
|
+
return (
|
|
1242
|
+
<View className="flex-row items-center gap-1.5">
|
|
1243
|
+
<View style={{ backgroundColor: color }} className="h-2 w-2 rounded-full" />
|
|
1244
|
+
<Text size="xs" muted>
|
|
1245
|
+
{label}
|
|
1246
|
+
</Text>
|
|
1247
|
+
</View>
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/* -------------------------------------------------------------------------- */
|
|
1252
|
+
/* Header layer */
|
|
1253
|
+
/* -------------------------------------------------------------------------- */
|
|
1254
|
+
|
|
1255
|
+
export interface MirrorAreaChartHeaderProps extends ViewProps {
|
|
1256
|
+
className?: string;
|
|
1257
|
+
/** Small line above the value — what the chart is of. */
|
|
1258
|
+
title?: string;
|
|
1259
|
+
/** The readout. The largest thing on the card, and the first thing read. */
|
|
1260
|
+
value?: string;
|
|
1261
|
+
/** One muted line under the value — a period, a comparison, a total. */
|
|
1262
|
+
caption?: string;
|
|
1263
|
+
/** Draw a swatch and a name per area along the trailing edge. */
|
|
1264
|
+
legend?: boolean;
|
|
1265
|
+
/** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */
|
|
1266
|
+
children?: ReactNode;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* The strip above the plot: what the chart is of and what it currently reads.
|
|
1271
|
+
* The value is not derived here — pass the formatted string, from
|
|
1272
|
+
* `onActiveIndexChange` if it follows the finger.
|
|
1273
|
+
*/
|
|
1274
|
+
function MirrorAreaChartHeader({
|
|
1275
|
+
className,
|
|
1276
|
+
title,
|
|
1277
|
+
value,
|
|
1278
|
+
caption,
|
|
1279
|
+
legend = false,
|
|
1280
|
+
children,
|
|
1281
|
+
...props
|
|
1282
|
+
}: MirrorAreaChartHeaderProps) {
|
|
1283
|
+
const { series } = useChart('MirrorAreaChart.Header');
|
|
1284
|
+
const trailing =
|
|
1285
|
+
children ??
|
|
1286
|
+
(legend && series.length ? (
|
|
1287
|
+
<View className="flex-row flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
|
1288
|
+
{sidesInOrder(series).map((item) => (
|
|
1289
|
+
<SeriesSwatch key={item.key} color={item.color} label={item.label} />
|
|
1290
|
+
))}
|
|
1291
|
+
</View>
|
|
1292
|
+
) : null);
|
|
1293
|
+
|
|
1294
|
+
return (
|
|
1295
|
+
<View
|
|
1296
|
+
{...props}
|
|
1297
|
+
className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
|
|
1298
|
+
>
|
|
1299
|
+
<View className="flex-1 gap-0.5">
|
|
1300
|
+
{title ? (
|
|
1301
|
+
<Text size="xs" muted>
|
|
1302
|
+
{title}
|
|
1303
|
+
</Text>
|
|
1304
|
+
) : null}
|
|
1305
|
+
{value ? (
|
|
1306
|
+
<Text size="xl" weight="bold">
|
|
1307
|
+
{value}
|
|
1308
|
+
</Text>
|
|
1309
|
+
) : null}
|
|
1310
|
+
{caption ? (
|
|
1311
|
+
<Text size="xs" muted>
|
|
1312
|
+
{caption}
|
|
1313
|
+
</Text>
|
|
1314
|
+
) : null}
|
|
1315
|
+
</View>
|
|
1316
|
+
{trailing ? <View className="shrink pt-1">{trailing}</View> : null}
|
|
1317
|
+
</View>
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
MirrorAreaChartHeader.displayName = 'MirrorAreaChart.Header';
|
|
1321
|
+
MirrorAreaChartHeader.layer = 'header' as Layer;
|
|
1322
|
+
|
|
1323
|
+
export const MirrorAreaChart = Object.assign(MirrorAreaChartRoot, {
|
|
1324
|
+
Header: MirrorAreaChartHeader,
|
|
1325
|
+
Grid: MirrorAreaChartGrid,
|
|
1326
|
+
Area: MirrorAreaChartArea,
|
|
1327
|
+
Baseline: MirrorAreaChartBaseline,
|
|
1328
|
+
Skeleton: MirrorAreaChartSkeleton,
|
|
1329
|
+
XAxis: MirrorAreaChartXAxis,
|
|
1330
|
+
YAxis: MirrorAreaChartYAxis,
|
|
1331
|
+
Tooltip: MirrorAreaChartTooltip,
|
|
1332
|
+
Legend: MirrorAreaChartLegend,
|
|
1333
|
+
});
|