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.
Files changed (46) hide show
  1. package/README.md +2 -0
  2. package/lib/module/components/animated-badge/index.js +24 -2
  3. package/lib/module/components/animated-badge/index.js.map +1 -1
  4. package/lib/module/components/bump-chart/index.js +1236 -0
  5. package/lib/module/components/bump-chart/index.js.map +1 -0
  6. package/lib/module/components/compare/index.js +576 -0
  7. package/lib/module/components/compare/index.js.map +1 -0
  8. package/lib/module/components/mirror-area-chart/index.js +1113 -0
  9. package/lib/module/components/mirror-area-chart/index.js.map +1 -0
  10. package/lib/module/components/sankey-chart/index.js +933 -0
  11. package/lib/module/components/sankey-chart/index.js.map +1 -0
  12. package/lib/module/components/sankey-chart/sankey-layout.js +530 -0
  13. package/lib/module/components/sankey-chart/sankey-layout.js.map +1 -0
  14. package/lib/module/icons/index.js +15 -3
  15. package/lib/module/icons/index.js.map +1 -1
  16. package/lib/module/index.js +4 -0
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/utils/chart.js +101 -0
  19. package/lib/module/utils/chart.js.map +1 -1
  20. package/lib/typescript/src/components/animated-badge/index.d.ts +5 -0
  21. package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -1
  22. package/lib/typescript/src/components/bump-chart/index.d.ts +267 -0
  23. package/lib/typescript/src/components/bump-chart/index.d.ts.map +1 -0
  24. package/lib/typescript/src/components/compare/index.d.ts +183 -0
  25. package/lib/typescript/src/components/compare/index.d.ts.map +1 -0
  26. package/lib/typescript/src/components/mirror-area-chart/index.d.ts +263 -0
  27. package/lib/typescript/src/components/mirror-area-chart/index.d.ts.map +1 -0
  28. package/lib/typescript/src/components/sankey-chart/index.d.ts +307 -0
  29. package/lib/typescript/src/components/sankey-chart/index.d.ts.map +1 -0
  30. package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts +107 -0
  31. package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts.map +1 -0
  32. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  33. package/lib/typescript/src/index.d.ts +4 -0
  34. package/lib/typescript/src/index.d.ts.map +1 -1
  35. package/lib/typescript/src/utils/chart.d.ts +40 -0
  36. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  37. package/package.json +1 -1
  38. package/src/components/animated-badge/index.tsx +35 -2
  39. package/src/components/bump-chart/index.tsx +1455 -0
  40. package/src/components/compare/index.tsx +650 -0
  41. package/src/components/mirror-area-chart/index.tsx +1333 -0
  42. package/src/components/sankey-chart/index.tsx +1165 -0
  43. package/src/components/sankey-chart/sankey-layout.ts +645 -0
  44. package/src/icons/index.tsx +20 -6
  45. package/src/index.ts +62 -0
  46. 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
+ });