panelui-native 0.62.0 → 0.63.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 (69) hide show
  1. package/lib/module/components/area-chart/index.js +14 -4
  2. package/lib/module/components/area-chart/index.js.map +1 -1
  3. package/lib/module/components/bar-chart/index.js +14 -4
  4. package/lib/module/components/bar-chart/index.js.map +1 -1
  5. package/lib/module/components/candlestick-chart/index.js +14 -4
  6. package/lib/module/components/candlestick-chart/index.js.map +1 -1
  7. package/lib/module/components/card/index.js +26 -1
  8. package/lib/module/components/card/index.js.map +1 -1
  9. package/lib/module/components/dialog/index.js +49 -3
  10. package/lib/module/components/dialog/index.js.map +1 -1
  11. package/lib/module/components/hex-chart/index.js +2 -2
  12. package/lib/module/components/hex-chart/index.js.map +1 -1
  13. package/lib/module/components/line-chart/index.js +14 -4
  14. package/lib/module/components/line-chart/index.js.map +1 -1
  15. package/lib/module/components/pie-chart/index.js +2 -2
  16. package/lib/module/components/pie-chart/index.js.map +1 -1
  17. package/lib/module/components/plot/index.js +1329 -0
  18. package/lib/module/components/plot/index.js.map +1 -0
  19. package/lib/module/components/polar-area-chart/index.js +2 -2
  20. package/lib/module/components/polar-area-chart/index.js.map +1 -1
  21. package/lib/module/components/radar-chart/index.js +14 -4
  22. package/lib/module/components/radar-chart/index.js.map +1 -1
  23. package/lib/module/components/ring-chart/index.js +2 -2
  24. package/lib/module/components/ring-chart/index.js.map +1 -1
  25. package/lib/module/components/section-rail/index.js +120 -15
  26. package/lib/module/components/section-rail/index.js.map +1 -1
  27. package/lib/module/components/selection-mode/index.js +129 -18
  28. package/lib/module/components/selection-mode/index.js.map +1 -1
  29. package/lib/module/components/sortable/index.js +15 -5
  30. package/lib/module/components/sortable/index.js.map +1 -1
  31. package/lib/module/index.js +7 -0
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  34. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  35. package/lib/typescript/src/components/candlestick-chart/index.d.ts +6 -3
  36. package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -1
  37. package/lib/typescript/src/components/card/index.d.ts +48 -1
  38. package/lib/typescript/src/components/card/index.d.ts.map +1 -1
  39. package/lib/typescript/src/components/dialog/index.d.ts +47 -2
  40. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/plot/index.d.ts +471 -0
  43. package/lib/typescript/src/components/plot/index.d.ts.map +1 -0
  44. package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -1
  45. package/lib/typescript/src/components/section-rail/index.d.ts +8 -1
  46. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  47. package/lib/typescript/src/components/selection-mode/index.d.ts +82 -6
  48. package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
  50. package/lib/typescript/src/index.d.ts +2 -0
  51. package/lib/typescript/src/index.d.ts.map +1 -1
  52. package/package.json +1 -1
  53. package/src/components/area-chart/index.tsx +14 -4
  54. package/src/components/bar-chart/index.tsx +14 -4
  55. package/src/components/candlestick-chart/index.tsx +20 -7
  56. package/src/components/card/index.tsx +32 -7
  57. package/src/components/dialog/index.tsx +55 -2
  58. package/src/components/hex-chart/index.tsx +2 -2
  59. package/src/components/line-chart/index.tsx +14 -4
  60. package/src/components/pie-chart/index.tsx +2 -2
  61. package/src/components/plot/index.tsx +1564 -0
  62. package/src/components/polar-area-chart/index.tsx +2 -2
  63. package/src/components/radar-chart/index.tsx +14 -4
  64. package/src/components/ring-chart/index.tsx +2 -2
  65. package/src/components/section-rail/index.tsx +133 -14
  66. package/src/components/selection-mode/index.tsx +163 -26
  67. package/src/components/sortable/index.tsx +15 -5
  68. package/src/index.ts +46 -0
  69. package/theme.css +24 -0
@@ -0,0 +1,1564 @@
1
+ /**
2
+ * Plot — a chart you assemble, for the chart that is not in the box.
3
+ *
4
+ * The other charts in this library each answer one question and answer it
5
+ * completely: a line chart knows it is drawing a series over time, and every
6
+ * decision inside it follows from that. This one knows nothing. It measures a
7
+ * box, resolves a scale, and hands both to whatever marks you put in it — so a
8
+ * combination nothing here ships as its own component is still a chart you can
9
+ * build rather than a chart you have to go without.
10
+ *
11
+ * ```tsx
12
+ * <Plot data={months} xDataKey="month">
13
+ * <Plot.Grid />
14
+ * <Plot.Bars dataKey="orders" colorIndex={2} />
15
+ * <Plot.Line dataKey="revenue" />
16
+ * <Plot.YAxis />
17
+ * <Plot.XAxis />
18
+ * <Plot.Cursor />
19
+ * <Plot.Tooltip />
20
+ * </Plot>
21
+ * ```
22
+ *
23
+ * ## One scale, however many marks
24
+ *
25
+ * Every mark reads the same plot box and the same y-domain, and the domain is
26
+ * derived from all of them together. That is the whole reason to compose rather
27
+ * than to stack two charts on top of each other: two scales drawn over each
28
+ * other look like a comparison and are not one.
29
+ *
30
+ * ## Where your own marks go
31
+ *
32
+ * `Plot.Layer` drops its children into the SVG tree, and `usePlot()` gives them
33
+ * the resolved geometry — the box, the tweening domain, the reveal, the palette.
34
+ * The scale functions are worklets exported alongside this component, so a mark
35
+ * of your own is rebuilt on the UI thread on the same frames these are:
36
+ *
37
+ * ```tsx
38
+ * function Threshold({ value }: { value: number }) {
39
+ * const { plot, domainMin, domainMax } = usePlot();
40
+ * const props = useAnimatedProps(() => {
41
+ * const y = yOf(value, plot, domainMin.value, domainMax.value);
42
+ * return { d: `M${plot.left},${y}H${plot.left + plot.width}` };
43
+ * });
44
+ * return <AnimatedPath animatedProps={props} stroke="red" />;
45
+ * }
46
+ *
47
+ * <Plot data={rows}>
48
+ * <Plot.Layer><Threshold value={80} /></Plot.Layer>
49
+ * </Plot>
50
+ * ```
51
+ *
52
+ * Anything that is text or takes a touch goes in `Plot.Overlay` instead, which
53
+ * is a React Native view over the drawing. That split is not a preference: SVG
54
+ * text ignores the platform's text scaling and the theme's font, and a gesture
55
+ * handler cannot be attached to an SVG node at all.
56
+ *
57
+ * ## What it will not do for you
58
+ *
59
+ * There is no `type` prop and no set of defaults that guess at one. A chart
60
+ * assembled here is exactly the marks you wrote, in the order you wrote them —
61
+ * which is also the order they are drawn in, so a line over bars is a line
62
+ * written after them.
63
+ */
64
+ import {
65
+ Children,
66
+ createContext,
67
+ forwardRef,
68
+ isValidElement,
69
+ useContext,
70
+ useEffect,
71
+ useImperativeHandle,
72
+ useMemo,
73
+ useRef,
74
+ useState,
75
+ type ReactNode,
76
+ } from 'react';
77
+ import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
78
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
79
+ import Animated, {
80
+ Easing,
81
+ runOnJS,
82
+ useAnimatedProps,
83
+ useAnimatedStyle,
84
+ useReducedMotion,
85
+ useSharedValue,
86
+ withTiming,
87
+ type SharedValue,
88
+ } from 'react-native-reanimated';
89
+ import Svg, {
90
+ Circle,
91
+ ClipPath,
92
+ Defs,
93
+ G,
94
+ Line as SvgLine,
95
+ Path,
96
+ Rect,
97
+ } from 'react-native-svg';
98
+ import { useCSSVariable } from 'uniwind';
99
+ import { Text } from '../../primitives/text';
100
+ import {
101
+ areaPath,
102
+ bandOf,
103
+ barPath,
104
+ columnValues,
105
+ compactNumber,
106
+ linePath,
107
+ useSeriesColor,
108
+ xOf,
109
+ yOf,
110
+ type Plot as PlotBox,
111
+ } from '../../utils/chart';
112
+ import { cn } from '../../utils/cn';
113
+
114
+ /*
115
+ * The scale functions, re-exported so a mark written outside this file can sit
116
+ * on the same geometry the built-in ones do. Every one of them is a worklet, so
117
+ * a custom mark is rebuilt on the UI thread on the frames these are — which is
118
+ * the difference between an escape hatch and a second, slower chart drawn on
119
+ * top of the first.
120
+ */
121
+ export {
122
+ areaPath,
123
+ bandOf,
124
+ barPath,
125
+ compactNumber,
126
+ linePath,
127
+ segment,
128
+ xAt,
129
+ xOf,
130
+ yOf,
131
+ } from '../../utils/chart';
132
+ export type { Plot as PlotBox, ChartPoint } from '../../utils/chart';
133
+
134
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
135
+ const AnimatedRect = Animated.createAnimatedComponent(Rect);
136
+ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
137
+
138
+ /** Room left around the plot for the axis labels and the markers' rings. */
139
+ const PADDING = { top: 12, right: 10, bottom: 22, left: 10 };
140
+
141
+ /** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */
142
+ const Y_AXIS_WIDTH = 44;
143
+
144
+ /** Gap between the value labels and the plot they sit beside. */
145
+ const Y_AXIS_GUTTER = 6;
146
+
147
+ /** Line height of an `xs` label, for centring one on the rule it names. */
148
+ const AXIS_LABEL_HEIGHT = 16;
149
+
150
+ /** Box each x label is centred in; a longer one is ellipsised rather than shoving. */
151
+ const POINT_LABEL_WIDTH = 56;
152
+
153
+ /** Width of the readout that rides the cursor. */
154
+ const LABEL_WIDTH = 120;
155
+
156
+ /**
157
+ * Which layer a part belongs to. Read off the component itself, so composition
158
+ * stays a flat list of children instead of three nested slots whose order the
159
+ * caller has to remember.
160
+ */
161
+ type Layer = 'svg' | 'overlay' | 'header';
162
+
163
+ export type PlotStatus = 'loading' | 'ready';
164
+ export type PlotCurve = 'monotone' | 'linear';
165
+ export type PlotDatum = Record<string, string | number | null | undefined>;
166
+
167
+ /**
168
+ * How the index of a row becomes an x.
169
+ *
170
+ * `point` puts the first and last rows on the plot's own edges, which is what a
171
+ * series wants — the line should reach the frame. `band` gives every row an
172
+ * equal slice and centres it in the middle of that, which is what anything with
173
+ * width wants: a bar sitting on the edge of the plot is a bar half of which is
174
+ * outside it.
175
+ *
176
+ * Resolved from the marks by default, so a chart with bars in it is banded
177
+ * without being told.
178
+ */
179
+ export type PlotScale = 'point' | 'band';
180
+
181
+ /** One end of the y-domain: a number to pin it at, or `auto` to derive it. */
182
+ export type PlotBound = number | 'auto';
183
+
184
+ /** Everything a mark needs to draw itself. Read it with `usePlot()`. */
185
+ export interface PlotGeometry {
186
+ /** The rows, in order. */
187
+ data: PlotDatum[];
188
+ /** Key holding the x label. */
189
+ xDataKey: string;
190
+ /** The drawable box, after the padding and any axis gutter are taken off. */
191
+ plot: PlotBox;
192
+ /** How an index becomes an x. */
193
+ xScale: PlotScale;
194
+ /** `loading` draws nothing but the frame. */
195
+ status: PlotStatus;
196
+ /**
197
+ * The y-domain, tweened. Read these inside worklets — they are what makes a
198
+ * chart whose numbers changed redraw against a moving axis rather than jump.
199
+ */
200
+ domainMin: SharedValue<number>;
201
+ domainMax: SharedValue<number>;
202
+ /**
203
+ * The domain the tween is heading for. Labels read this rather than the shared
204
+ * values: a number re-rendered on every frame of a tween is thirty renders of
205
+ * a label that lands on the string it started on.
206
+ */
207
+ extent: [number, number];
208
+ /** `0` to `1` as the plot is uncovered on mount. */
209
+ reveal: SharedValue<number>;
210
+ /** The five theme series colours, in order of prominence. */
211
+ palette: string[];
212
+ /** Every mark that registered itself, as `[dataKey, colour]`. */
213
+ series: [string, string][];
214
+ registerSeries: (key: string, color: string) => void;
215
+ unregisterSeries: (key: string) => void;
216
+ /** Row under the cursor, or `-1`. On the UI thread. */
217
+ activeIndex: SharedValue<number>;
218
+ /** The same index on the JS thread, for anything that has to re-render. */
219
+ activeIndexJS: number;
220
+ setActiveIndexJS: (index: number) => void;
221
+ /** The clip every mark shares, so they are revealed as one drawing. */
222
+ clipId: string;
223
+ }
224
+
225
+ const PlotContext = createContext<PlotGeometry | null>(null);
226
+
227
+ function useChart(component: string): PlotGeometry {
228
+ const context = useContext(PlotContext);
229
+ if (!context) {
230
+ throw new Error(`${component} must be used within a <Plot>`);
231
+ }
232
+ return context;
233
+ }
234
+
235
+ /**
236
+ * The resolved geometry, for a mark of your own.
237
+ *
238
+ * Everything in it is either a plain number that changes on layout or a shared
239
+ * value that changes every frame, so a mark built from it animates with the
240
+ * rest of the chart rather than beside it.
241
+ */
242
+ export function usePlot(): PlotGeometry {
243
+ return useChart('usePlot');
244
+ }
245
+
246
+ /** The row under the cursor, for a readout drawn inside the plot. */
247
+ export function usePlotCursor(): { activeIndex: number; activePoint: PlotDatum | null } {
248
+ const { data, activeIndexJS } = useChart('usePlotCursor');
249
+ return {
250
+ activeIndex: activeIndexJS,
251
+ activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,
252
+ };
253
+ }
254
+
255
+ export interface PlotProps extends ViewProps {
256
+ className?: string;
257
+ /** The rows. Each one is a position along the x-axis. */
258
+ data: PlotDatum[];
259
+ /** Key holding the x label. Used by the axis and the readout. */
260
+ xDataKey?: string;
261
+ /**
262
+ * `loading` draws the frame and nothing in it, and reveals the marks when it
263
+ * turns `ready`. One component throughout rather than a spinner swapped for a
264
+ * chart — swapping loses the transition.
265
+ */
266
+ status?: PlotStatus;
267
+ /** Width ÷ height. `2` is the wide card shape; `1.6` suits a narrow column. */
268
+ aspectRatio?: number;
269
+ /** Milliseconds for the plot to be uncovered on mount. */
270
+ animationDuration?: number;
271
+ /** Milliseconds for the y-axis to settle after the data changes. */
272
+ domainDuration?: number;
273
+ /**
274
+ * The y-domain, as `[low, high]`. Either end may be a number to pin it there
275
+ * or `auto` to take it from the data.
276
+ *
277
+ * Pinning one end is the case this exists for: `[0, 'auto']` keeps the
278
+ * baseline at zero, which a chart of lengths needs — a bar cropped at the
279
+ * bottom is a length that lies — while still letting the top follow whatever
280
+ * arrives.
281
+ */
282
+ yDomain?: [PlotBound, PlotBound];
283
+ /**
284
+ * How an index becomes an x. Derived from the marks when left out: a plot
285
+ * with bars in it is banded, and anything else is on points.
286
+ */
287
+ xScale?: PlotScale;
288
+ /** How series are joined between points, unless a mark overrides it. */
289
+ curve?: PlotCurve;
290
+ /**
291
+ * The row under the cursor as it moves, and `-1`/`null` when the finger
292
+ * lifts. This is how a readout *outside* the plot gets its value — that
293
+ * header is not inside this provider, so it cannot use `usePlotCursor`.
294
+ */
295
+ onActiveIndexChange?: (index: number, datum: PlotDatum | null) => void;
296
+ /**
297
+ * Drop the padding so the marks reach the edges — for a plot with no axis,
298
+ * grid or cursor, where the shape is the whole point.
299
+ */
300
+ compact?: boolean;
301
+ children?: ReactNode;
302
+ }
303
+
304
+ /** Imperative handle: re-run the reveal, for a "replay" control. */
305
+ export interface PlotHandle {
306
+ replay: () => void;
307
+ }
308
+
309
+ const PlotRoot = forwardRef<PlotHandle, PlotProps>(function PlotRoot(
310
+ {
311
+ className,
312
+ data,
313
+ xDataKey = 'label',
314
+ status = 'ready',
315
+ aspectRatio = 2,
316
+ animationDuration = 700,
317
+ domainDuration = 500,
318
+ yDomain,
319
+ xScale: xScaleProp,
320
+ curve = 'monotone',
321
+ onActiveIndexChange,
322
+ compact = false,
323
+ children,
324
+ ...props
325
+ },
326
+ ref
327
+ ) {
328
+ const [size, setSize] = useState({ width: 0, height: 0 });
329
+ const [series, setSeries] = useState<[string, string][]>([]);
330
+ const [activeIndexJS, setActiveIndexJS] = useState(-1);
331
+ const clipId = useRef(`panelui-plot-${Math.random().toString(36).slice(2, 9)}`).current;
332
+
333
+ const reveal = useSharedValue(0);
334
+ const domainMin = useSharedValue(0);
335
+ const domainMax = useSharedValue(0);
336
+ const activeIndex = useSharedValue(-1);
337
+ const reducedMotion = useReducedMotion();
338
+
339
+ const registerSeries = useMemo(
340
+ () => (key: string, color: string) =>
341
+ setSeries((current) => {
342
+ const existing = current.find(([k]) => k === key);
343
+ if (existing?.[1] === color) return current;
344
+ return [...current.filter(([k]) => k !== key), [key, color]];
345
+ }),
346
+ []
347
+ );
348
+
349
+ const unregisterSeries = useMemo(
350
+ () => (key: string) => setSeries((current) => current.filter(([k]) => k !== key)),
351
+ []
352
+ );
353
+
354
+ /*
355
+ * What the children need decided before anything is laid out.
356
+ *
357
+ * Both have to be known *before* the plot box exists, and neither can be
358
+ * asked of the parts themselves — a part renders into a box that has already
359
+ * been decided. An axis given no gutter is drawn over the marks; a bar placed
360
+ * on a point scale hangs half of itself off the frame, and a bar on an axis
361
+ * that skips zero is drawn at a length the data does not support.
362
+ */
363
+ const { hasYAxis, hasBars } = useMemo(() => {
364
+ let axis = false;
365
+ let bars = false;
366
+ Children.forEach(children, (child) => {
367
+ if (!isValidElement(child)) return;
368
+ const type = child.type as { axis?: string; mark?: string };
369
+ if (type.axis === 'y') axis = true;
370
+ if (type.mark === 'band') bars = true;
371
+ });
372
+ return { hasYAxis: axis, hasBars: bars };
373
+ }, [children]);
374
+
375
+ const xScale: PlotScale = xScaleProp ?? (hasBars ? 'band' : 'point');
376
+
377
+ const pad = compact
378
+ ? { top: 2, right: 1, bottom: 2, left: 1 }
379
+ : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };
380
+ const plot: PlotBox = {
381
+ left: pad.left,
382
+ top: pad.top,
383
+ width: Math.max(size.width - pad.left - pad.right, 0),
384
+ height: Math.max(size.height - pad.top - pad.bottom, 0),
385
+ };
386
+
387
+ /*
388
+ * One extent across every registered mark, so two series share one axis and
389
+ * stay comparable. A scale per series makes two quantities orders of
390
+ * magnitude apart look alike, which is the chart lying rather than the chart
391
+ * being convenient.
392
+ */
393
+ const seriesKeys = series.map(([key]) => key).join('|');
394
+ const extent = useMemo<[number, number]>(() => {
395
+ const lowPin = yDomain?.[0];
396
+ const highPin = yDomain?.[1];
397
+ if (typeof lowPin === 'number' && typeof highPin === 'number') {
398
+ return [lowPin, highPin];
399
+ }
400
+
401
+ const keys = seriesKeys ? seriesKeys.split('|') : [];
402
+ let min = Infinity;
403
+ let max = -Infinity;
404
+ for (const row of data) {
405
+ for (const key of keys) {
406
+ const value = row[key];
407
+ if (typeof value !== 'number' || Number.isNaN(value)) continue;
408
+ if (value < min) min = value;
409
+ if (value > max) max = value;
410
+ }
411
+ }
412
+
413
+ if (min === Infinity) {
414
+ // Nothing to measure. Fall back to whatever was pinned, and to a unit
415
+ // domain when nothing was — dividing by a span of zero is how a mark
416
+ // ends up drawn at infinity.
417
+ const low = typeof lowPin === 'number' ? lowPin : 0;
418
+ const high = typeof highPin === 'number' ? highPin : low + 1;
419
+ return [low, high];
420
+ }
421
+
422
+ /*
423
+ * A bar's length is measured from zero, so an axis that does not contain
424
+ * zero makes every bar on it a lie about its own size — six columns of
425
+ * near-identical height standing for numbers that differ by half. So a plot
426
+ * with bars in it gets zero whether the data reaches it or not, and that end
427
+ * is then left exactly where it is: headroom under a baseline lifts the bars
428
+ * off the thing they are measured from.
429
+ */
430
+ let lowRoom = 1;
431
+ let highRoom = 1;
432
+ if (hasBars) {
433
+ if (min > 0) min = 0;
434
+ if (max < 0) max = 0;
435
+ if (min === 0) lowRoom = 0;
436
+ if (max === 0) highRoom = 0;
437
+ }
438
+
439
+ // A flat series has no extent of its own; give it one so it lands on the
440
+ // middle of the plot instead of dividing by zero.
441
+ if (min === max) {
442
+ min -= 1;
443
+ max += 1;
444
+ }
445
+
446
+ /*
447
+ * Headroom goes on the derived ends only. A pinned zero that got a tenth of
448
+ * the span subtracted from it is not a pinned zero, and the baseline
449
+ * drifting off the axis is exactly what pinning it was for.
450
+ */
451
+ const headroom = (max - min) * 0.1;
452
+ return [
453
+ typeof lowPin === 'number' ? lowPin : min - headroom * lowRoom,
454
+ typeof highPin === 'number' ? highPin : max + headroom * highRoom,
455
+ ];
456
+ }, [data, yDomain, seriesKeys, hasBars]);
457
+
458
+ const loading = status === 'loading';
459
+ // Nothing has said what the axis is yet — no mark has registered and no
460
+ // domain was given. Assigning one now would tween the real domain up out of a
461
+ // placeholder on the frame the first mark mounts.
462
+ const hasDomain = seriesKeys.length > 0 || yDomain !== undefined;
463
+
464
+ useEffect(() => {
465
+ if (loading || !hasDomain) return;
466
+ const [min, max] = extent;
467
+ // The first domain lands without a tween: there is no previous scale to
468
+ // move from, and animating up from zero reads as the numbers changing.
469
+ const first = domainMin.value === 0 && domainMax.value === 0;
470
+ if (first || reducedMotion) {
471
+ domainMin.value = min;
472
+ domainMax.value = max;
473
+ return;
474
+ }
475
+ domainMin.value = withTiming(min, { duration: domainDuration });
476
+ domainMax.value = withTiming(max, { duration: domainDuration });
477
+ }, [extent, loading, hasDomain, reducedMotion, domainDuration, domainMin, domainMax]);
478
+
479
+ const revealed = useRef(false);
480
+ const playReveal = useMemo(
481
+ () => () => {
482
+ if (reducedMotion) {
483
+ reveal.value = 1;
484
+ return;
485
+ }
486
+ reveal.value = 0;
487
+ reveal.value = withTiming(1, {
488
+ duration: animationDuration,
489
+ easing: Easing.out(Easing.cubic),
490
+ });
491
+ },
492
+ [reducedMotion, animationDuration, reveal]
493
+ );
494
+
495
+ useEffect(() => {
496
+ // Going back to `loading` arms the reveal again, so a refetch is uncovered
497
+ // rather than appearing whole on the frame the data lands.
498
+ if (loading) {
499
+ revealed.current = false;
500
+ reveal.value = 0;
501
+ return;
502
+ }
503
+ if (revealed.current || plot.width <= 0 || !data.length) return;
504
+ revealed.current = true;
505
+ playReveal();
506
+ }, [loading, plot.width, data.length, playReveal, reveal]);
507
+
508
+ useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
509
+
510
+ const c1 = useSeriesColor(undefined, 1);
511
+ const c2 = useSeriesColor(undefined, 2);
512
+ const c3 = useSeriesColor(undefined, 3);
513
+ const c4 = useSeriesColor(undefined, 4);
514
+ const c5 = useSeriesColor(undefined, 5);
515
+ const palette = useMemo(() => [c1, c2, c3, c4, c5], [c1, c2, c3, c4, c5]);
516
+
517
+ const handleActiveIndex = useMemo(
518
+ () => (index: number) => {
519
+ setActiveIndexJS(index);
520
+ onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);
521
+ },
522
+ [onActiveIndexChange, data]
523
+ );
524
+
525
+ const onLayout = (event: LayoutChangeEvent) => {
526
+ const { width, height } = event.nativeEvent.layout;
527
+ setSize((current) =>
528
+ Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
529
+ ? current
530
+ : { width, height }
531
+ );
532
+ props.onLayout?.(event);
533
+ };
534
+
535
+ const context = useMemo<PlotGeometry>(
536
+ () => ({
537
+ data,
538
+ xDataKey,
539
+ plot,
540
+ xScale,
541
+ status,
542
+ domainMin,
543
+ domainMax,
544
+ extent,
545
+ reveal,
546
+ palette,
547
+ series,
548
+ registerSeries,
549
+ unregisterSeries,
550
+ activeIndex,
551
+ activeIndexJS,
552
+ setActiveIndexJS: handleActiveIndex,
553
+ clipId,
554
+ }),
555
+ // `plot` is rebuilt every render from `size`, so it is compared by value.
556
+ // eslint-disable-next-line react-hooks/exhaustive-deps
557
+ [
558
+ data,
559
+ xDataKey,
560
+ plot.width,
561
+ plot.height,
562
+ plot.left,
563
+ plot.top,
564
+ xScale,
565
+ status,
566
+ domainMin,
567
+ domainMax,
568
+ extent,
569
+ reveal,
570
+ palette,
571
+ series,
572
+ registerSeries,
573
+ unregisterSeries,
574
+ activeIndex,
575
+ activeIndexJS,
576
+ handleActiveIndex,
577
+ clipId,
578
+ ]
579
+ );
580
+
581
+ const clipProps = useAnimatedProps(() => ({ width: plot.width * reveal.value }));
582
+
583
+ const { svg, overlay, header } = partition(children);
584
+
585
+ /*
586
+ * Two views, because the header is not part of the plot. `aspectRatio` and
587
+ * the layout measurement belong to the drawing area alone — measured on the
588
+ * outer view they would take the header in too, and the plot would lose as
589
+ * much height as the readout took while still claiming the shape asked for.
590
+ */
591
+ return (
592
+ <PlotContext.Provider value={context}>
593
+ <View {...props} style={props.style} className={cn('w-full', className)}>
594
+ {header}
595
+ <View onLayout={onLayout} style={{ aspectRatio }} className="w-full">
596
+ {plot.width > 0 ? (
597
+ <>
598
+ <Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
599
+ <Defs>
600
+ {/*
601
+ * One clip for every mark. Sharing it is what makes the reveal
602
+ * read as the chart arriving, rather than as four separate
603
+ * things animating in at once — which is the thing a composed
604
+ * chart is most at risk of looking like.
605
+ */}
606
+ <ClipPath id={clipId}>
607
+ <AnimatedRect
608
+ x={plot.left}
609
+ y={0}
610
+ height={size.height}
611
+ animatedProps={clipProps}
612
+ />
613
+ </ClipPath>
614
+ </Defs>
615
+ {svg}
616
+ </Svg>
617
+ {overlay}
618
+ </>
619
+ ) : null}
620
+ </View>
621
+ </View>
622
+ </PlotContext.Provider>
623
+ );
624
+ });
625
+ PlotRoot.displayName = 'Plot';
626
+
627
+ /** Sorts the children into the SVG tree, the view layer over it, and the header. */
628
+ function partition(children: ReactNode) {
629
+ const svg: ReactNode[] = [];
630
+ const overlay: ReactNode[] = [];
631
+ const header: ReactNode[] = [];
632
+
633
+ Children.forEach(children, (child, index) => {
634
+ if (!isValidElement(child)) return;
635
+ const layer = (child.type as { layer?: Layer }).layer ?? 'svg';
636
+ (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(
637
+ // Children of a `Children.forEach` need keys of their own once they are
638
+ // put into a new array.
639
+ <ChildSlot key={index}>{child}</ChildSlot>
640
+ );
641
+ });
642
+
643
+ return { svg, overlay, header };
644
+ }
645
+
646
+ /** Identity wrapper, purely so the partitioned arrays can carry keys. */
647
+ function ChildSlot({ children }: { children: ReactNode }) {
648
+ return <>{children}</>;
649
+ }
650
+
651
+ /**
652
+ * A mark's colour: the one it was given, else the theme token at its index.
653
+ *
654
+ * Registration happens here rather than in each mark, because every mark that
655
+ * registers does it for the same two reasons — the domain has to take it in,
656
+ * and the legend has to name it.
657
+ */
658
+ function useMark(dataKey: string, color: string | undefined, colorIndex: number) {
659
+ const { palette, registerSeries, unregisterSeries } = useChart('Plot mark');
660
+ const resolved = color ?? palette[(colorIndex - 1) % palette.length] ?? palette[0]!;
661
+
662
+ useEffect(() => {
663
+ registerSeries(dataKey, resolved);
664
+ return () => unregisterSeries(dataKey);
665
+ }, [dataKey, resolved, registerSeries, unregisterSeries]);
666
+
667
+ return resolved;
668
+ }
669
+
670
+ /* -------------------------------------------------------------------------- */
671
+ /* SVG layer */
672
+ /* -------------------------------------------------------------------------- */
673
+
674
+ export interface PlotGridProps {
675
+ /** Horizontal rules across the plot. */
676
+ rows?: number;
677
+ color?: string;
678
+ /** Dash pattern, e.g. `"4,6"`. Omit for a solid rule. */
679
+ dashArray?: string;
680
+ opacity?: number;
681
+ }
682
+
683
+ /**
684
+ * Horizontal reference lines.
685
+ *
686
+ * Outside the reveal clip on purpose: the grid is the frame the chart arrives
687
+ * into, so it is already there when the marks start being uncovered.
688
+ */
689
+ function PlotGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: PlotGridProps) {
690
+ const { plot } = useChart('Plot.Grid');
691
+ const token = useCSSVariable('--color-border');
692
+ const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
693
+
694
+ return (
695
+ <G opacity={opacity}>
696
+ {Array.from({ length: rows + 1 }, (_, index) => {
697
+ const y = plot.top + (plot.height / rows) * index;
698
+ return (
699
+ <SvgLine
700
+ key={index}
701
+ x1={plot.left}
702
+ x2={plot.left + plot.width}
703
+ y1={y}
704
+ y2={y}
705
+ stroke={stroke}
706
+ strokeWidth={1}
707
+ strokeDasharray={dashArray}
708
+ />
709
+ );
710
+ })}
711
+ </G>
712
+ );
713
+ }
714
+ PlotGrid.displayName = 'Plot.Grid';
715
+ PlotGrid.layer = 'svg' as Layer;
716
+
717
+ export interface PlotSeriesProps {
718
+ /** Column of `data` this mark draws. */
719
+ dataKey: string;
720
+ /** Overrides the theme token. */
721
+ color?: string;
722
+ /** Which `--color-chart-*` token to take, `1` to `5`. */
723
+ colorIndex?: number;
724
+ }
725
+
726
+ export interface PlotLineProps extends PlotSeriesProps {
727
+ strokeWidth?: number;
728
+ /** `monotone` never overshoots between points; `linear` joins them straight. */
729
+ curve?: PlotCurve;
730
+ /** Dash pattern, e.g. `"6,4"` — for a forecast, or a series that is not real. */
731
+ dashArray?: string;
732
+ }
733
+
734
+ /** A series as a stroked line. */
735
+ function PlotLine({
736
+ dataKey,
737
+ color,
738
+ colorIndex = 1,
739
+ strokeWidth = 2.5,
740
+ curve = 'monotone',
741
+ dashArray,
742
+ }: PlotLineProps) {
743
+ const { data, plot, xScale, domainMin, domainMax, status, clipId } =
744
+ useChart('Plot.Line');
745
+ const stroke = useMark(dataKey, color, colorIndex);
746
+
747
+ /*
748
+ * Pulled out of the worklet as a plain array. A worklet may close over numbers
749
+ * and arrays of them freely, but reading `data`'s rows — objects of mixed
750
+ * types, keyed by strings the caller chose — inside one on every frame is work
751
+ * that belongs on the JS side once.
752
+ */
753
+ const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);
754
+ const banded = xScale === 'band';
755
+
756
+ const animatedProps = useAnimatedProps(() => {
757
+ const min = domainMin.value;
758
+ const max = domainMax.value;
759
+ if (max === min || plot.width <= 0) return { d: '' };
760
+
761
+ if (!banded) return { d: linePath(values, plot, min, max, curve, false) };
762
+
763
+ /*
764
+ * A banded line is built here rather than through `linePath`, which spreads
765
+ * its points across the full width. On a band scale the points belong in
766
+ * the middle of their own slice — a line that ignored that would start half
767
+ * a slice left of the bar it is describing.
768
+ */
769
+ let d = '';
770
+ let drawn = 0;
771
+ for (let index = 0; index < values.length; index += 1) {
772
+ const value = values[index];
773
+ if (value === null || value === undefined) {
774
+ drawn = 0;
775
+ continue;
776
+ }
777
+ const x = bandOf(index, values.length, plot);
778
+ const y = yOf(value, plot, min, max);
779
+ d += `${drawn === 0 ? 'M' : 'L'}${x},${y}`;
780
+ drawn += 1;
781
+ }
782
+ return { d };
783
+ });
784
+
785
+ if (status === 'loading') return null;
786
+
787
+ return (
788
+ <G clipPath={`url(#${clipId})`}>
789
+ <AnimatedPath
790
+ animatedProps={animatedProps}
791
+ fill="none"
792
+ stroke={stroke}
793
+ strokeWidth={strokeWidth}
794
+ strokeDasharray={dashArray}
795
+ strokeLinecap="round"
796
+ strokeLinejoin="round"
797
+ />
798
+ </G>
799
+ );
800
+ }
801
+ PlotLine.displayName = 'Plot.Line';
802
+ PlotLine.layer = 'svg' as Layer;
803
+
804
+ export interface PlotAreaProps extends PlotSeriesProps {
805
+ opacity?: number;
806
+ curve?: PlotCurve;
807
+ }
808
+
809
+ /**
810
+ * A series as a fill down to the baseline.
811
+ *
812
+ * Written before the line it belongs under, since the order the marks are
813
+ * written is the order they are drawn.
814
+ */
815
+ function PlotArea({
816
+ dataKey,
817
+ color,
818
+ colorIndex = 1,
819
+ opacity = 0.18,
820
+ curve = 'monotone',
821
+ }: PlotAreaProps) {
822
+ const { data, plot, domainMin, domainMax, status, clipId } = useChart('Plot.Area');
823
+ const fill = useMark(dataKey, color, colorIndex);
824
+ const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);
825
+
826
+ const animatedProps = useAnimatedProps(() => {
827
+ const min = domainMin.value;
828
+ const max = domainMax.value;
829
+ if (max === min || plot.width <= 0) return { d: '' };
830
+ return { d: areaPath(values, plot, min, max, curve, false) };
831
+ });
832
+
833
+ if (status === 'loading') return null;
834
+
835
+ return (
836
+ <G clipPath={`url(#${clipId})`}>
837
+ <AnimatedPath animatedProps={animatedProps} fill={fill} fillOpacity={opacity} />
838
+ </G>
839
+ );
840
+ }
841
+ PlotArea.displayName = 'Plot.Area';
842
+ PlotArea.layer = 'svg' as Layer;
843
+
844
+ export interface PlotBarsProps extends PlotSeriesProps {
845
+ /** Fraction of each slice left empty, `0` to `1`. */
846
+ gap?: number;
847
+ /** Rounds the end the bar grows towards, in points. */
848
+ radius?: number;
849
+ opacity?: number;
850
+ }
851
+
852
+ /**
853
+ * A series as columns.
854
+ *
855
+ * One path for all of them rather than one node per bar: a plot of two hundred
856
+ * periods is the same single animated prop a frame as a plot of twenty.
857
+ *
858
+ * A bar has width, so its presence puts the whole plot on a band scale unless
859
+ * the root was told otherwise — see `xScale`.
860
+ */
861
+ function PlotBars({
862
+ dataKey,
863
+ color,
864
+ colorIndex = 1,
865
+ gap = 0.35,
866
+ radius = 4,
867
+ opacity = 1,
868
+ }: PlotBarsProps) {
869
+ const { data, plot, domainMin, domainMax, status, clipId } = useChart('Plot.Bars');
870
+ const fill = useMark(dataKey, color, colorIndex);
871
+ const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);
872
+
873
+ const total = values.length;
874
+ const slice = total > 0 ? plot.width / total : 0;
875
+ const width = Math.max(1, slice * (1 - Math.min(Math.max(gap, 0), 0.95)));
876
+
877
+ const animatedProps = useAnimatedProps(() => {
878
+ const min = domainMin.value;
879
+ const max = domainMax.value;
880
+ if (max === min || plot.width <= 0 || total === 0) return { d: '' };
881
+
882
+ /*
883
+ * Bars grow from the baseline, which is the domain's zero where the domain
884
+ * covers it and the nearer edge where it does not. A bar drawn from the
885
+ * bottom of a plot whose axis starts at 40 is a length that is not in the
886
+ * data.
887
+ */
888
+ const base = yOf(Math.min(Math.max(0, min), max), plot, min, max);
889
+ let path = '';
890
+
891
+ for (let index = 0; index < total; index += 1) {
892
+ const value = values[index];
893
+ if (value === null || value === undefined) continue;
894
+ const y = yOf(value, plot, min, max);
895
+ const top = Math.min(y, base);
896
+ const height = Math.abs(base - y);
897
+ if (height <= 0) continue;
898
+ path += barPath(
899
+ bandOf(index, total, plot) - width / 2,
900
+ top,
901
+ width,
902
+ height,
903
+ radius,
904
+ y <= base ? 'up' : 'down'
905
+ );
906
+ }
907
+ return { d: path };
908
+ });
909
+
910
+ if (status === 'loading') return null;
911
+
912
+ return (
913
+ <G clipPath={`url(#${clipId})`}>
914
+ <AnimatedPath animatedProps={animatedProps} fill={fill} fillOpacity={opacity} />
915
+ </G>
916
+ );
917
+ }
918
+ PlotBars.displayName = 'Plot.Bars';
919
+ PlotBars.layer = 'svg' as Layer;
920
+ // Read by the root before it resolves the scale: a mark with width cannot sit
921
+ // on a point scale without half of the first and last one leaving the plot.
922
+ PlotBars.mark = 'band' as const;
923
+
924
+ export interface PlotDotsProps extends PlotSeriesProps {
925
+ /** Radius, in points. */
926
+ size?: number;
927
+ /** Ring around each dot, so it reads on top of the line rather than in it. */
928
+ ringWidth?: number;
929
+ }
930
+
931
+ /** A dot per row — for a series short enough that its points are worth marking. */
932
+ function PlotDots({
933
+ dataKey,
934
+ color,
935
+ colorIndex = 1,
936
+ size = 3.5,
937
+ ringWidth = 2,
938
+ }: PlotDotsProps) {
939
+ const { data, plot, xScale, domainMin, domainMax, status, clipId } =
940
+ useChart('Plot.Dots');
941
+ const fill = useMark(dataKey, color, colorIndex);
942
+ const ringToken = useCSSVariable('--color-background');
943
+ const ring = typeof ringToken === 'string' ? ringToken : '#000000';
944
+ const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);
945
+ const banded = xScale === 'band';
946
+
947
+ return (
948
+ <G clipPath={`url(#${clipId})`}>
949
+ {status === 'loading'
950
+ ? null
951
+ : values.map((value, index) =>
952
+ value === null || value === undefined ? null : (
953
+ <Dot
954
+ key={index}
955
+ value={value}
956
+ x={
957
+ banded
958
+ ? bandOf(index, values.length, plot)
959
+ : xOf(index, values.length, plot)
960
+ }
961
+ plot={plot}
962
+ domainMin={domainMin}
963
+ domainMax={domainMax}
964
+ fill={fill}
965
+ ring={ring}
966
+ size={size}
967
+ ringWidth={ringWidth}
968
+ />
969
+ )
970
+ )}
971
+ </G>
972
+ );
973
+ }
974
+ PlotDots.displayName = 'Plot.Dots';
975
+ PlotDots.layer = 'svg' as Layer;
976
+
977
+ /** One dot, whose y follows the domain tween. */
978
+ function Dot({
979
+ value,
980
+ x,
981
+ plot,
982
+ domainMin,
983
+ domainMax,
984
+ fill,
985
+ ring,
986
+ size,
987
+ ringWidth,
988
+ }: {
989
+ value: number;
990
+ x: number;
991
+ plot: PlotBox;
992
+ domainMin: SharedValue<number>;
993
+ domainMax: SharedValue<number>;
994
+ fill: string;
995
+ ring: string;
996
+ size: number;
997
+ ringWidth: number;
998
+ }) {
999
+ const animatedProps = useAnimatedProps(() => ({
1000
+ cy: yOf(value, plot, domainMin.value, domainMax.value),
1001
+ }));
1002
+
1003
+ return (
1004
+ <AnimatedCircle
1005
+ animatedProps={animatedProps}
1006
+ cx={x}
1007
+ r={size}
1008
+ fill={fill}
1009
+ stroke={ring}
1010
+ strokeWidth={ringWidth}
1011
+ />
1012
+ );
1013
+ }
1014
+
1015
+ export interface PlotLayerProps {
1016
+ children?: ReactNode;
1017
+ }
1018
+
1019
+ /**
1020
+ * Marks of your own, in the SVG tree.
1021
+ *
1022
+ * Whatever is inside is drawn where it is written — before the marks written
1023
+ * after it, after the ones before it — and reaches the geometry through
1024
+ * `usePlot()`. It is not given the geometry as an argument, because a mark that
1025
+ * animates has to hold hooks of its own and a render prop is not a component.
1026
+ */
1027
+ function PlotLayer({ children }: PlotLayerProps) {
1028
+ const { clipId } = useChart('Plot.Layer');
1029
+ return <G clipPath={`url(#${clipId})`}>{children}</G>;
1030
+ }
1031
+ PlotLayer.displayName = 'Plot.Layer';
1032
+ PlotLayer.layer = 'svg' as Layer;
1033
+
1034
+ /* -------------------------------------------------------------------------- */
1035
+ /* Overlay layer */
1036
+ /* -------------------------------------------------------------------------- */
1037
+
1038
+ export interface PlotOverlayProps {
1039
+ className?: string;
1040
+ children?: ReactNode;
1041
+ }
1042
+
1043
+ /**
1044
+ * Anything of your own that is text or takes a touch, laid over the drawing.
1045
+ *
1046
+ * SVG text ignores the platform's text scaling and the theme's font, and a
1047
+ * gesture handler cannot be attached to an SVG node at all — so those two go
1048
+ * here rather than in `Plot.Layer`.
1049
+ */
1050
+ function PlotOverlay({ className, children }: PlotOverlayProps) {
1051
+ return (
1052
+ <View pointerEvents="box-none" style={StyleSheet.absoluteFill} className={className}>
1053
+ {children}
1054
+ </View>
1055
+ );
1056
+ }
1057
+ PlotOverlay.displayName = 'Plot.Overlay';
1058
+ PlotOverlay.layer = 'overlay' as Layer;
1059
+
1060
+ export interface PlotRuleProps {
1061
+ /** Where to draw it, in the data's own units. */
1062
+ y: number;
1063
+ /** A name for what the line means. Nothing is drawn without one. */
1064
+ label?: string;
1065
+ color?: string;
1066
+ className?: string;
1067
+ }
1068
+
1069
+ /**
1070
+ * A reference line across the plot — a target, a limit, an average.
1071
+ *
1072
+ * A view rather than an SVG line, so its label is real text and follows the
1073
+ * theme. Its height is one point and its position is a transform, so it costs
1074
+ * no more than the SVG line would while gaining a legible caption.
1075
+ */
1076
+ function PlotRule({ y, label, color, className }: PlotRuleProps) {
1077
+ const { plot, domainMin, domainMax, status } = useChart('Plot.Rule');
1078
+ const token = useCSSVariable('--color-muted-foreground');
1079
+ const stroke = color ?? (typeof token === 'string' ? token : '#888888');
1080
+
1081
+ const style = useAnimatedStyle(() => {
1082
+ const min = domainMin.value;
1083
+ const max = domainMax.value;
1084
+ if (max === min) return { opacity: 0 };
1085
+ const at = yOf(y, plot, min, max);
1086
+ // Off the top or bottom of the plot is out of the chart's range, and a rule
1087
+ // pinned to the edge would claim a value the axis does not cover.
1088
+ const inside = at >= plot.top - 0.5 && at <= plot.top + plot.height + 0.5;
1089
+ return { opacity: inside ? 1 : 0, transform: [{ translateY: at }] };
1090
+ });
1091
+
1092
+ if (status === 'loading') return null;
1093
+
1094
+ return (
1095
+ <Animated.View
1096
+ pointerEvents="none"
1097
+ style={[
1098
+ { position: 'absolute', left: plot.left, width: plot.width, top: 0 },
1099
+ style,
1100
+ ]}
1101
+ className={className}
1102
+ >
1103
+ <View style={{ height: 1, backgroundColor: stroke, opacity: 0.5 }} />
1104
+ {label ? (
1105
+ <Text size="xs" muted numberOfLines={1} className="self-end pt-0.5">
1106
+ {label}
1107
+ </Text>
1108
+ ) : null}
1109
+ </Animated.View>
1110
+ );
1111
+ }
1112
+ PlotRule.displayName = 'Plot.Rule';
1113
+ PlotRule.layer = 'overlay' as Layer;
1114
+
1115
+ export interface PlotXAxisProps {
1116
+ /** How many labels to show. The rest are dropped, evenly. */
1117
+ ticks?: number;
1118
+ /** Turn a row into its label. Defaults to the value at `xDataKey`. */
1119
+ format?: (datum: PlotDatum, index: number) => string;
1120
+ className?: string;
1121
+ }
1122
+
1123
+ /**
1124
+ * The x labels. Real text rather than SVG text, so they follow the theme's font
1125
+ * and the platform's text scaling — SVG text does neither.
1126
+ */
1127
+ function PlotXAxis({ ticks = 4, format, className }: PlotXAxisProps) {
1128
+ const { data, xDataKey, plot, xScale } = useChart('Plot.XAxis');
1129
+
1130
+ const labels = useMemo(() => {
1131
+ if (!data.length) return [];
1132
+ const count = Math.min(ticks, data.length);
1133
+ const step = count > 1 ? (data.length - 1) / (count - 1) : 0;
1134
+ return Array.from({ length: count }, (_, index) => {
1135
+ const dataIndex = Math.round(index * step);
1136
+ const datum = data[dataIndex];
1137
+ if (!datum) return null;
1138
+ return {
1139
+ key: dataIndex,
1140
+ text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),
1141
+ };
1142
+ }).filter((label): label is { key: number; text: string } => label !== null);
1143
+ }, [data, ticks, format, xDataKey]);
1144
+
1145
+ /*
1146
+ * Each label sits on its own point rather than being spread along the axis.
1147
+ * The indices `ticks` picks are not evenly spaced — twelve months shown five
1148
+ * at a time gives 0, 3, 6, 8, 11 — so spreading them evenly put the ones in
1149
+ * between over the wrong part of the plot.
1150
+ */
1151
+ return (
1152
+ <View
1153
+ style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
1154
+ className={cn(className)}
1155
+ >
1156
+ {labels.map((label) => {
1157
+ const x =
1158
+ xScale === 'band'
1159
+ ? bandOf(label.key, data.length, plot)
1160
+ : xOf(label.key, data.length, plot);
1161
+ return (
1162
+ <Text
1163
+ key={label.key}
1164
+ size="xs"
1165
+ muted
1166
+ numberOfLines={1}
1167
+ style={{
1168
+ position: 'absolute',
1169
+ bottom: 0,
1170
+ // Centred on its point, then held inside the chart. On a point
1171
+ // scale the first and last sit on the plot's own edges, so a box
1172
+ // centred on them hangs half its width off the side.
1173
+ left: Math.max(
1174
+ 0,
1175
+ Math.min(
1176
+ x - POINT_LABEL_WIDTH / 2,
1177
+ plot.left + plot.width + PADDING.right - POINT_LABEL_WIDTH
1178
+ )
1179
+ ),
1180
+ width: POINT_LABEL_WIDTH,
1181
+ textAlign: 'center',
1182
+ }}
1183
+ >
1184
+ {label.text}
1185
+ </Text>
1186
+ );
1187
+ })}
1188
+ </View>
1189
+ );
1190
+ }
1191
+ PlotXAxis.displayName = 'Plot.XAxis';
1192
+ PlotXAxis.layer = 'overlay' as Layer;
1193
+
1194
+ export interface PlotYAxisProps {
1195
+ /** How many intervals to divide the axis into. Yields `ticks + 1` labels. */
1196
+ ticks?: number;
1197
+ /** Turn a value into its label. Defaults to a compact number. */
1198
+ format?: (value: number) => string;
1199
+ className?: string;
1200
+ }
1201
+
1202
+ /**
1203
+ * Value labels down the side, one per grid line.
1204
+ *
1205
+ * Give it the same `ticks` as the grid, or the numbers name lines that are not
1206
+ * there. Four is the default on both for that reason.
1207
+ *
1208
+ * The labels are the domain the data settles at, not the tweening one — a
1209
+ * number counting through every intermediate value while the axis animates is
1210
+ * noise, and the axis is the part of a chart that has to hold still enough to
1211
+ * be read.
1212
+ */
1213
+ function PlotYAxis({ ticks = 4, format, className }: PlotYAxisProps) {
1214
+ const { plot, extent } = useChart('Plot.YAxis');
1215
+
1216
+ const labels = useMemo(() => {
1217
+ const [min, max] = extent;
1218
+ if (min === 0 && max === 0) return [];
1219
+ return Array.from({ length: ticks + 1 }, (_unused, index) => {
1220
+ const value = max - ((max - min) * index) / ticks;
1221
+ return { key: index, text: format ? format(value) : compactNumber(value) };
1222
+ });
1223
+ }, [extent, ticks, format]);
1224
+
1225
+ return (
1226
+ <View
1227
+ pointerEvents="none"
1228
+ style={{
1229
+ position: 'absolute',
1230
+ left: 0,
1231
+ // Centred on the grid line each label names: the strip is lifted half a
1232
+ // label and grown by a whole one, so `justify-between` lands the text's
1233
+ // middle on the line rather than its top edge on the first.
1234
+ top: plot.top - AXIS_LABEL_HEIGHT / 2,
1235
+ height: plot.height + AXIS_LABEL_HEIGHT,
1236
+ width: Math.max(plot.left - Y_AXIS_GUTTER, 0),
1237
+ }}
1238
+ className={cn('items-end justify-between', className)}
1239
+ >
1240
+ {labels.map((label) => (
1241
+ <Text key={label.key} size="xs" muted numberOfLines={1}>
1242
+ {label.text}
1243
+ </Text>
1244
+ ))}
1245
+ </View>
1246
+ );
1247
+ }
1248
+ PlotYAxis.displayName = 'Plot.YAxis';
1249
+ PlotYAxis.layer = 'overlay' as Layer;
1250
+ // Read by the root, which has to leave room for the labels before it lays the
1251
+ // plot out.
1252
+ PlotYAxis.axis = 'y' as const;
1253
+
1254
+ export interface PlotCursorProps {
1255
+ color?: string;
1256
+ /** Hide the vertical line and keep only the touch handling. */
1257
+ showLine?: boolean;
1258
+ }
1259
+
1260
+ /**
1261
+ * The touch handling, and the line that follows it.
1262
+ *
1263
+ * Split from the readout next door because they are separately useful: a plot
1264
+ * whose value is shown in its own header wants this and no label, and a plot
1265
+ * that highlights a bar wants this and nothing else at all. Both read the same
1266
+ * index.
1267
+ *
1268
+ * The hit area is the whole plot. A cursor you have to land on the line to
1269
+ * summon is a cursor nobody finds.
1270
+ */
1271
+ function PlotCursor({ color, showLine = true }: PlotCursorProps) {
1272
+ const { data, plot, xScale, activeIndex, setActiveIndexJS, status } =
1273
+ useChart('Plot.Cursor');
1274
+ const token = useCSSVariable('--color-foreground');
1275
+ const stroke = color ?? (typeof token === 'string' ? token : '#888888');
1276
+
1277
+ const total = data.length;
1278
+ const left = plot.left;
1279
+ const width = plot.width;
1280
+ const banded = xScale === 'band';
1281
+
1282
+ /*
1283
+ * Built in one closure, and everything it captures is a number or a shared
1284
+ * value. A worklet may only call another worklet, and the rule is enforced by
1285
+ * crashing rather than by warning — so the resolver is declared next to its
1286
+ * callers instead of as a helper further down the file where it would be easy
1287
+ * to leave un-workletised.
1288
+ */
1289
+ const pan = useMemo(() => {
1290
+ const resolve = (x: number) => {
1291
+ 'worklet';
1292
+ if (total < 1 || width <= 0) return;
1293
+ const ratio = (x - left) / width;
1294
+ const clamped = Math.min(1, Math.max(0, ratio));
1295
+ // A band scale divides the plot into `total` slices and the finger is in
1296
+ // one of them; a point scale has `total - 1` gaps between marks and the
1297
+ // finger rounds to the nearest.
1298
+ const next = banded
1299
+ ? Math.min(total - 1, Math.floor(clamped * total))
1300
+ : Math.round(clamped * Math.max(total - 1, 0));
1301
+ if (next === activeIndex.value) return;
1302
+ activeIndex.value = next;
1303
+ // Only the index needs JS, and only when it changes — a drag across a
1304
+ // hundred rows costs a hundred re-renders at most, not one per frame for
1305
+ // the length of the gesture.
1306
+ runOnJS(setActiveIndexJS)(next);
1307
+ };
1308
+
1309
+ return Gesture.Pan()
1310
+ .minDistance(0)
1311
+ .onBegin((event) => {
1312
+ 'worklet';
1313
+ resolve(event.x);
1314
+ })
1315
+ .onUpdate((event) => {
1316
+ 'worklet';
1317
+ resolve(event.x);
1318
+ })
1319
+ .onFinalize(() => {
1320
+ 'worklet';
1321
+ activeIndex.value = -1;
1322
+ runOnJS(setActiveIndexJS)(-1);
1323
+ });
1324
+ }, [total, left, width, banded, activeIndex, setActiveIndexJS]);
1325
+
1326
+ const lineStyle = useAnimatedStyle(() => {
1327
+ const index = activeIndex.value;
1328
+ if (index < 0) return { opacity: 0 };
1329
+ const x = banded ? bandOf(index, total, plot) : xOf(index, total, plot);
1330
+ return { opacity: 0.45, transform: [{ translateX: x }] };
1331
+ });
1332
+
1333
+ if (status === 'loading') return null;
1334
+
1335
+ return (
1336
+ <GestureDetector gesture={pan}>
1337
+ <View style={StyleSheet.absoluteFill}>
1338
+ {showLine ? (
1339
+ <Animated.View
1340
+ pointerEvents="none"
1341
+ style={[
1342
+ {
1343
+ position: 'absolute',
1344
+ left: 0,
1345
+ top: plot.top,
1346
+ width: 1,
1347
+ height: plot.height,
1348
+ backgroundColor: stroke,
1349
+ },
1350
+ lineStyle,
1351
+ ]}
1352
+ />
1353
+ ) : null}
1354
+ </View>
1355
+ </GestureDetector>
1356
+ );
1357
+ }
1358
+ PlotCursor.displayName = 'Plot.Cursor';
1359
+ PlotCursor.layer = 'overlay' as Layer;
1360
+
1361
+ export interface PlotTooltipProps {
1362
+ /** Format one series' value. Defaults to a compact number. */
1363
+ formatValue?: (value: number, key: string) => string;
1364
+ /** Format the heading from the row. Defaults to the value at `xDataKey`. */
1365
+ formatX?: (datum: PlotDatum) => string;
1366
+ /** Draw the readout yourself, given the row under the cursor. */
1367
+ children?: (datum: PlotDatum, index: number) => ReactNode;
1368
+ className?: string;
1369
+ }
1370
+
1371
+ /**
1372
+ * The readout that rides the cursor.
1373
+ *
1374
+ * Needs a `Plot.Cursor` beside it — the cursor owns the gesture and this only
1375
+ * reads the index it resolves. On its own it never appears, which is the right
1376
+ * failure: a label with no way to move is worse than no label.
1377
+ */
1378
+ function PlotTooltip({ formatValue, formatX, children, className }: PlotTooltipProps) {
1379
+ const { data, xDataKey, plot, xScale, series, activeIndex, activeIndexJS, status } =
1380
+ useChart('Plot.Tooltip');
1381
+
1382
+ const total = data.length;
1383
+ const banded = xScale === 'band';
1384
+
1385
+ // Centred over the cursor but clamped inside the plot, so it never runs off
1386
+ // the edge at the first or last row.
1387
+ const style = useAnimatedStyle(() => {
1388
+ const index = activeIndex.value;
1389
+ if (index < 0) return { opacity: 0 };
1390
+ const x = banded ? bandOf(index, total, plot) : xOf(index, total, plot);
1391
+ const half = LABEL_WIDTH / 2;
1392
+ const clamped = Math.min(
1393
+ plot.left + plot.width - half,
1394
+ Math.max(plot.left + half, x)
1395
+ );
1396
+ return { opacity: 1, transform: [{ translateX: clamped - half }] };
1397
+ });
1398
+
1399
+ const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;
1400
+ const fmtValue = formatValue ?? ((value: number) => compactNumber(value));
1401
+ const fmtX = formatX ?? ((datum: PlotDatum) => String(datum[xDataKey] ?? ''));
1402
+
1403
+ if (status === 'loading') return null;
1404
+
1405
+ return (
1406
+ <Animated.View
1407
+ pointerEvents="none"
1408
+ style={[
1409
+ {
1410
+ position: 'absolute',
1411
+ left: 0,
1412
+ top: Math.max(plot.top - 4, 0),
1413
+ width: LABEL_WIDTH,
1414
+ },
1415
+ style,
1416
+ ]}
1417
+ >
1418
+ <View
1419
+ className={cn(
1420
+ 'items-center rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',
1421
+ className
1422
+ )}
1423
+ >
1424
+ {active ? (
1425
+ children ? (
1426
+ children(active, activeIndexJS)
1427
+ ) : (
1428
+ <>
1429
+ <Text size="xs" muted numberOfLines={1}>
1430
+ {fmtX(active)}
1431
+ </Text>
1432
+ {series.map(([key, color]) => {
1433
+ const value = active[key];
1434
+ if (typeof value !== 'number') return null;
1435
+ return (
1436
+ <View key={key} className="flex-row items-center gap-1.5">
1437
+ {series.length > 1 ? (
1438
+ <View
1439
+ style={{ backgroundColor: color }}
1440
+ className="h-1.5 w-1.5 rounded-full"
1441
+ />
1442
+ ) : null}
1443
+ <Text size="sm" weight="semibold" numberOfLines={1}>
1444
+ {fmtValue(value, key)}
1445
+ </Text>
1446
+ </View>
1447
+ );
1448
+ })}
1449
+ </>
1450
+ )
1451
+ ) : null}
1452
+ </View>
1453
+ </Animated.View>
1454
+ );
1455
+ }
1456
+ PlotTooltip.displayName = 'Plot.Tooltip';
1457
+ PlotTooltip.layer = 'overlay' as Layer;
1458
+
1459
+ /* -------------------------------------------------------------------------- */
1460
+ /* Header layer */
1461
+ /* -------------------------------------------------------------------------- */
1462
+
1463
+ export interface PlotHeaderProps extends ViewProps {
1464
+ className?: string;
1465
+ /** A word for what the plot is of. */
1466
+ title?: string;
1467
+ /** The figure, large. Usually the total, or the row under the cursor. */
1468
+ value?: string;
1469
+ /** A line under the value. */
1470
+ caption?: string;
1471
+ /** Replaces the whole header, keeping only its place above the drawing. */
1472
+ children?: ReactNode;
1473
+ }
1474
+
1475
+ /** The row above the drawing: what it is, and the one number worth reading. */
1476
+ function PlotHeader({
1477
+ className,
1478
+ title,
1479
+ value,
1480
+ caption,
1481
+ children,
1482
+ ...props
1483
+ }: PlotHeaderProps) {
1484
+ return (
1485
+ <View className={cn('gap-0.5', className)} {...props}>
1486
+ {children ?? (
1487
+ <>
1488
+ {title ? (
1489
+ <Text size="sm" muted numberOfLines={1}>
1490
+ {title}
1491
+ </Text>
1492
+ ) : null}
1493
+ {value ? (
1494
+ <Text size="2xl" weight="semibold" numberOfLines={1}>
1495
+ {value}
1496
+ </Text>
1497
+ ) : null}
1498
+ {caption ? (
1499
+ <Text size="xs" muted numberOfLines={1}>
1500
+ {caption}
1501
+ </Text>
1502
+ ) : null}
1503
+ </>
1504
+ )}
1505
+ </View>
1506
+ );
1507
+ }
1508
+ PlotHeader.displayName = 'Plot.Header';
1509
+ PlotHeader.layer = 'header' as Layer;
1510
+
1511
+ export interface PlotLegendProps extends ViewProps {
1512
+ className?: string;
1513
+ /** Names for the columns, keyed by `dataKey`. Falls back to the key itself. */
1514
+ labels?: Record<string, string>;
1515
+ }
1516
+
1517
+ /**
1518
+ * A swatch and a name per mark, taken from the marks that registered.
1519
+ *
1520
+ * In the header rather than over the drawing, because a key laid inside the
1521
+ * plot either covers a mark or is squeezed to one word a line.
1522
+ */
1523
+ function PlotLegend({ className, labels, ...props }: PlotLegendProps) {
1524
+ const { series } = useChart('Plot.Legend');
1525
+ if (!series.length) return null;
1526
+
1527
+ return (
1528
+ <View
1529
+ className={cn('flex-row flex-wrap items-center gap-x-3 gap-y-1 pt-1', className)}
1530
+ {...props}
1531
+ >
1532
+ {series.map(([key, color]) => (
1533
+ <View key={key} className="flex-row items-center gap-1.5">
1534
+ <View
1535
+ style={{ backgroundColor: color }}
1536
+ className="h-2 w-2 rounded-full"
1537
+ />
1538
+ <Text size="xs" muted numberOfLines={1}>
1539
+ {labels?.[key] ?? key}
1540
+ </Text>
1541
+ </View>
1542
+ ))}
1543
+ </View>
1544
+ );
1545
+ }
1546
+ PlotLegend.displayName = 'Plot.Legend';
1547
+ PlotLegend.layer = 'header' as Layer;
1548
+
1549
+ export const Plot = Object.assign(PlotRoot, {
1550
+ Header: PlotHeader,
1551
+ Legend: PlotLegend,
1552
+ Grid: PlotGrid,
1553
+ Area: PlotArea,
1554
+ Bars: PlotBars,
1555
+ Line: PlotLine,
1556
+ Dots: PlotDots,
1557
+ Rule: PlotRule,
1558
+ Layer: PlotLayer,
1559
+ Overlay: PlotOverlay,
1560
+ XAxis: PlotXAxis,
1561
+ YAxis: PlotYAxis,
1562
+ Cursor: PlotCursor,
1563
+ Tooltip: PlotTooltip,
1564
+ });