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,1455 @@
1
+ /**
2
+ * BumpChart — how a set of things ranked against each other over time.
3
+ *
4
+ * ```tsx
5
+ * <BumpChart data={standings} xDataKey="week" defaultHighlight="harbour">
6
+ * <BumpChart.Grid />
7
+ * <BumpChart.Line dataKey="harbour" label="Harbour" />
8
+ * <BumpChart.Line dataKey="northside" label="Northside" />
9
+ * <BumpChart.YAxis />
10
+ * <BumpChart.XAxis />
11
+ * <BumpChart.Labels />
12
+ * <BumpChart.Tooltip />
13
+ * </BumpChart>
14
+ * ```
15
+ *
16
+ * ## Positions, not values
17
+ *
18
+ * The y-axis is the rank, so the rows are evenly spaced whatever the gap
19
+ * between the underlying scores. That is the point of the chart: who passed
20
+ * whom, and when. A line chart of the same scores answers how far apart they
21
+ * were, and loses the order wherever two lines run close together.
22
+ *
23
+ * Pass the ranks directly, or pass the scores with `values="score"` and each
24
+ * column is ranked for you, highest first. `bumpRanks` is exported so a header
25
+ * or a table beside the chart can read the same order the chart drew.
26
+ *
27
+ * ## One line at a time
28
+ *
29
+ * Past three or four series, every line in its own colour is a tangle. The
30
+ * chart reads best with one line picked out: `highlight` draws that one in its
31
+ * colour and on top, and the rest in a muted grey. Tapping a name in
32
+ * `BumpChart.Labels` picks that line, and tapping it again clears it.
33
+ */
34
+ import {
35
+ Children,
36
+ createContext,
37
+ forwardRef,
38
+ isValidElement,
39
+ useContext,
40
+ useEffect,
41
+ useImperativeHandle,
42
+ useMemo,
43
+ useRef,
44
+ useState,
45
+ type ReactNode,
46
+ } from 'react';
47
+ import {
48
+ Pressable,
49
+ StyleSheet,
50
+ View,
51
+ type LayoutChangeEvent,
52
+ type ViewProps,
53
+ } from 'react-native';
54
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
55
+ import Animated, {
56
+ Easing,
57
+ cancelAnimation,
58
+ runOnJS,
59
+ runOnUI,
60
+ useAnimatedProps,
61
+ useAnimatedStyle,
62
+ useReducedMotion,
63
+ useSharedValue,
64
+ withRepeat,
65
+ withTiming,
66
+ type SharedValue,
67
+ } from 'react-native-reanimated';
68
+ import Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Rect, Stop } from 'react-native-svg';
69
+ import { useCSSVariable } from 'uniwind';
70
+ import { Text } from '../../primitives/text';
71
+ import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility';
72
+ import { useControllableState } from '../../primitives/controllable-state';
73
+ import { finiteChartNumber } from '../../primitives/finite-chart';
74
+ import {
75
+ bumpSegment,
76
+ colorStop,
77
+ dotsPath,
78
+ useSeriesColor,
79
+ xOf,
80
+ type ChartPoint,
81
+ type Plot,
82
+ type SeriesColorIndex,
83
+ } from '../../utils/chart';
84
+ import { cn } from '../../utils/cn';
85
+
86
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
87
+ const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
88
+
89
+ const PADDING = { top: 8, right: 10, bottom: 32, left: 10 };
90
+
91
+ /** Left gutter reserved when a `YAxis` is present, for `#1`…`#12`. */
92
+ const Y_AXIS_WIDTH = 34;
93
+
94
+ /** Gap between the rank labels and the first column. */
95
+ const Y_AXIS_GUTTER = 8;
96
+
97
+ /** Gap between the last column and the series names. */
98
+ const LABELS_GAP = 12;
99
+
100
+ /** Box each x label is centred in. */
101
+ const POINT_LABEL_WIDTH = 56;
102
+
103
+ /** Line height of an `xs` label, for centring one on the row it names. */
104
+ const AXIS_LABEL_HEIGHT = 16;
105
+
106
+ /** Width of the scrub readout. */
107
+ const READOUT_WIDTH = 148;
108
+
109
+ /** How far the lines that are not picked out fade back. */
110
+ const DIMMED_OPACITY = 0.5;
111
+
112
+ type Layer = 'svg' | 'series' | 'overlay' | 'header';
113
+
114
+ export type BumpChartStatus = 'loading' | 'ready';
115
+ export type BumpChartDatum = Record<string, string | number | null | undefined>;
116
+
117
+ /** Ranks per series key, one entry per row. `null` where a series has none. */
118
+ export type BumpRanks = Record<string, (number | null)[]>;
119
+
120
+ /**
121
+ * Each series' rank in every row.
122
+ *
123
+ * With `values: 'rank'` the rows already hold ranks and they are only
124
+ * validated. With `values: 'score'` each row is ranked highest first; a tie
125
+ * keeps the order the keys are passed in, so two equal scores never draw on
126
+ * top of each other.
127
+ */
128
+ export function bumpRanks(
129
+ data: BumpChartDatum[],
130
+ keys: string[],
131
+ options: { values?: 'rank' | 'score' } = {}
132
+ ): BumpRanks {
133
+ const ranks: BumpRanks = {};
134
+ for (const key of keys) ranks[key] = [];
135
+
136
+ for (const row of data) {
137
+ if (options.values === 'score') {
138
+ const scored = keys
139
+ .map((key, order) => ({ key, order, score: finiteChartNumber(row[key]) }))
140
+ .filter((entry): entry is { key: string; order: number; score: number } =>
141
+ entry.score !== undefined
142
+ )
143
+ .sort((a, b) => b.score - a.score || a.order - b.order);
144
+ const place = new Map(scored.map((entry, index) => [entry.key, index + 1]));
145
+ for (const key of keys) ranks[key]!.push(place.get(key) ?? null);
146
+ } else {
147
+ for (const key of keys) {
148
+ const rank = finiteChartNumber(row[key]);
149
+ ranks[key]!.push(rank !== undefined && rank >= 1 ? rank : null);
150
+ }
151
+ }
152
+ }
153
+ return ranks;
154
+ }
155
+
156
+ /**
157
+ * Ranks as a fraction of the plot's height: `0` is the top row, `1` the
158
+ * bottom. Stored this way so a line can travel between two charts with a
159
+ * different number of rows without a jump.
160
+ */
161
+ function rankFractions(ranks: BumpRanks, rows: number): BumpRanks {
162
+ const out: BumpRanks = {};
163
+ for (const key of Object.keys(ranks)) {
164
+ out[key] = ranks[key]!.map((rank) =>
165
+ rank === null ? null : rows <= 1 ? 0.5 : (rank - 1) / (rows - 1)
166
+ );
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /** A line's points at `progress` of the way between two sets of positions. */
172
+ function pointsAt(
173
+ key: string,
174
+ from: BumpRanks,
175
+ to: BumpRanks,
176
+ progress: number,
177
+ plot: Plot
178
+ ): ChartPoint[][] {
179
+ 'worklet';
180
+ const target = to[key];
181
+ if (!target) return [];
182
+ const origin = from[key];
183
+ const runs: ChartPoint[][] = [];
184
+ let run: ChartPoint[] = [];
185
+ for (let i = 0; i < target.length; i += 1) {
186
+ const end = target[i];
187
+ if (end === null || end === undefined) {
188
+ if (run.length) runs.push(run);
189
+ run = [];
190
+ continue;
191
+ }
192
+ const start = origin?.[i];
193
+ const fraction =
194
+ start === null || start === undefined ? end : start + (end - start) * progress;
195
+ run.push({ x: xOf(i, target.length, plot), y: plot.top + fraction * plot.height });
196
+ }
197
+ if (run.length) runs.push(run);
198
+ return runs;
199
+ }
200
+
201
+ interface BumpSeries {
202
+ key: string;
203
+ color: string;
204
+ label: string;
205
+ strokeWidth: number;
206
+ showDots: boolean;
207
+ }
208
+
209
+ interface BumpChartContextValue {
210
+ data: BumpChartDatum[];
211
+ xDataKey: string;
212
+ plot: Plot;
213
+ pad: typeof PADDING;
214
+ status: BumpChartStatus;
215
+ series: BumpSeries[];
216
+ ranks: BumpRanks;
217
+ rows: number;
218
+ registerSeries: (series: BumpSeries) => void;
219
+ unregisterSeries: (key: string) => void;
220
+ from: SharedValue<BumpRanks>;
221
+ to: SharedValue<BumpRanks>;
222
+ morph: SharedValue<number>;
223
+ highlight: string | null;
224
+ setHighlight: (key: string | null) => void;
225
+ activeIndex: SharedValue<number>;
226
+ activeIndexJS: number;
227
+ setActiveIndexJS: (index: number) => void;
228
+ }
229
+
230
+ const BumpChartContext = createContext<BumpChartContextValue | null>(null);
231
+
232
+ function useChart(component: string): BumpChartContextValue {
233
+ const context = useContext(BumpChartContext);
234
+ if (!context) {
235
+ throw new Error(`${component} must be used within a <BumpChart>`);
236
+ }
237
+ return context;
238
+ }
239
+
240
+ /** The row under the scrub, and which line is picked out. */
241
+ export function useBumpChart() {
242
+ const { data, activeIndexJS, xDataKey, ranks, highlight, setHighlight } =
243
+ useChart('useBumpChart');
244
+ return {
245
+ activeIndex: activeIndexJS,
246
+ activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,
247
+ xDataKey,
248
+ ranks,
249
+ highlight,
250
+ setHighlight,
251
+ };
252
+ }
253
+
254
+ export interface BumpChartProps extends ViewProps, ChartAccessibilityProps<BumpChartDatum> {
255
+ className?: string;
256
+ /** The rows. Each one is a column: a week, a round, a release. */
257
+ data: BumpChartDatum[];
258
+ /** Key holding the column's label. */
259
+ xDataKey?: string;
260
+ /**
261
+ * What the series columns hold. `rank` (the default) takes them as places,
262
+ * `1` at the top. `score` ranks every row for you, highest first.
263
+ */
264
+ values?: 'rank' | 'score';
265
+ /**
266
+ * The series drawn in its colour and on top, with the rest muted. `null`
267
+ * picks none, and every line keeps its own colour. Controlled — pair it with
268
+ * `onHighlightChange`.
269
+ */
270
+ highlight?: string | null;
271
+ /** The series picked out on first render, when `highlight` is not passed. */
272
+ defaultHighlight?: string | null;
273
+ /** Called when a name in `BumpChart.Labels` is tapped. */
274
+ onHighlightChange?: (key: string | null) => void;
275
+ /**
276
+ * `loading` hides the lines and shows `BumpChart.Skeleton` if there is one.
277
+ * The lines are revealed when it turns `ready`.
278
+ */
279
+ status?: BumpChartStatus;
280
+ /** Width ÷ height. */
281
+ aspectRatio?: number;
282
+ /** Milliseconds for the reveal on mount. */
283
+ animationDuration?: number;
284
+ /** Milliseconds for the lines to move to new places when the data changes. */
285
+ morphDuration?: number;
286
+ /**
287
+ * The column under the scrub as it moves, and `-1`/`null` when it lifts.
288
+ * Fires when the index changes, not per frame.
289
+ */
290
+ onActiveIndexChange?: (index: number, datum: BumpChartDatum | null) => void;
291
+ /** Drop the axis padding, for a chart with no axes. */
292
+ compact?: boolean;
293
+ children?: ReactNode;
294
+ }
295
+
296
+ /** Imperative handle: re-run the reveal on demand, for a "replay" control. */
297
+ export interface BumpChartHandle {
298
+ replay: () => void;
299
+ }
300
+
301
+ function ChildSlot({ children }: { children: ReactNode }) {
302
+ return <>{children}</>;
303
+ }
304
+
305
+ function partition(children: ReactNode) {
306
+ const svg: ReactNode[] = [];
307
+ const series: ReactNode[] = [];
308
+ const overlay: ReactNode[] = [];
309
+ const header: ReactNode[] = [];
310
+ Children.forEach(children, (child, index) => {
311
+ if (!isValidElement(child)) return;
312
+ const layer = (child.type as { layer?: Layer }).layer ?? 'svg';
313
+ const slot = <ChildSlot key={index}>{child}</ChildSlot>;
314
+ const bucket =
315
+ layer === 'header'
316
+ ? header
317
+ : layer === 'overlay'
318
+ ? overlay
319
+ : layer === 'series'
320
+ ? series
321
+ : svg;
322
+ bucket.push(slot);
323
+ });
324
+ return { svg, series, overlay, header };
325
+ }
326
+
327
+ /**
328
+ * What the root needs to know about its children before it lays the plot out:
329
+ * whether an axis or the names want room at the sides, and the order the lines
330
+ * were declared in.
331
+ */
332
+ function survey(children: ReactNode) {
333
+ let hasYAxis = false;
334
+ let labelsWidth = 0;
335
+ const order: string[] = [];
336
+ Children.forEach(children, (child) => {
337
+ if (!isValidElement(child)) return;
338
+ const type = child.type as { axis?: string; labels?: boolean; line?: boolean };
339
+ const props = child.props as { dataKey?: string; width?: number };
340
+ if (type.axis === 'y') hasYAxis = true;
341
+ if (type.labels) labelsWidth = props.width ?? DEFAULT_LABELS_WIDTH;
342
+ if (type.line && typeof props.dataKey === 'string') order.push(props.dataKey);
343
+ });
344
+ return { hasYAxis, labelsWidth, order };
345
+ }
346
+
347
+ const BumpChartRoot = forwardRef<BumpChartHandle, BumpChartProps>(function BumpChartRoot(
348
+ {
349
+ className,
350
+ data,
351
+ xDataKey = 'date',
352
+ values = 'rank',
353
+ highlight: highlightProp,
354
+ defaultHighlight = null,
355
+ onHighlightChange,
356
+ status = 'ready',
357
+ aspectRatio = 1.7,
358
+ animationDuration = 700,
359
+ morphDuration = 500,
360
+ onActiveIndexChange,
361
+ accessible,
362
+ accessibilityLabel,
363
+ accessibilityHint,
364
+ accessibilityLabelForDatum,
365
+ onAccessibilityDatumPress,
366
+ compact = false,
367
+ children,
368
+ ...props
369
+ },
370
+ ref
371
+ ) {
372
+ const [size, setSize] = useState({ width: 0, height: 0 });
373
+ const [registered, setRegistered] = useState<BumpSeries[]>([]);
374
+ const [activeIndexJS, setActiveIndexJS] = useState(-1);
375
+
376
+ const reveal = useSharedValue(0);
377
+ const morph = useSharedValue(1);
378
+ const activeIndex = useSharedValue(-1);
379
+ const reducedMotion = useReducedMotion();
380
+
381
+ const { value: highlight, setValue: setHighlight } = useControllableState<string | null>({
382
+ value: highlightProp,
383
+ defaultValue: defaultHighlight,
384
+ onChange: onHighlightChange,
385
+ });
386
+
387
+ const registerSeries = useMemo(
388
+ () => (entry: BumpSeries) =>
389
+ setRegistered((current) => {
390
+ const existing = current.find((item) => item.key === entry.key);
391
+ if (
392
+ existing &&
393
+ existing.color === entry.color &&
394
+ existing.label === entry.label &&
395
+ existing.strokeWidth === entry.strokeWidth &&
396
+ existing.showDots === entry.showDots
397
+ ) {
398
+ return current;
399
+ }
400
+ return [...current.filter((item) => item.key !== entry.key), entry];
401
+ }),
402
+ []
403
+ );
404
+
405
+ const unregisterSeries = useMemo(
406
+ () => (key: string) => setRegistered((current) => current.filter((item) => item.key !== key)),
407
+ []
408
+ );
409
+
410
+ const { hasYAxis, labelsWidth, order } = survey(children);
411
+ const orderKey = order.join('|');
412
+
413
+ /*
414
+ * Declaration order, not registration order. A line re-registers when its
415
+ * colour resolves or its label changes, which would otherwise move it to the
416
+ * end of the list — and in `score` mode the order breaks ties.
417
+ */
418
+ const series = useMemo(() => {
419
+ const declared = orderKey ? orderKey.split('|') : [];
420
+ const position = (key: string) => {
421
+ const index = declared.indexOf(key);
422
+ return index === -1 ? declared.length : index;
423
+ };
424
+ return [...registered].sort((a, b) => position(a.key) - position(b.key));
425
+ }, [registered, orderKey]);
426
+
427
+ const pad = compact
428
+ ? { top: 6, right: 6, bottom: 6, left: 6 }
429
+ : {
430
+ ...PADDING,
431
+ left: hasYAxis ? Y_AXIS_WIDTH + Y_AXIS_GUTTER : PADDING.left,
432
+ right: labelsWidth ? labelsWidth + LABELS_GAP : PADDING.right,
433
+ };
434
+ const plot: Plot = {
435
+ left: pad.left,
436
+ top: pad.top,
437
+ width: Math.max(size.width - pad.left - pad.right, 0),
438
+ height: Math.max(size.height - pad.top - pad.bottom, 0),
439
+ };
440
+
441
+ const seriesKeys = series.map((item) => item.key).join('|');
442
+ const ranks = useMemo(
443
+ () => bumpRanks(data, seriesKeys ? seriesKeys.split('|') : [], { values }),
444
+ [data, seriesKeys, values]
445
+ );
446
+
447
+ /** How many places there are: the lowest rank anybody holds. */
448
+ const rows = useMemo(() => {
449
+ let most = 0;
450
+ for (const key of Object.keys(ranks)) {
451
+ for (const rank of ranks[key]!) {
452
+ if (rank !== null && rank > most) most = rank;
453
+ }
454
+ }
455
+ return Math.max(most, 1);
456
+ }, [ranks]);
457
+
458
+ const fractions = useMemo(() => rankFractions(ranks, rows), [ranks, rows]);
459
+ const from = useSharedValue<BumpRanks>(fractions);
460
+ const to = useSharedValue<BumpRanks>(fractions);
461
+
462
+ /*
463
+ * A change of data moves every line from where it is drawn now to its new
464
+ * place. "Where it is drawn now" is read on the UI thread, inside the same
465
+ * worklet that starts the move, so a change landing mid-move carries on from
466
+ * the lines' actual positions instead of snapping back to the last target.
467
+ */
468
+ const loading = status === 'loading';
469
+ const settledOnce = useRef(false);
470
+ useEffect(() => {
471
+ if (!settledOnce.current || reducedMotion) {
472
+ settledOnce.current = true;
473
+ from.value = fractions;
474
+ to.value = fractions;
475
+ morph.value = 1;
476
+ return;
477
+ }
478
+ runOnUI((next: BumpRanks, duration: number) => {
479
+ 'worklet';
480
+ const progress = morph.value;
481
+ const current: BumpRanks = {};
482
+ const previousTo = to.value;
483
+ const previousFrom = from.value;
484
+ for (const key of Object.keys(next)) {
485
+ const target = previousTo[key];
486
+ const origin = previousFrom[key];
487
+ current[key] = next[key]!.map((_unused, index) => {
488
+ const end = target?.[index];
489
+ if (end === null || end === undefined) return null;
490
+ const start = origin?.[index];
491
+ return start === null || start === undefined ? end : start + (end - start) * progress;
492
+ });
493
+ }
494
+ from.value = current;
495
+ to.value = next;
496
+ morph.value = 0;
497
+ morph.value = withTiming(1, { duration, easing: Easing.inOut(Easing.cubic) });
498
+ })(fractions, morphDuration);
499
+ }, [fractions, reducedMotion, morphDuration, from, to, morph]);
500
+
501
+ const revealed = useRef(false);
502
+ const playReveal = useMemo(
503
+ () => () => {
504
+ if (reducedMotion) {
505
+ reveal.value = 1;
506
+ return;
507
+ }
508
+ reveal.value = 0;
509
+ reveal.value = withTiming(1, {
510
+ duration: animationDuration,
511
+ easing: Easing.out(Easing.cubic),
512
+ });
513
+ },
514
+ [reducedMotion, animationDuration, reveal]
515
+ );
516
+
517
+ useEffect(() => {
518
+ // Going back to `loading` arms the reveal again, so a refetched chart
519
+ // draws itself in rather than appearing complete on the frame it lands.
520
+ if (loading) {
521
+ revealed.current = false;
522
+ reveal.value = 0;
523
+ return;
524
+ }
525
+ if (revealed.current || plot.width <= 0 || !data.length) return;
526
+ revealed.current = true;
527
+ playReveal();
528
+ }, [loading, plot.width, data.length, playReveal, reveal]);
529
+
530
+ useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
531
+
532
+ /*
533
+ * The reveal widens past the plot's right edge by the dots' radius, so the
534
+ * last column's dots are not cut in half when it finishes.
535
+ */
536
+ const revealWidth = plot.width + 8;
537
+ const revealStyle = useAnimatedStyle(() => ({ width: revealWidth * reveal.value + 8 }));
538
+
539
+ const handleActiveIndex = useMemo(
540
+ () => (index: number) => {
541
+ setActiveIndexJS(index);
542
+ onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);
543
+ },
544
+ [onActiveIndexChange, data]
545
+ );
546
+
547
+ const onLayout = (event: LayoutChangeEvent) => {
548
+ const { width, height } = event.nativeEvent.layout;
549
+ setSize((current) =>
550
+ Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
551
+ ? current
552
+ : { width, height }
553
+ );
554
+ props.onLayout?.(event);
555
+ };
556
+
557
+ const context = useMemo<BumpChartContextValue>(
558
+ () => ({
559
+ data,
560
+ xDataKey,
561
+ plot,
562
+ pad,
563
+ status,
564
+ series,
565
+ ranks,
566
+ rows,
567
+ registerSeries,
568
+ unregisterSeries,
569
+ from,
570
+ to,
571
+ morph,
572
+ highlight,
573
+ setHighlight,
574
+ activeIndex,
575
+ activeIndexJS,
576
+ setActiveIndexJS: handleActiveIndex,
577
+ }),
578
+ // `plot` and `pad` are rebuilt every render from `size`, so they are
579
+ // compared by value.
580
+ // eslint-disable-next-line react-hooks/exhaustive-deps
581
+ [
582
+ data,
583
+ xDataKey,
584
+ plot.width,
585
+ plot.height,
586
+ plot.left,
587
+ plot.top,
588
+ pad.right,
589
+ pad.bottom,
590
+ status,
591
+ series,
592
+ ranks,
593
+ rows,
594
+ registerSeries,
595
+ unregisterSeries,
596
+ from,
597
+ to,
598
+ morph,
599
+ highlight,
600
+ setHighlight,
601
+ activeIndex,
602
+ activeIndexJS,
603
+ handleActiveIndex,
604
+ ]
605
+ );
606
+
607
+ const { svg, series: seriesLayer, overlay, header } = partition(children);
608
+
609
+ return (
610
+ <BumpChartContext.Provider value={context}>
611
+ <View {...props} style={props.style} className={cn('w-full', className)}>
612
+ {header}
613
+ <ChartAccessibilityData
614
+ chart="Bump chart"
615
+ data={data}
616
+ disabled={accessible === false || loading}
617
+ accessibilityLabel={accessibilityLabel}
618
+ accessibilityHint={accessibilityHint}
619
+ accessibilityLabelForDatum={accessibilityLabelForDatum}
620
+ onAccessibilityDatumPress={onAccessibilityDatumPress}
621
+ valueOf={(datum) => {
622
+ const index = data.indexOf(datum);
623
+ return [
624
+ [xDataKey, datum[xDataKey]],
625
+ ...series.map((item) => {
626
+ const rank = ranks[item.key]?.[index];
627
+ return [item.label, rank == null ? null : `rank ${rank}`] as [string, unknown];
628
+ }),
629
+ ];
630
+ }}
631
+ />
632
+ <View
633
+ onLayout={onLayout}
634
+ style={{ aspectRatio }}
635
+ className="w-full"
636
+ accessibilityElementsHidden
637
+ importantForAccessibility="no-hide-descendants"
638
+ >
639
+ {plot.width > 0 ? (
640
+ <>
641
+ <Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
642
+ {svg}
643
+ </Svg>
644
+ {/*
645
+ * The reveal is a view that grows, not an SVG clip path: animated
646
+ * props on a clip inside `<Defs>` never reach the native clip on
647
+ * Android, and the lines would simply appear complete there.
648
+ * Started 8pt left of the plot so the first column's dots are
649
+ * whole from the first frame.
650
+ */}
651
+ {loading ? null : (
652
+ <Animated.View
653
+ pointerEvents="none"
654
+ style={[
655
+ {
656
+ position: 'absolute',
657
+ top: 0,
658
+ bottom: 0,
659
+ left: plot.left - 8,
660
+ overflow: 'hidden',
661
+ },
662
+ revealStyle,
663
+ ]}
664
+ >
665
+ <Svg
666
+ width={size.width}
667
+ height={size.height}
668
+ style={{ position: 'absolute', top: 0, left: -(plot.left - 8) }}
669
+ >
670
+ {seriesLayer}
671
+ <BumpLines />
672
+ </Svg>
673
+ </Animated.View>
674
+ )}
675
+ {overlay}
676
+ </>
677
+ ) : null}
678
+ </View>
679
+ </View>
680
+ </BumpChartContext.Provider>
681
+ );
682
+ });
683
+ BumpChartRoot.displayName = 'BumpChart';
684
+
685
+ /* -------------------------------------------------------------------------- */
686
+ /* SVG layer */
687
+ /* -------------------------------------------------------------------------- */
688
+
689
+ export interface BumpChartGridProps {
690
+ /** A guide down every column. */
691
+ vertical?: boolean;
692
+ /** A guide across every rank. */
693
+ horizontal?: boolean;
694
+ color?: string;
695
+ dashArray?: string;
696
+ opacity?: number;
697
+ }
698
+
699
+ /** Guides down each column, so a dot can be read against the label below it. */
700
+ function BumpChartGrid({
701
+ vertical = true,
702
+ horizontal = false,
703
+ color,
704
+ dashArray = '3,5',
705
+ opacity = 1,
706
+ }: BumpChartGridProps) {
707
+ const { plot, data, rows } = useChart('BumpChart.Grid');
708
+ const token = useCSSVariable('--color-border');
709
+ const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');
710
+
711
+ return (
712
+ <G opacity={opacity}>
713
+ {vertical
714
+ ? data.map((_unused, index) => {
715
+ const x = xOf(index, data.length, plot);
716
+ return (
717
+ <SvgLine
718
+ key={`x${index}`}
719
+ x1={x}
720
+ x2={x}
721
+ y1={plot.top}
722
+ y2={plot.top + plot.height}
723
+ stroke={stroke}
724
+ strokeDasharray={dashArray}
725
+ strokeWidth={1}
726
+ />
727
+ );
728
+ })
729
+ : null}
730
+ {horizontal
731
+ ? Array.from({ length: rows }, (_unused, index) => {
732
+ const y = plot.top + (rows <= 1 ? plot.height / 2 : (plot.height * index) / (rows - 1));
733
+ return (
734
+ <SvgLine
735
+ key={`y${index}`}
736
+ x1={plot.left}
737
+ x2={plot.left + plot.width}
738
+ y1={y}
739
+ y2={y}
740
+ stroke={stroke}
741
+ strokeDasharray={dashArray}
742
+ strokeWidth={1}
743
+ />
744
+ );
745
+ })
746
+ : null}
747
+ </G>
748
+ );
749
+ }
750
+ BumpChartGrid.displayName = 'BumpChart.Grid';
751
+ BumpChartGrid.layer = 'svg' as Layer;
752
+
753
+ export interface BumpChartLineProps {
754
+ /** Column in the data holding this series' rank, or its score with `values="score"`. */
755
+ dataKey: string;
756
+ /** The name shown by `Labels`, the tooltip and the legend. Defaults to `dataKey`. */
757
+ label?: string;
758
+ /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */
759
+ color?: string;
760
+ /** Which of the five chart tokens to take. */
761
+ colorIndex?: SeriesColorIndex;
762
+ /** Thickness of the line. The picked-out line is drawn one point thicker. */
763
+ strokeWidth?: number;
764
+ /** Draw a dot at every column. */
765
+ showDots?: boolean;
766
+ }
767
+
768
+ /**
769
+ * One series. It draws nothing itself: the lines are drawn together, so the
770
+ * one picked out can be drawn last and sit on top of every crossing.
771
+ */
772
+ function BumpChartLine({
773
+ dataKey,
774
+ label,
775
+ color,
776
+ colorIndex = 1,
777
+ strokeWidth = 1.5,
778
+ showDots = true,
779
+ }: BumpChartLineProps) {
780
+ const { registerSeries, unregisterSeries } = useChart('BumpChart.Line');
781
+ const stroke = useSeriesColor(color, colorIndex);
782
+ const name = label ?? dataKey;
783
+
784
+ useEffect(() => {
785
+ registerSeries({ key: dataKey, color: stroke, label: name, strokeWidth, showDots });
786
+ }, [dataKey, stroke, name, strokeWidth, showDots, registerSeries]);
787
+
788
+ useEffect(() => () => unregisterSeries(dataKey), [dataKey, unregisterSeries]);
789
+
790
+ return null;
791
+ }
792
+ BumpChartLine.displayName = 'BumpChart.Line';
793
+ BumpChartLine.layer = 'series' as Layer;
794
+ // Read by the root, for the order the lines were declared in.
795
+ BumpChartLine.line = true as const;
796
+
797
+ /** Every line, the picked-out one last. */
798
+ function BumpLines() {
799
+ const { series, highlight } = useChart('BumpChart');
800
+ const muted = useCSSVariable('--color-muted-foreground');
801
+ const mutedColor = typeof muted === 'string' ? muted : '#737373';
802
+
803
+ const ordered = highlight
804
+ ? [...series.filter((item) => item.key !== highlight), ...series.filter((item) => item.key === highlight)]
805
+ : series;
806
+
807
+ return (
808
+ <G>
809
+ {ordered.map((item) => {
810
+ const picked = highlight === item.key;
811
+ const dimmed = highlight !== null && !picked;
812
+ return (
813
+ <BumpLine
814
+ key={item.key}
815
+ series={item}
816
+ color={dimmed ? mutedColor : item.color}
817
+ opacity={dimmed ? DIMMED_OPACITY : 1}
818
+ strokeWidth={picked ? item.strokeWidth + 1 : item.strokeWidth}
819
+ dotRadius={picked ? 3.5 : 2.5}
820
+ />
821
+ );
822
+ })}
823
+ </G>
824
+ );
825
+ }
826
+
827
+ function BumpLine({
828
+ series,
829
+ color,
830
+ opacity,
831
+ strokeWidth,
832
+ dotRadius,
833
+ }: {
834
+ series: BumpSeries;
835
+ color: string;
836
+ opacity: number;
837
+ strokeWidth: number;
838
+ dotRadius: number;
839
+ }) {
840
+ const { plot, from, to, morph } = useChart('BumpChart.Line');
841
+ const key = series.key;
842
+
843
+ const lineProps = useAnimatedProps(() => ({
844
+ d: pointsAt(key, from.value, to.value, morph.value, plot)
845
+ .map((run) => bumpSegment(run))
846
+ .join(' '),
847
+ }));
848
+
849
+ const dotProps = useAnimatedProps(() => {
850
+ let d = '';
851
+ for (const run of pointsAt(key, from.value, to.value, morph.value, plot)) {
852
+ d += dotsPath(run, dotRadius);
853
+ }
854
+ return { d };
855
+ });
856
+
857
+ return (
858
+ <G opacity={opacity}>
859
+ <AnimatedPath
860
+ animatedProps={lineProps}
861
+ stroke={color}
862
+ strokeWidth={strokeWidth}
863
+ strokeLinecap="round"
864
+ strokeLinejoin="round"
865
+ fill="none"
866
+ />
867
+ {series.showDots ? <AnimatedPath animatedProps={dotProps} fill={color} /> : null}
868
+ </G>
869
+ );
870
+ }
871
+
872
+ export interface BumpChartSkeletonProps {
873
+ /** Milliseconds for one pass of the sweep. */
874
+ duration?: number;
875
+ color?: string;
876
+ /** How many rank rows to stand in for. */
877
+ rows?: number;
878
+ }
879
+
880
+ /**
881
+ * The loading state: a thin bar on every rank row with a highlight travelling
882
+ * across them. Flat, so it says where the lines will be and nothing about
883
+ * where they go.
884
+ */
885
+ function BumpChartSkeleton({ duration = 1400, color, rows: rowsProp }: BumpChartSkeletonProps) {
886
+ const { plot, status, rows: dataRows } = useChart('BumpChart.Skeleton');
887
+ const token = useCSSVariable('--color-skeleton');
888
+ const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
889
+ const highlight = useSeriesColor(undefined, 1);
890
+ const rows = rowsProp ?? Math.max(dataRows, 4);
891
+
892
+ const sweep = useSharedValue(0);
893
+ const reducedMotion = useReducedMotion();
894
+ const loading = status === 'loading';
895
+
896
+ useEffect(() => {
897
+ if (!loading || reducedMotion) {
898
+ cancelAnimation(sweep);
899
+ sweep.value = 0;
900
+ return;
901
+ }
902
+ sweep.value = 0;
903
+ sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);
904
+ return () => cancelAnimation(sweep);
905
+ }, [loading, reducedMotion, duration, sweep]);
906
+
907
+ const animatedProps = useAnimatedProps(() => ({
908
+ x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,
909
+ x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,
910
+ }));
911
+
912
+ if (!loading) return null;
913
+
914
+ const gradientId = 'panelui-bump-skeleton';
915
+ const thickness = 4;
916
+
917
+ return (
918
+ <G>
919
+ <Defs>
920
+ <AnimatedLinearGradient id={gradientId} animatedProps={animatedProps} y1="0" y2="0">
921
+ <Stop offset="0" {...colorStop(base)} />
922
+ <Stop offset="0.5" stopColor={highlight} stopOpacity={0.3} />
923
+ <Stop offset="1" {...colorStop(base)} />
924
+ </AnimatedLinearGradient>
925
+ </Defs>
926
+ {Array.from({ length: rows }, (_unused, index) => {
927
+ const y = plot.top + (rows <= 1 ? plot.height / 2 : (plot.height * index) / (rows - 1));
928
+ return (
929
+ <Rect
930
+ key={index}
931
+ x={plot.left}
932
+ y={y - thickness / 2}
933
+ width={plot.width}
934
+ height={thickness}
935
+ rx={thickness / 2}
936
+ fill={`url(#${gradientId})`}
937
+ />
938
+ );
939
+ })}
940
+ </G>
941
+ );
942
+ }
943
+ BumpChartSkeleton.displayName = 'BumpChart.Skeleton';
944
+ BumpChartSkeleton.layer = 'svg' as Layer;
945
+
946
+ /* -------------------------------------------------------------------------- */
947
+ /* Overlay layer */
948
+ /* -------------------------------------------------------------------------- */
949
+
950
+ export interface BumpChartXAxisProps {
951
+ /** How many columns to label, spread across the run. */
952
+ ticks?: number;
953
+ format?: (datum: BumpChartDatum, index: number) => string;
954
+ className?: string;
955
+ }
956
+
957
+ /** The column labels. Real text, so they follow the theme's font and text scaling. */
958
+ function BumpChartXAxis({ ticks = 4, format, className }: BumpChartXAxisProps) {
959
+ const { data, xDataKey, plot, pad } = useChart('BumpChart.XAxis');
960
+
961
+ const labels = useMemo(() => {
962
+ if (!data.length) return [];
963
+ const count = Math.min(ticks, data.length);
964
+ const step = count > 1 ? (data.length - 1) / (count - 1) : 0;
965
+ return Array.from({ length: count }, (_unused, index) => {
966
+ const dataIndex = Math.round(index * step);
967
+ const datum = data[dataIndex];
968
+ if (!datum) return null;
969
+ return {
970
+ key: dataIndex,
971
+ text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),
972
+ };
973
+ }).filter((label): label is { key: number; text: string } => label !== null);
974
+ }, [data, ticks, format, xDataKey]);
975
+
976
+ return (
977
+ <View
978
+ style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
979
+ className={cn(className)}
980
+ >
981
+ {labels.map((label) => (
982
+ <Text
983
+ key={label.key}
984
+ size="xs"
985
+ muted
986
+ numberOfLines={1}
987
+ style={{
988
+ position: 'absolute',
989
+ bottom: 0,
990
+ left: Math.max(
991
+ 0,
992
+ Math.min(
993
+ xOf(label.key, data.length, plot) - POINT_LABEL_WIDTH / 2,
994
+ plot.left + plot.width + pad.right - POINT_LABEL_WIDTH
995
+ )
996
+ ),
997
+ width: POINT_LABEL_WIDTH,
998
+ textAlign: 'center',
999
+ }}
1000
+ >
1001
+ {label.text}
1002
+ </Text>
1003
+ ))}
1004
+ </View>
1005
+ );
1006
+ }
1007
+ BumpChartXAxis.displayName = 'BumpChart.XAxis';
1008
+ BumpChartXAxis.layer = 'overlay' as Layer;
1009
+
1010
+ export interface BumpChartYAxisProps {
1011
+ /** Format a rank. Defaults to `#1`, `#2`… */
1012
+ format?: (rank: number) => string;
1013
+ className?: string;
1014
+ }
1015
+
1016
+ /** The ranks down the side, one on every row. */
1017
+ function BumpChartYAxis({ format, className }: BumpChartYAxisProps) {
1018
+ const { plot, rows } = useChart('BumpChart.YAxis');
1019
+
1020
+ return (
1021
+ <View
1022
+ pointerEvents="none"
1023
+ style={{
1024
+ position: 'absolute',
1025
+ left: 0,
1026
+ top: plot.top - AXIS_LABEL_HEIGHT / 2,
1027
+ height: plot.height + AXIS_LABEL_HEIGHT,
1028
+ width: Math.max(plot.left - Y_AXIS_GUTTER, 0),
1029
+ }}
1030
+ className={cn(rows <= 1 ? 'items-end justify-center' : 'items-end justify-between', className)}
1031
+ >
1032
+ {Array.from({ length: rows }, (_unused, index) => (
1033
+ <Text key={index} size="xs" muted numberOfLines={1}>
1034
+ {format ? format(index + 1) : `#${index + 1}`}
1035
+ </Text>
1036
+ ))}
1037
+ </View>
1038
+ );
1039
+ }
1040
+ BumpChartYAxis.displayName = 'BumpChart.YAxis';
1041
+ BumpChartYAxis.layer = 'overlay' as Layer;
1042
+ // Read by the root, which leaves room for the ranks before laying the plot out.
1043
+ BumpChartYAxis.axis = 'y' as const;
1044
+
1045
+ const DEFAULT_LABELS_WIDTH = 84;
1046
+
1047
+ export interface BumpChartLabelsProps {
1048
+ /** Room kept to the right of the plot for the names. Longer names are cut short. */
1049
+ width?: number;
1050
+ /** Let a tap on a name pick that line out, and a second tap clear it. */
1051
+ pressable?: boolean;
1052
+ className?: string;
1053
+ }
1054
+
1055
+ /**
1056
+ * Each series' name beside its last point, so the lines can be told apart
1057
+ * without a legend to look across to. The names move with their lines when
1058
+ * the data changes.
1059
+ */
1060
+ function BumpChartLabels({ pressable = true, className }: BumpChartLabelsProps) {
1061
+ const { series, plot, pad, highlight, setHighlight, from, to, morph, status } =
1062
+ useChart('BumpChart.Labels');
1063
+ if (status === 'loading') return null;
1064
+
1065
+ return (
1066
+ <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
1067
+ {series.map((item) => (
1068
+ <BumpLabel
1069
+ key={item.key}
1070
+ series={item}
1071
+ left={plot.left + plot.width + LABELS_GAP}
1072
+ width={pad.right - LABELS_GAP}
1073
+ plot={plot}
1074
+ from={from}
1075
+ to={to}
1076
+ morph={morph}
1077
+ picked={highlight === item.key}
1078
+ dimmed={highlight !== null && highlight !== item.key}
1079
+ onPress={
1080
+ pressable ? () => setHighlight(highlight === item.key ? null : item.key) : undefined
1081
+ }
1082
+ className={className}
1083
+ />
1084
+ ))}
1085
+ </View>
1086
+ );
1087
+ }
1088
+ BumpChartLabels.displayName = 'BumpChart.Labels';
1089
+ BumpChartLabels.layer = 'overlay' as Layer;
1090
+ // Read by the root, which keeps room for the names to the right of the plot.
1091
+ BumpChartLabels.labels = true as const;
1092
+
1093
+ function BumpLabel({
1094
+ series,
1095
+ left,
1096
+ width,
1097
+ plot,
1098
+ from,
1099
+ to,
1100
+ morph,
1101
+ picked,
1102
+ dimmed,
1103
+ onPress,
1104
+ className,
1105
+ }: {
1106
+ series: BumpSeries;
1107
+ left: number;
1108
+ width: number;
1109
+ plot: Plot;
1110
+ from: SharedValue<BumpRanks>;
1111
+ to: SharedValue<BumpRanks>;
1112
+ morph: SharedValue<number>;
1113
+ picked: boolean;
1114
+ dimmed: boolean;
1115
+ onPress?: () => void;
1116
+ className?: string;
1117
+ }) {
1118
+ const key = series.key;
1119
+
1120
+ // The name sits level with the line's last point, wherever that is drawn.
1121
+ const style = useAnimatedStyle(() => {
1122
+ const runs = pointsAt(key, from.value, to.value, morph.value, plot);
1123
+ const run = runs[runs.length - 1];
1124
+ const last = run?.[run.length - 1];
1125
+ if (!last) return { opacity: 0, transform: [{ translateY: 0 }] };
1126
+ return { opacity: 1, transform: [{ translateY: last.y - LABEL_ROW / 2 }] };
1127
+ });
1128
+
1129
+ return (
1130
+ <Animated.View
1131
+ style={[{ position: 'absolute', top: 0, left, width, height: LABEL_ROW }, style]}
1132
+ >
1133
+ <Pressable
1134
+ onPress={onPress}
1135
+ disabled={!onPress}
1136
+ hitSlop={{ top: 4, bottom: 4 }}
1137
+ accessibilityRole="button"
1138
+ accessibilityState={{ selected: picked }}
1139
+ accessibilityLabel={series.label}
1140
+ className="h-full justify-center"
1141
+ >
1142
+ <Text
1143
+ size="xs"
1144
+ weight={picked ? 'semibold' : undefined}
1145
+ muted={!picked}
1146
+ numberOfLines={1}
1147
+ // With nothing picked out, each name takes its line's colour, which
1148
+ // is the only thing tying it to that line across the crossings.
1149
+ style={dimmed ? { opacity: 0.8 } : picked ? undefined : { color: series.color }}
1150
+ className={className}
1151
+ >
1152
+ {series.label}
1153
+ </Text>
1154
+ </Pressable>
1155
+ </Animated.View>
1156
+ );
1157
+ }
1158
+
1159
+ /** Height of one name's hit box. */
1160
+ const LABEL_ROW = 20;
1161
+
1162
+ export interface BumpChartTooltipProps {
1163
+ color?: string;
1164
+ /** Format a rank in the readout. Defaults to `#1`, `#2`… */
1165
+ formatRank?: (rank: number) => string;
1166
+ /** Format the readout's heading from the row. Defaults to the value at xDataKey. */
1167
+ formatX?: (datum: BumpChartDatum) => string;
1168
+ className?: string;
1169
+ }
1170
+
1171
+ /**
1172
+ * A scrub across the columns, and a readout listing every series in the order
1173
+ * it stood at the column under the finger.
1174
+ */
1175
+ function BumpChartTooltip({ color, formatRank, formatX, className }: BumpChartTooltipProps) {
1176
+ const {
1177
+ data,
1178
+ xDataKey,
1179
+ plot,
1180
+ series,
1181
+ ranks,
1182
+ highlight,
1183
+ activeIndex,
1184
+ activeIndexJS,
1185
+ setActiveIndexJS,
1186
+ status,
1187
+ } = useChart('BumpChart.Tooltip');
1188
+
1189
+ const token = useCSSVariable('--color-foreground');
1190
+ const stroke = color ?? (typeof token === 'string' ? token : '#888888');
1191
+ const muted = useCSSVariable('--color-muted-foreground');
1192
+ const mutedColor = typeof muted === 'string' ? muted : '#737373';
1193
+
1194
+ const total = data.length;
1195
+ const left = plot.left;
1196
+ const width = plot.width;
1197
+
1198
+ // Declared inside the memo, next to its callers: a worklet may only call
1199
+ // another worklet.
1200
+ const pan = useMemo(() => {
1201
+ const resolve = (x: number) => {
1202
+ 'worklet';
1203
+ if (total < 1 || width <= 0) return;
1204
+ const ratio = total < 2 ? 0 : (x - left) / width;
1205
+ const next = Math.round(Math.min(1, Math.max(0, ratio)) * (total - 1));
1206
+ if (next === activeIndex.value) return;
1207
+ activeIndex.value = next;
1208
+ runOnJS(setActiveIndexJS)(next);
1209
+ };
1210
+
1211
+ return Gesture.Pan()
1212
+ .minDistance(0)
1213
+ .onBegin((event) => {
1214
+ 'worklet';
1215
+ resolve(event.x);
1216
+ })
1217
+ .onUpdate((event) => {
1218
+ 'worklet';
1219
+ resolve(event.x);
1220
+ })
1221
+ .onFinalize(() => {
1222
+ 'worklet';
1223
+ activeIndex.value = -1;
1224
+ runOnJS(setActiveIndexJS)(-1);
1225
+ });
1226
+ }, [total, left, width, activeIndex, setActiveIndexJS]);
1227
+
1228
+ const crosshairStyle = useAnimatedStyle(() => {
1229
+ const index = activeIndex.value;
1230
+ return {
1231
+ opacity: index < 0 ? 0 : 0.45,
1232
+ transform: [{ translateX: index < 0 ? 0 : xOf(index, total, plot) }],
1233
+ };
1234
+ });
1235
+
1236
+ /*
1237
+ * The readout sits beside the crosshair rather than over it, on whichever
1238
+ * side has room, so it never hides the column it is describing.
1239
+ */
1240
+ const readoutStyle = useAnimatedStyle(() => {
1241
+ const index = activeIndex.value;
1242
+ if (index < 0) return { opacity: 0 };
1243
+ const x = xOf(index, total, plot);
1244
+ const gap = 10;
1245
+ const right = x + gap;
1246
+ const fits = right + READOUT_WIDTH <= plot.left + plot.width;
1247
+ const at = fits ? right : Math.max(plot.left, x - gap - READOUT_WIDTH);
1248
+ return { opacity: 1, transform: [{ translateX: at }] };
1249
+ });
1250
+
1251
+ const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;
1252
+ const standings = useMemo(() => {
1253
+ if (activeIndexJS < 0) return [];
1254
+ return series
1255
+ .map((item) => ({ item, rank: ranks[item.key]?.[activeIndexJS] ?? null }))
1256
+ .filter((entry): entry is { item: BumpSeries; rank: number } => entry.rank !== null)
1257
+ .sort((a, b) => a.rank - b.rank);
1258
+ }, [series, ranks, activeIndexJS]);
1259
+
1260
+ const fmtRank = formatRank ?? ((rank: number) => `#${rank}`);
1261
+ const fmtX = formatX ?? ((datum: BumpChartDatum) => String(datum[xDataKey] ?? ''));
1262
+
1263
+ if (status === 'loading') return null;
1264
+
1265
+ return (
1266
+ <GestureDetector gesture={pan}>
1267
+ {/* Stops short of the names, so a tap there picks a line rather than
1268
+ starting a scrub. */}
1269
+ <View style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: plot.left + plot.width + 8 }}>
1270
+ <Animated.View
1271
+ pointerEvents="none"
1272
+ style={[
1273
+ {
1274
+ position: 'absolute',
1275
+ left: 0,
1276
+ top: plot.top,
1277
+ width: 1,
1278
+ height: plot.height,
1279
+ backgroundColor: stroke,
1280
+ },
1281
+ crosshairStyle,
1282
+ ]}
1283
+ />
1284
+ <Animated.View
1285
+ pointerEvents="none"
1286
+ style={[
1287
+ { position: 'absolute', left: 0, top: plot.top, width: READOUT_WIDTH },
1288
+ readoutStyle,
1289
+ ]}
1290
+ >
1291
+ {active ? (
1292
+ <View
1293
+ className={cn(
1294
+ 'gap-0.5 rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',
1295
+ className
1296
+ )}
1297
+ >
1298
+ <Text size="xs" muted numberOfLines={1}>
1299
+ {fmtX(active)}
1300
+ </Text>
1301
+ {standings.map(({ item, rank }) => (
1302
+ <View key={item.key} className="flex-row items-center gap-1.5">
1303
+ <Text size="xs" muted style={{ minWidth: 20 }}>
1304
+ {fmtRank(rank)}
1305
+ </Text>
1306
+ {/* The colour the line is drawn in, so a muted line has a
1307
+ muted swatch rather than its unused series colour. */}
1308
+ <View
1309
+ style={{
1310
+ width: 6,
1311
+ height: 6,
1312
+ borderRadius: 3,
1313
+ backgroundColor:
1314
+ highlight !== null && highlight !== item.key ? mutedColor : item.color,
1315
+ }}
1316
+ />
1317
+ <Text
1318
+ size="xs"
1319
+ weight={highlight === item.key ? 'semibold' : 'medium'}
1320
+ numberOfLines={1}
1321
+ className="shrink"
1322
+ >
1323
+ {item.label}
1324
+ </Text>
1325
+ </View>
1326
+ ))}
1327
+ </View>
1328
+ ) : null}
1329
+ </Animated.View>
1330
+ </View>
1331
+ </GestureDetector>
1332
+ );
1333
+ }
1334
+ BumpChartTooltip.displayName = 'BumpChart.Tooltip';
1335
+ BumpChartTooltip.layer = 'overlay' as Layer;
1336
+
1337
+ export interface BumpChartLegendProps extends ViewProps {
1338
+ className?: string;
1339
+ }
1340
+
1341
+ /** A swatch and a name per series, in declaration order. */
1342
+ function BumpChartLegend({ className, ...props }: BumpChartLegendProps) {
1343
+ const { series } = useChart('BumpChart.Legend');
1344
+ if (!series.length) return null;
1345
+
1346
+ return (
1347
+ <View
1348
+ {...props}
1349
+ style={[{ pointerEvents: 'none' }, props.style]}
1350
+ className={cn('absolute right-2 top-0 flex-row flex-wrap gap-3', className)}
1351
+ >
1352
+ {series.map((item) => (
1353
+ <SeriesSwatch key={item.key} color={item.color} label={item.label} />
1354
+ ))}
1355
+ </View>
1356
+ );
1357
+ }
1358
+ BumpChartLegend.displayName = 'BumpChart.Legend';
1359
+ BumpChartLegend.layer = 'overlay' as Layer;
1360
+
1361
+ /** One series' colour and name. Shared by the legend and the header. */
1362
+ function SeriesSwatch({ color, label }: { color: string; label: string }) {
1363
+ return (
1364
+ <View className="flex-row items-center gap-1.5">
1365
+ <View style={{ backgroundColor: color }} className="h-2 w-2 rounded-full" />
1366
+ <Text size="xs" muted>
1367
+ {label}
1368
+ </Text>
1369
+ </View>
1370
+ );
1371
+ }
1372
+
1373
+ /* -------------------------------------------------------------------------- */
1374
+ /* Header layer */
1375
+ /* -------------------------------------------------------------------------- */
1376
+
1377
+ export interface BumpChartHeaderProps extends ViewProps {
1378
+ className?: string;
1379
+ /** Small line above the value — what the chart is of. */
1380
+ title?: string;
1381
+ /** The readout. The largest thing on the card, and the first thing read. */
1382
+ value?: string;
1383
+ /** One muted line under the value — a period, a comparison, a total. */
1384
+ caption?: string;
1385
+ /** Draw a swatch and a name per series along the trailing edge. */
1386
+ legend?: boolean;
1387
+ /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */
1388
+ children?: ReactNode;
1389
+ }
1390
+
1391
+ /**
1392
+ * The strip above the plot: what the chart is of and what it currently reads.
1393
+ * The value is not derived here — pass the formatted string, from
1394
+ * `onActiveIndexChange` or `onHighlightChange` if it follows the chart.
1395
+ */
1396
+ function BumpChartHeader({
1397
+ className,
1398
+ title,
1399
+ value,
1400
+ caption,
1401
+ legend = false,
1402
+ children,
1403
+ ...props
1404
+ }: BumpChartHeaderProps) {
1405
+ const { series } = useChart('BumpChart.Header');
1406
+ const trailing =
1407
+ children ??
1408
+ (legend && series.length ? (
1409
+ <View className="flex-row flex-wrap items-center justify-end gap-x-3 gap-y-1">
1410
+ {series.map((item) => (
1411
+ <SeriesSwatch key={item.key} color={item.color} label={item.label} />
1412
+ ))}
1413
+ </View>
1414
+ ) : null);
1415
+
1416
+ return (
1417
+ <View
1418
+ {...props}
1419
+ className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
1420
+ >
1421
+ <View className="flex-1 gap-0.5">
1422
+ {title ? (
1423
+ <Text size="xs" muted>
1424
+ {title}
1425
+ </Text>
1426
+ ) : null}
1427
+ {value ? (
1428
+ <Text size="xl" weight="bold">
1429
+ {value}
1430
+ </Text>
1431
+ ) : null}
1432
+ {caption ? (
1433
+ <Text size="xs" muted>
1434
+ {caption}
1435
+ </Text>
1436
+ ) : null}
1437
+ </View>
1438
+ {trailing ? <View className="shrink pt-1">{trailing}</View> : null}
1439
+ </View>
1440
+ );
1441
+ }
1442
+ BumpChartHeader.displayName = 'BumpChart.Header';
1443
+ BumpChartHeader.layer = 'header' as Layer;
1444
+
1445
+ export const BumpChart = Object.assign(BumpChartRoot, {
1446
+ Header: BumpChartHeader,
1447
+ Grid: BumpChartGrid,
1448
+ Line: BumpChartLine,
1449
+ Skeleton: BumpChartSkeleton,
1450
+ XAxis: BumpChartXAxis,
1451
+ YAxis: BumpChartYAxis,
1452
+ Labels: BumpChartLabels,
1453
+ Tooltip: BumpChartTooltip,
1454
+ Legend: BumpChartLegend,
1455
+ });