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