panelui-native 0.7.0 → 0.12.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 (161) hide show
  1. package/README.md +5 -3
  2. package/lib/module/components/attachment/index.js +219 -0
  3. package/lib/module/components/attachment/index.js.map +1 -0
  4. package/lib/module/components/bottom-sheet/index.js +53 -3
  5. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  6. package/lib/module/components/button/index.js +19 -1
  7. package/lib/module/components/button/index.js.map +1 -1
  8. package/lib/module/components/dialog/index.js +6 -2
  9. package/lib/module/components/dialog/index.js.map +1 -1
  10. package/lib/module/components/empty-state/index.js +94 -17
  11. package/lib/module/components/empty-state/index.js.map +1 -1
  12. package/lib/module/components/frame/index.js +128 -15
  13. package/lib/module/components/frame/index.js.map +1 -1
  14. package/lib/module/components/input/index.js +107 -15
  15. package/lib/module/components/input/index.js.map +1 -1
  16. package/lib/module/components/line-chart/index.js +1024 -0
  17. package/lib/module/components/line-chart/index.js.map +1 -0
  18. package/lib/module/components/marker/index.js +190 -0
  19. package/lib/module/components/marker/index.js.map +1 -0
  20. package/lib/module/components/message/index.js +24 -8
  21. package/lib/module/components/message/index.js.map +1 -1
  22. package/lib/module/components/message-scroller/index.js +451 -0
  23. package/lib/module/components/message-scroller/index.js.map +1 -0
  24. package/lib/module/components/popover/index.js +511 -0
  25. package/lib/module/components/popover/index.js.map +1 -0
  26. package/lib/module/components/progress/index.js +52 -2
  27. package/lib/module/components/progress/index.js.map +1 -1
  28. package/lib/module/components/radio-group/index.js +132 -17
  29. package/lib/module/components/radio-group/index.js.map +1 -1
  30. package/lib/module/components/section-rail/index.js +355 -0
  31. package/lib/module/components/section-rail/index.js.map +1 -0
  32. package/lib/module/components/select/index.js +245 -56
  33. package/lib/module/components/select/index.js.map +1 -1
  34. package/lib/module/components/separator/index.js +91 -0
  35. package/lib/module/components/separator/index.js.map +1 -0
  36. package/lib/module/components/slider/index.js +355 -0
  37. package/lib/module/components/slider/index.js.map +1 -0
  38. package/lib/module/components/surface/index.js +21 -2
  39. package/lib/module/components/surface/index.js.map +1 -1
  40. package/lib/module/components/switch/index.js +10 -1
  41. package/lib/module/components/switch/index.js.map +1 -1
  42. package/lib/module/components/tabs/index.js +186 -16
  43. package/lib/module/components/tabs/index.js.map +1 -1
  44. package/lib/module/components/toggle-button/index.js +297 -0
  45. package/lib/module/components/toggle-button/index.js.map +1 -0
  46. package/lib/module/hooks/index.js +1 -0
  47. package/lib/module/hooks/index.js.map +1 -1
  48. package/lib/module/hooks/use-keyboard-avoidance.js +57 -8
  49. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  50. package/lib/module/hooks/use-scroll-sections.js +89 -0
  51. package/lib/module/hooks/use-scroll-sections.js.map +1 -0
  52. package/lib/module/icons/index.js +69 -0
  53. package/lib/module/icons/index.js.map +1 -1
  54. package/lib/module/index.js +11 -2
  55. package/lib/module/index.js.map +1 -1
  56. package/lib/module/native/index.js.map +1 -1
  57. package/lib/module/primitives/keyboard-avoider.js +2 -0
  58. package/lib/module/primitives/keyboard-avoider.js.map +1 -1
  59. package/lib/module/primitives/scrim.js +72 -0
  60. package/lib/module/primitives/scrim.js.map +1 -0
  61. package/lib/module/utils/haptics.js +57 -0
  62. package/lib/module/utils/haptics.js.map +1 -0
  63. package/lib/typescript/src/components/attachment/index.d.ts +93 -0
  64. package/lib/typescript/src/components/attachment/index.d.ts.map +1 -0
  65. package/lib/typescript/src/components/bottom-sheet/index.d.ts +6 -1
  66. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/button/index.d.ts +9 -4
  68. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  69. package/lib/typescript/src/components/dialog/index.d.ts +7 -1
  70. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  71. package/lib/typescript/src/components/empty-state/index.d.ts +152 -2
  72. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  73. package/lib/typescript/src/components/frame/index.d.ts +47 -7
  74. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/input/index.d.ts +113 -2
  76. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/line-chart/index.d.ts +247 -0
  78. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -0
  79. package/lib/typescript/src/components/marker/index.d.ts +137 -0
  80. package/lib/typescript/src/components/marker/index.d.ts.map +1 -0
  81. package/lib/typescript/src/components/message/index.d.ts +8 -1
  82. package/lib/typescript/src/components/message/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/message-scroller/index.d.ts +150 -0
  84. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -0
  85. package/lib/typescript/src/components/popover/index.d.ts +136 -0
  86. package/lib/typescript/src/components/popover/index.d.ts.map +1 -0
  87. package/lib/typescript/src/components/progress/index.d.ts +33 -0
  88. package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
  89. package/lib/typescript/src/components/radio-group/index.d.ts +39 -0
  90. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  91. package/lib/typescript/src/components/section-rail/index.d.ts +115 -0
  92. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -0
  93. package/lib/typescript/src/components/select/index.d.ts +46 -7
  94. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  95. package/lib/typescript/src/components/separator/index.d.ts +47 -0
  96. package/lib/typescript/src/components/separator/index.d.ts.map +1 -0
  97. package/lib/typescript/src/components/slider/index.d.ts +203 -0
  98. package/lib/typescript/src/components/slider/index.d.ts.map +1 -0
  99. package/lib/typescript/src/components/surface/index.d.ts +51 -2
  100. package/lib/typescript/src/components/surface/index.d.ts.map +1 -1
  101. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  102. package/lib/typescript/src/components/tabs/index.d.ts +46 -4
  103. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  104. package/lib/typescript/src/components/toggle-button/index.d.ts +244 -0
  105. package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -0
  106. package/lib/typescript/src/hooks/index.d.ts +1 -0
  107. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  108. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +10 -1
  109. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  110. package/lib/typescript/src/hooks/use-scroll-sections.d.ts +31 -0
  111. package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -0
  112. package/lib/typescript/src/icons/index.d.ts +6 -0
  113. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  114. package/lib/typescript/src/index.d.ts +13 -4
  115. package/lib/typescript/src/index.d.ts.map +1 -1
  116. package/lib/typescript/src/native/index.d.ts +16 -1
  117. package/lib/typescript/src/native/index.d.ts.map +1 -1
  118. package/lib/typescript/src/primitives/keyboard-avoider.d.ts +8 -1
  119. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
  120. package/lib/typescript/src/primitives/scrim.d.ts +21 -0
  121. package/lib/typescript/src/primitives/scrim.d.ts.map +1 -0
  122. package/lib/typescript/src/utils/haptics.d.ts +12 -0
  123. package/lib/typescript/src/utils/haptics.d.ts.map +1 -0
  124. package/package.json +9 -1
  125. package/src/components/attachment/index.tsx +279 -0
  126. package/src/components/bottom-sheet/index.tsx +58 -2
  127. package/src/components/button/index.tsx +26 -5
  128. package/src/components/dialog/index.tsx +10 -1
  129. package/src/components/empty-state/index.tsx +60 -14
  130. package/src/components/frame/index.tsx +175 -29
  131. package/src/components/input/index.tsx +122 -16
  132. package/src/components/line-chart/index.tsx +1224 -0
  133. package/src/components/marker/index.tsx +194 -0
  134. package/src/components/message/index.tsx +29 -5
  135. package/src/components/message-scroller/index.tsx +574 -0
  136. package/src/components/popover/index.tsx +624 -0
  137. package/src/components/progress/index.tsx +81 -1
  138. package/src/components/radio-group/index.tsx +156 -18
  139. package/src/components/section-rail/index.tsx +459 -0
  140. package/src/components/select/index.tsx +265 -52
  141. package/src/components/separator/index.tsx +97 -0
  142. package/src/components/slider/index.tsx +371 -0
  143. package/src/components/surface/index.tsx +29 -3
  144. package/src/components/switch/index.tsx +8 -1
  145. package/src/components/tabs/index.tsx +208 -28
  146. package/src/components/toggle-button/index.tsx +374 -0
  147. package/src/hooks/index.ts +5 -0
  148. package/src/hooks/use-keyboard-avoidance.ts +73 -7
  149. package/src/hooks/use-scroll-sections.ts +127 -0
  150. package/src/icons/index.tsx +48 -0
  151. package/src/index.ts +97 -6
  152. package/src/native/index.ts +13 -1
  153. package/src/primitives/keyboard-avoider.tsx +9 -1
  154. package/src/primitives/scrim.tsx +95 -0
  155. package/src/utils/haptics.ts +63 -0
  156. package/theme.css +64 -2
  157. package/lib/module/components/inline-select/index.js +0 -284
  158. package/lib/module/components/inline-select/index.js.map +0 -1
  159. package/lib/typescript/src/components/inline-select/index.d.ts +0 -48
  160. package/lib/typescript/src/components/inline-select/index.d.ts.map +0 -1
  161. package/src/components/inline-select/index.tsx +0 -332
@@ -0,0 +1,1224 @@
1
+ /**
2
+ * LineChart — a time series, drawn and animated on the UI thread.
3
+ *
4
+ * The chart is composed rather than configured: the grid, each series, the
5
+ * axis and the crosshair are separate children, so a chart that wants no grid
6
+ * simply does not have one. A single component with twenty booleans is how
7
+ * charts end up unreadable at the call site.
8
+ *
9
+ * ```tsx
10
+ * <LineChart data={visits} xDataKey="date">
11
+ * <LineChart.Grid />
12
+ * <LineChart.Area dataKey="visits" />
13
+ * <LineChart.Line dataKey="visits" />
14
+ * <LineChart.XAxis />
15
+ * <LineChart.Tooltip />
16
+ * </LineChart>
17
+ * ```
18
+ *
19
+ * Internally there are two layers, and the parts sort themselves into the
20
+ * right one: the geometry is SVG, and anything with text or a gesture on it is
21
+ * a React Native view laid over the top. That split is not a detail — SVG text
22
+ * ignores the platform's text scaling and the theme's font, and a gesture
23
+ * handler cannot be attached to an SVG node at all.
24
+ *
25
+ * Three things animate, each for a different reason:
26
+ *
27
+ * - **The reveal.** On mount the plot is uncovered left to right by an animated
28
+ * clip rectangle. Everything inside shares that clip, so the line, its fill
29
+ * and its markers arrive together rather than as three separate effects.
30
+ * - **The y-domain.** When the data changes the *scale* is tweened rather than
31
+ * the path swapped, so a series that grows is redrawn against a moving axis
32
+ * instead of jumping to a new shape. The reveal does not replay — it happened
33
+ * once, and repeating it on every refresh turns a data update into an
34
+ * animation.
35
+ * - **The crosshair.** A drag resolves the nearest index on the UI thread and
36
+ * moves the line and the dots from there. Only the index crosses back into
37
+ * JS, and only when it changes, so a drag costs a handful of re-renders
38
+ * rather than one per frame.
39
+ *
40
+ * Colours come from the `--color-chart-*` tokens, so a chart follows the active
41
+ * theme and is put on brand by overriding those five in the app's own
42
+ * global.css. Nothing here hardcodes a hex.
43
+ */
44
+ import {
45
+ Children,
46
+ createContext,
47
+ forwardRef,
48
+ isValidElement,
49
+ useContext,
50
+ useEffect,
51
+ useImperativeHandle,
52
+ useMemo,
53
+ useRef,
54
+ useState,
55
+ type ReactNode,
56
+ } from 'react';
57
+ import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
58
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
59
+ import Animated, {
60
+ cancelAnimation,
61
+ Easing,
62
+ runOnJS,
63
+ useAnimatedProps,
64
+ useAnimatedStyle,
65
+ useReducedMotion,
66
+ useSharedValue,
67
+ withRepeat,
68
+ withTiming,
69
+ type SharedValue,
70
+ } from 'react-native-reanimated';
71
+ import Svg, {
72
+ Circle,
73
+ ClipPath,
74
+ Defs,
75
+ G,
76
+ Line as SvgLine,
77
+ LinearGradient,
78
+ Path,
79
+ Rect,
80
+ Stop,
81
+ } from 'react-native-svg';
82
+ import { useCSSVariable } from 'uniwind';
83
+ import { Text } from '../../primitives/text';
84
+ import { cn } from '../../utils/cn';
85
+
86
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
87
+ const AnimatedRect = Animated.createAnimatedComponent(Rect);
88
+ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
89
+ const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
90
+
91
+ /** Room left around the plot for the axis labels and the marker rings. */
92
+ const PADDING = { top: 12, right: 10, bottom: 22, left: 10 };
93
+
94
+ /**
95
+ * Which layer a part belongs to. Read off the component itself, so composition
96
+ * stays a flat list of children instead of two nested slots the caller has to
97
+ * remember the order of.
98
+ */
99
+ type Layer = 'svg' | 'overlay';
100
+
101
+ export type LineChartStatus = 'loading' | 'ready';
102
+ export type LineChartCurve = 'monotone' | 'linear';
103
+ export type LineChartDatum = Record<string, string | number | null | undefined>;
104
+
105
+ interface Plot {
106
+ width: number;
107
+ height: number;
108
+ left: number;
109
+ top: number;
110
+ }
111
+
112
+ interface LineChartContextValue {
113
+ data: LineChartDatum[];
114
+ xDataKey: string;
115
+ plot: Plot;
116
+ status: LineChartStatus;
117
+ curve: LineChartCurve;
118
+ /** Series registered by the `Line` children, so the tooltip can read them. */
119
+ series: [string, string][];
120
+ registerSeries: (key: string, color: string) => void;
121
+ unregisterSeries: (key: string) => void;
122
+ /** Tweened y-domain. Read inside worklets to build the paths. */
123
+ domainMin: SharedValue<number>;
124
+ domainMax: SharedValue<number>;
125
+ /** Index under the finger, or -1 when nothing is being touched. */
126
+ activeIndex: SharedValue<number>;
127
+ activeIndexJS: number;
128
+ setActiveIndexJS: (index: number) => void;
129
+ clipId: string;
130
+ }
131
+
132
+ const LineChartContext = createContext<LineChartContextValue | null>(null);
133
+
134
+ function useChart(component: string): LineChartContextValue {
135
+ const context = useContext(LineChartContext);
136
+ if (!context) {
137
+ throw new Error(`${component} must be used within a <LineChart>`);
138
+ }
139
+ return context;
140
+ }
141
+
142
+ /**
143
+ * The point under the crosshair, for something rendered *inside* the chart.
144
+ *
145
+ * A readout usually belongs in the card's header, which is outside this
146
+ * provider — use `onActiveIndexChange` for that. A hook cannot reach up out of
147
+ * the subtree it is called in, and pretending otherwise is how a component
148
+ * ends up with a context that has to wrap half the screen.
149
+ */
150
+ export function useLineChart() {
151
+ const { data, activeIndexJS, xDataKey } = useChart('useLineChart');
152
+ return {
153
+ activeIndex: activeIndexJS,
154
+ activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,
155
+ xDataKey,
156
+ };
157
+ }
158
+
159
+ export interface LineChartProps extends ViewProps {
160
+ className?: string;
161
+ /** The rows. Each one is a point along the x-axis. */
162
+ data: LineChartDatum[];
163
+ /** Key holding the x label. Used by the axis and the crosshair readout. */
164
+ xDataKey?: string;
165
+ /**
166
+ * `loading` draws a flat skeleton with a sweep running along it, and morphs
167
+ * into the real series when it turns `ready`. One component throughout,
168
+ * rather than a spinner swapped for a chart — swapping loses the transition.
169
+ */
170
+ status?: LineChartStatus;
171
+ /** Width ÷ height. `2` is the wide card shape; `1.6` suits a narrow column. */
172
+ aspectRatio?: number;
173
+ /** Milliseconds for the reveal on mount. */
174
+ animationDuration?: number;
175
+ /** Milliseconds for the y-axis to settle after the data changes. */
176
+ domainDuration?: number;
177
+ /** Fix the y-axis instead of deriving it from the data. */
178
+ yDomain?: [number, number];
179
+ /** `monotone` never overshoots between points; `linear` joins them straight. */
180
+ curve?: LineChartCurve;
181
+ /**
182
+ * The point under the crosshair as it moves, and `-1`/`null` when the finger
183
+ * lifts. This is how a readout in the card's header gets its value — that
184
+ * header is outside the chart, so it cannot use `useLineChart`.
185
+ *
186
+ * Fires when the index changes, not per frame.
187
+ */
188
+ onActiveIndexChange?: (index: number, datum: LineChartDatum | null) => void;
189
+ /**
190
+ * Drop the axis padding so the line reaches the edges — for a sparkline with
191
+ * no grid, axis or crosshair, where the shape is the whole point.
192
+ */
193
+ compact?: boolean;
194
+ children?: ReactNode;
195
+ }
196
+
197
+ /** Imperative handle: re-run the reveal on demand, for a "replay" control. */
198
+ export interface LineChartHandle {
199
+ replay: () => void;
200
+ }
201
+
202
+ const LineChartRoot = forwardRef<LineChartHandle, LineChartProps>(function LineChartRoot(
203
+ {
204
+ className,
205
+ data,
206
+ xDataKey = 'date',
207
+ status = 'ready',
208
+ aspectRatio = 2,
209
+ animationDuration = 1100,
210
+ domainDuration = 500,
211
+ yDomain,
212
+ curve = 'monotone',
213
+ onActiveIndexChange,
214
+ compact = false,
215
+ children,
216
+ ...props
217
+ },
218
+ ref
219
+ ) {
220
+ const [size, setSize] = useState({ width: 0, height: 0 });
221
+ const [series, setSeries] = useState<[string, string][]>([]);
222
+ const [activeIndexJS, setActiveIndexJS] = useState(-1);
223
+ const clipId = useRef(`panelui-clip-${Math.random().toString(36).slice(2, 9)}`).current;
224
+
225
+ const reveal = useSharedValue(0);
226
+ const domainMin = useSharedValue(0);
227
+ const domainMax = useSharedValue(0);
228
+ const activeIndex = useSharedValue(-1);
229
+ const reducedMotion = useReducedMotion();
230
+
231
+ const registerSeries = useMemo(
232
+ () => (key: string, color: string) =>
233
+ setSeries((current) => {
234
+ const existing = current.find(([k]) => k === key);
235
+ if (existing?.[1] === color) return current;
236
+ return [...current.filter(([k]) => k !== key), [key, color]];
237
+ }),
238
+ []
239
+ );
240
+
241
+ const unregisterSeries = useMemo(
242
+ () => (key: string) => setSeries((current) => current.filter(([k]) => k !== key)),
243
+ []
244
+ );
245
+
246
+ // A sparkline has no axis or grid to leave room for, so the line reaches the
247
+ // edges; a stroke of a couple of pixels still needs a hair of inset not to be
248
+ // clipped at the very top and bottom.
249
+ const pad = compact ? { top: 2, right: 1, bottom: 2, left: 1 } : PADDING;
250
+ const plot: Plot = {
251
+ left: pad.left,
252
+ top: pad.top,
253
+ width: Math.max(size.width - pad.left - pad.right, 0),
254
+ height: Math.max(size.height - pad.top - pad.bottom, 0),
255
+ };
256
+
257
+ // One extent across every registered series, so two series share one axis
258
+ // and stay comparable — a per-series scale makes them look alike when they
259
+ // are orders of magnitude apart.
260
+ const seriesKeys = series.map(([key]) => key).join('|');
261
+ const extent = useMemo<[number, number]>(() => {
262
+ if (yDomain) return yDomain;
263
+ const keys = seriesKeys ? seriesKeys.split('|') : [];
264
+ let min = Infinity;
265
+ let max = -Infinity;
266
+ for (const row of data) {
267
+ for (const key of keys) {
268
+ const value = row[key];
269
+ if (typeof value !== 'number' || Number.isNaN(value)) continue;
270
+ if (value < min) min = value;
271
+ if (value > max) max = value;
272
+ }
273
+ }
274
+ if (min === Infinity) return [0, 1];
275
+ // A flat series has no extent of its own; give it one so it lands on the
276
+ // middle of the plot instead of dividing by zero.
277
+ if (min === max) return [min - 1, max + 1];
278
+ // A little headroom, so the peak is not welded to the top edge.
279
+ const pad = (max - min) * 0.1;
280
+ return [min - pad, max + pad];
281
+ }, [data, yDomain, seriesKeys]);
282
+
283
+ const loading = status === 'loading';
284
+
285
+ useEffect(() => {
286
+ if (loading) return;
287
+ const [min, max] = extent;
288
+ // The first domain lands without a tween: there is no previous scale to
289
+ // move from, and animating up from zero reads as the numbers changing.
290
+ const first = domainMin.value === 0 && domainMax.value === 0;
291
+ if (first || reducedMotion) {
292
+ domainMin.value = min;
293
+ domainMax.value = max;
294
+ return;
295
+ }
296
+ domainMin.value = withTiming(min, { duration: domainDuration });
297
+ domainMax.value = withTiming(max, { duration: domainDuration });
298
+ }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);
299
+
300
+ // Plays once, when there is both a plot to reveal and data to reveal in it.
301
+ const revealed = useRef(false);
302
+ const playReveal = useMemo(
303
+ () => () => {
304
+ if (reducedMotion) {
305
+ reveal.value = 1;
306
+ return;
307
+ }
308
+ reveal.value = 0;
309
+ reveal.value = withTiming(1, {
310
+ duration: animationDuration,
311
+ easing: Easing.bezier(0.85, 0, 0.15, 1),
312
+ });
313
+ },
314
+ [reducedMotion, animationDuration, reveal]
315
+ );
316
+
317
+ useEffect(() => {
318
+ if (revealed.current || loading || plot.width <= 0 || !data.length) return;
319
+ revealed.current = true;
320
+ playReveal();
321
+ }, [loading, plot.width, data.length, playReveal]);
322
+
323
+ // `replay()` re-runs the reveal — for a control the caller wires up.
324
+ useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
325
+
326
+ const clipProps = useAnimatedProps(() => ({ width: plot.width * reveal.value }));
327
+
328
+ // One place the crosshair index lands, so the chart's own children and a
329
+ // readout outside it never disagree about which point is active.
330
+ const handleActiveIndex = useMemo(
331
+ () => (index: number) => {
332
+ setActiveIndexJS(index);
333
+ onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);
334
+ },
335
+ [onActiveIndexChange, data]
336
+ );
337
+
338
+ const onLayout = (event: LayoutChangeEvent) => {
339
+ const { width, height } = event.nativeEvent.layout;
340
+ setSize((current) =>
341
+ Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
342
+ ? current
343
+ : { width, height }
344
+ );
345
+ props.onLayout?.(event);
346
+ };
347
+
348
+ const context = useMemo<LineChartContextValue>(
349
+ () => ({
350
+ data,
351
+ xDataKey,
352
+ plot,
353
+ status,
354
+ curve,
355
+ series,
356
+ registerSeries,
357
+ unregisterSeries,
358
+ domainMin,
359
+ domainMax,
360
+ activeIndex,
361
+ activeIndexJS,
362
+ setActiveIndexJS: handleActiveIndex,
363
+ clipId,
364
+ }),
365
+ // `plot` is rebuilt every render from `size`, so it is compared by value.
366
+ // eslint-disable-next-line react-hooks/exhaustive-deps
367
+ [
368
+ data,
369
+ xDataKey,
370
+ plot.width,
371
+ plot.height,
372
+ plot.left,
373
+ plot.top,
374
+ status,
375
+ curve,
376
+ series,
377
+ registerSeries,
378
+ unregisterSeries,
379
+ domainMin,
380
+ domainMax,
381
+ activeIndex,
382
+ activeIndexJS,
383
+ handleActiveIndex,
384
+ clipId,
385
+ ]
386
+ );
387
+
388
+ const { svg, overlay } = partition(children);
389
+
390
+ return (
391
+ <LineChartContext.Provider value={context}>
392
+ <View
393
+ {...props}
394
+ onLayout={onLayout}
395
+ style={[{ aspectRatio }, props.style]}
396
+ className={cn('w-full', className)}
397
+ >
398
+ {plot.width > 0 ? (
399
+ <>
400
+ <Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
401
+ <Defs>
402
+ {/*
403
+ * One clip for everything in the plot. Sharing it is what makes
404
+ * the reveal read as the chart arriving, rather than as three
405
+ * separate things animating in at once.
406
+ */}
407
+ <ClipPath id={clipId}>
408
+ <AnimatedRect
409
+ x={plot.left}
410
+ y={0}
411
+ height={size.height}
412
+ animatedProps={clipProps}
413
+ />
414
+ </ClipPath>
415
+ </Defs>
416
+ {svg}
417
+ </Svg>
418
+ {overlay}
419
+ </>
420
+ ) : null}
421
+ </View>
422
+ </LineChartContext.Provider>
423
+ );
424
+ });
425
+ LineChartRoot.displayName = 'LineChart';
426
+
427
+ /** Sorts the children into the SVG tree and the view layer over it. */
428
+ function partition(children: ReactNode) {
429
+ const svg: ReactNode[] = [];
430
+ const overlay: ReactNode[] = [];
431
+
432
+ Children.forEach(children, (child, index) => {
433
+ if (!isValidElement(child)) return;
434
+ const layer = (child.type as { layer?: Layer }).layer ?? 'svg';
435
+ (layer === 'overlay' ? overlay : svg).push(
436
+ // Children of a `Children.forEach` need keys of their own once they are
437
+ // put into a new array.
438
+ <ChildSlot key={index}>{child}</ChildSlot>
439
+ );
440
+ });
441
+
442
+ return { svg, overlay };
443
+ }
444
+
445
+ /** Identity wrapper, purely so the partitioned arrays can carry keys. */
446
+ function ChildSlot({ children }: { children: ReactNode }) {
447
+ return <>{children}</>;
448
+ }
449
+
450
+ /* -------------------------------------------------------------------------- */
451
+ /* SVG layer */
452
+ /* -------------------------------------------------------------------------- */
453
+
454
+ export interface LineChartGridProps {
455
+ /** Horizontal rules across the plot. */
456
+ rows?: number;
457
+ color?: string;
458
+ /** Dash pattern, e.g. `"4,6"`. Omit for a solid rule. */
459
+ dashArray?: string;
460
+ opacity?: number;
461
+ }
462
+
463
+ /** Horizontal reference lines. Drawn under everything, outside the reveal clip. */
464
+ function LineChartGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: LineChartGridProps) {
465
+ const { plot } = useChart('LineChart.Grid');
466
+ const token = useCSSVariable('--color-border');
467
+ const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
468
+
469
+ return (
470
+ <G opacity={opacity}>
471
+ {Array.from({ length: rows + 1 }, (_, index) => {
472
+ const y = plot.top + (plot.height / rows) * index;
473
+ return (
474
+ <SvgLine
475
+ key={index}
476
+ x1={plot.left}
477
+ x2={plot.left + plot.width}
478
+ y1={y}
479
+ y2={y}
480
+ stroke={stroke}
481
+ strokeWidth={1}
482
+ strokeDasharray={dashArray}
483
+ />
484
+ );
485
+ })}
486
+ </G>
487
+ );
488
+ }
489
+ LineChartGrid.displayName = 'LineChart.Grid';
490
+ LineChartGrid.layer = 'svg' as Layer;
491
+
492
+ export interface LineChartLineProps {
493
+ /** Key holding this series' y values. */
494
+ dataKey: string;
495
+ /**
496
+ * Stroke colour. Defaults to the `--color-chart-*` token at `colorIndex`, so
497
+ * a series follows the theme without the call site naming a colour.
498
+ */
499
+ color?: string;
500
+ /** Which `--color-chart-*` token to take when `color` is not given. */
501
+ colorIndex?: 1 | 2 | 3 | 4 | 5;
502
+ strokeWidth?: number;
503
+ /** Dash pattern, e.g. `"6,4"` — for a projection or a secondary series. */
504
+ dashArray?: string;
505
+ /** A dot at every point. Best kept for short series. */
506
+ showMarkers?: boolean;
507
+ }
508
+
509
+ /** One series. */
510
+ function LineChartLine({
511
+ dataKey,
512
+ color,
513
+ colorIndex = 1,
514
+ strokeWidth = 2.5,
515
+ dashArray,
516
+ showMarkers = false,
517
+ }: LineChartLineProps) {
518
+ const { data, plot, domainMin, domainMax, curve, status, registerSeries, unregisterSeries, clipId } =
519
+ useChart('LineChart.Line');
520
+ const stroke = useSeriesColor(color, colorIndex);
521
+
522
+ useEffect(() => {
523
+ registerSeries(dataKey, stroke);
524
+ return () => unregisterSeries(dataKey);
525
+ }, [dataKey, stroke, registerSeries, unregisterSeries]);
526
+
527
+ const values = useMemo(() => numbersOf(data, dataKey), [data, dataKey]);
528
+ const loading = status === 'loading';
529
+
530
+ const animatedProps = useAnimatedProps(() => ({
531
+ d: linePath(values, plot, domainMin.value, domainMax.value, curve, loading),
532
+ }));
533
+
534
+ return (
535
+ <G clipPath={`url(#${clipId})`}>
536
+ <AnimatedPath
537
+ animatedProps={animatedProps}
538
+ fill="none"
539
+ stroke={loading ? 'transparent' : stroke}
540
+ strokeWidth={strokeWidth}
541
+ strokeDasharray={dashArray}
542
+ strokeLinecap="round"
543
+ strokeLinejoin="round"
544
+ />
545
+ {showMarkers && !loading
546
+ ? values.map((value, index) =>
547
+ value === null ? null : (
548
+ <PointMarker
549
+ key={index}
550
+ index={index}
551
+ total={values.length}
552
+ value={value}
553
+ plot={plot}
554
+ domainMin={domainMin}
555
+ domainMax={domainMax}
556
+ color={stroke}
557
+ />
558
+ )
559
+ )
560
+ : null}
561
+ </G>
562
+ );
563
+ }
564
+ LineChartLine.displayName = 'LineChart.Line';
565
+ LineChartLine.layer = 'svg' as Layer;
566
+
567
+ /** A dot at one point. Follows the y-domain tween exactly as the line does. */
568
+ function PointMarker({
569
+ index,
570
+ total,
571
+ value,
572
+ plot,
573
+ domainMin,
574
+ domainMax,
575
+ color,
576
+ }: {
577
+ index: number;
578
+ total: number;
579
+ value: number;
580
+ plot: Plot;
581
+ domainMin: SharedValue<number>;
582
+ domainMax: SharedValue<number>;
583
+ color: string;
584
+ }) {
585
+ const animatedProps = useAnimatedProps(() => ({
586
+ cy: yOf(value, plot, domainMin.value, domainMax.value),
587
+ }));
588
+
589
+ return (
590
+ <AnimatedCircle animatedProps={animatedProps} cx={xOf(index, total, plot)} r={3} fill={color} />
591
+ );
592
+ }
593
+
594
+ export interface LineChartAreaProps {
595
+ dataKey: string;
596
+ color?: string;
597
+ colorIndex?: 1 | 2 | 3 | 4 | 5;
598
+ /** Opacity at the line. Fades to nothing at the baseline. */
599
+ opacity?: number;
600
+ }
601
+
602
+ /**
603
+ * The fill under a series. A separate child from the line, because a chart with
604
+ * two series usually wants the fill on only one of them — two translucent
605
+ * fills over each other make a third colour that means nothing.
606
+ */
607
+ function LineChartArea({ dataKey, color, colorIndex = 1, opacity = 0.18 }: LineChartAreaProps) {
608
+ const { data, plot, domainMin, domainMax, curve, status, clipId } = useChart('LineChart.Area');
609
+ const fill = useSeriesColor(color, colorIndex);
610
+ const gradientId = useRef(`panelui-area-${Math.random().toString(36).slice(2, 9)}`).current;
611
+
612
+ const values = useMemo(() => numbersOf(data, dataKey), [data, dataKey]);
613
+ const loading = status === 'loading';
614
+
615
+ const animatedProps = useAnimatedProps(() => ({
616
+ d: areaPath(values, plot, domainMin.value, domainMax.value, curve, loading),
617
+ }));
618
+
619
+ return (
620
+ <G clipPath={`url(#${clipId})`}>
621
+ <Defs>
622
+ <LinearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
623
+ <Stop offset="0" stopColor={fill} stopOpacity={opacity} />
624
+ <Stop offset="1" stopColor={fill} stopOpacity={0} />
625
+ </LinearGradient>
626
+ </Defs>
627
+ <AnimatedPath animatedProps={animatedProps} fill={`url(#${gradientId})`} stroke="none" />
628
+ </G>
629
+ );
630
+ }
631
+ LineChartArea.displayName = 'LineChart.Area';
632
+ LineChartArea.layer = 'svg' as Layer;
633
+
634
+ export interface LineChartSkeletonProps {
635
+ /** Milliseconds for one pass of the sweep. */
636
+ duration?: number;
637
+ color?: string;
638
+ }
639
+
640
+ /**
641
+ * The loading state: a flat rule where the series will be, with a highlight
642
+ * travelling along it. Drawn inside the same SVG rather than as an overlay, so
643
+ * arriving data is one tree changing rather than one view replacing another —
644
+ * which is what lets the flat line become the series instead of cutting to it.
645
+ */
646
+ function LineChartSkeleton({ duration = 1400, color }: LineChartSkeletonProps) {
647
+ const { plot, status } = useChart('LineChart.Skeleton');
648
+ const token = useCSSVariable('--color-skeleton');
649
+ const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
650
+ const highlight = useSeriesColor(undefined, 1);
651
+
652
+ const sweep = useSharedValue(0);
653
+ const reducedMotion = useReducedMotion();
654
+ const loading = status === 'loading';
655
+
656
+ useEffect(() => {
657
+ if (!loading || reducedMotion) {
658
+ cancelAnimation(sweep);
659
+ sweep.value = 0;
660
+ return;
661
+ }
662
+ sweep.value = 0;
663
+ sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);
664
+ return () => cancelAnimation(sweep);
665
+ }, [loading, reducedMotion, duration, sweep]);
666
+
667
+ // The band travels by moving the gradient's own endpoints, so the whole
668
+ // effect is two numbers changing on the UI thread.
669
+ const animatedProps = useAnimatedProps(() => ({
670
+ x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,
671
+ x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,
672
+ }));
673
+
674
+ if (!loading) return null;
675
+
676
+ const y = plot.top + plot.height / 2;
677
+ const gradientId = 'panelui-chart-skeleton';
678
+
679
+ return (
680
+ <G>
681
+ <Defs>
682
+ <AnimatedLinearGradient id={gradientId} animatedProps={animatedProps} y1="0" y2="0">
683
+ <Stop offset="0" stopColor={base} />
684
+ <Stop offset="0.5" stopColor={highlight} stopOpacity={0.55} />
685
+ <Stop offset="1" stopColor={base} />
686
+ </AnimatedLinearGradient>
687
+ </Defs>
688
+ <SvgLine
689
+ x1={plot.left}
690
+ x2={plot.left + plot.width}
691
+ y1={y}
692
+ y2={y}
693
+ stroke={`url(#${gradientId})`}
694
+ strokeWidth={2.5}
695
+ strokeLinecap="round"
696
+ />
697
+ </G>
698
+ );
699
+ }
700
+ LineChartSkeleton.displayName = 'LineChart.Skeleton';
701
+ LineChartSkeleton.layer = 'svg' as Layer;
702
+
703
+ /* -------------------------------------------------------------------------- */
704
+ /* Overlay layer */
705
+ /* -------------------------------------------------------------------------- */
706
+
707
+ export interface LineChartXAxisProps {
708
+ /** How many labels to show. The rest are dropped, evenly. */
709
+ ticks?: number;
710
+ /** Turn a row into its label. Defaults to the value at `xDataKey`. */
711
+ format?: (datum: LineChartDatum, index: number) => string;
712
+ className?: string;
713
+ }
714
+
715
+ /**
716
+ * The x labels. Real text rather than SVG text, so they follow the theme's font
717
+ * and the platform's text scaling — SVG text does neither.
718
+ */
719
+ function LineChartXAxis({ ticks = 4, format, className }: LineChartXAxisProps) {
720
+ const { data, xDataKey, plot } = useChart('LineChart.XAxis');
721
+
722
+ const labels = useMemo(() => {
723
+ if (!data.length) return [];
724
+ const count = Math.min(ticks, data.length);
725
+ const step = count > 1 ? (data.length - 1) / (count - 1) : 0;
726
+ return Array.from({ length: count }, (_, index) => {
727
+ const dataIndex = Math.round(index * step);
728
+ const datum = data[dataIndex];
729
+ if (!datum) return null;
730
+ return {
731
+ key: dataIndex,
732
+ text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),
733
+ };
734
+ }).filter((label): label is { key: number; text: string } => label !== null);
735
+ }, [data, ticks, format, xDataKey]);
736
+
737
+ return (
738
+ <View
739
+ pointerEvents="none"
740
+ style={{
741
+ position: 'absolute',
742
+ left: plot.left,
743
+ right: PADDING.right,
744
+ bottom: 0,
745
+ height: PADDING.bottom,
746
+ }}
747
+ className={cn('flex-row items-center justify-between', className)}
748
+ >
749
+ {labels.map((label) => (
750
+ <Text key={label.key} size="xs" muted>
751
+ {label.text}
752
+ </Text>
753
+ ))}
754
+ </View>
755
+ );
756
+ }
757
+ LineChartXAxis.displayName = 'LineChart.XAxis';
758
+ LineChartXAxis.layer = 'overlay' as Layer;
759
+
760
+ export interface LineChartTooltipProps {
761
+ color?: string;
762
+ /**
763
+ * Float a small label at the crosshair showing the x-value and each series'
764
+ * value at that point — the minimal readout a drag wants. On by default.
765
+ */
766
+ showLabel?: boolean;
767
+ /** Format one series' value for the label. Defaults to a compact number. */
768
+ formatValue?: (value: number, key: string) => string;
769
+ /** Format the label's heading from the row. Defaults to the value at xDataKey. */
770
+ formatX?: (datum: LineChartDatum) => string;
771
+ }
772
+
773
+ /**
774
+ * The crosshair, the gesture that drives it, and the label that rides it.
775
+ *
776
+ * They live in the view layer: a gesture handler cannot be attached to an SVG
777
+ * node, and once the gesture is a view the crosshair may as well be one too —
778
+ * a 1px view moved by `translateX` costs less than re-rendering an SVG line.
779
+ * The label follows on the UI thread the same way; only its *text* crosses back
780
+ * into JS, and only when the active index changes.
781
+ *
782
+ * The hit area is the whole plot. A crosshair you have to land on the line to
783
+ * summon is a crosshair nobody finds.
784
+ */
785
+ function LineChartTooltip({ color, showLabel = true, formatValue, formatX }: LineChartTooltipProps) {
786
+ const {
787
+ data,
788
+ xDataKey,
789
+ plot,
790
+ domainMin,
791
+ domainMax,
792
+ series,
793
+ activeIndex,
794
+ activeIndexJS,
795
+ setActiveIndexJS,
796
+ status,
797
+ } = useChart('LineChart.Tooltip');
798
+ const token = useCSSVariable('--color-foreground');
799
+ const stroke = color ?? (typeof token === 'string' ? token : '#888888');
800
+
801
+ const total = data.length;
802
+ const left = plot.left;
803
+ const width = plot.width;
804
+
805
+ /*
806
+ * Built in one closure, and everything it captures is a number or a shared
807
+ * value. A worklet may only call another worklet, and the rule is enforced by
808
+ * crashing the app rather than by warning — so the resolver is declared here,
809
+ * next to its callers, instead of as a helper further down the file where it
810
+ * would be easy to leave un-workletised.
811
+ */
812
+ const pan = useMemo(() => {
813
+ const resolve = (x: number) => {
814
+ 'worklet';
815
+ if (total < 2 || width <= 0) return;
816
+ const ratio = (x - left) / width;
817
+ const next = Math.round(Math.min(1, Math.max(0, ratio)) * (total - 1));
818
+ if (next === activeIndex.value) return;
819
+ activeIndex.value = next;
820
+ // Only the index needs JS, and only when it changes — a drag across a
821
+ // hundred points costs a hundred re-renders at most, not one per frame
822
+ // for the length of the gesture.
823
+ runOnJS(setActiveIndexJS)(next);
824
+ };
825
+
826
+ return Gesture.Pan()
827
+ .minDistance(0)
828
+ .onBegin((event) => {
829
+ 'worklet';
830
+ resolve(event.x);
831
+ })
832
+ .onUpdate((event) => {
833
+ 'worklet';
834
+ resolve(event.x);
835
+ })
836
+ .onFinalize(() => {
837
+ 'worklet';
838
+ activeIndex.value = -1;
839
+ runOnJS(setActiveIndexJS)(-1);
840
+ });
841
+ }, [total, left, width, activeIndex, setActiveIndexJS]);
842
+
843
+ const crosshairStyle = useAnimatedStyle(() => {
844
+ const index = activeIndex.value;
845
+ return {
846
+ opacity: index < 0 ? 0 : 0.45,
847
+ transform: [{ translateX: index < 0 ? 0 : xOf(index, total, plot) }],
848
+ };
849
+ });
850
+
851
+ // The label centres over the crosshair but is clamped inside the plot, so it
852
+ // never runs off the edge at the first or last point.
853
+ const labelStyle = useAnimatedStyle(() => {
854
+ const index = activeIndex.value;
855
+ if (index < 0) return { opacity: 0 };
856
+ const x = xOf(index, total, plot);
857
+ const half = LABEL_WIDTH / 2;
858
+ const clamped = Math.min(
859
+ plot.left + plot.width - half,
860
+ Math.max(plot.left + half, x)
861
+ );
862
+ return {
863
+ opacity: 1,
864
+ transform: [{ translateX: clamped - half }],
865
+ };
866
+ });
867
+
868
+ const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;
869
+ const fmtValue =
870
+ formatValue ?? ((value: number) => compactNumber(value));
871
+ const fmtX =
872
+ formatX ?? ((datum: LineChartDatum) => String(datum[xDataKey] ?? ''));
873
+
874
+ if (status === 'loading') return null;
875
+
876
+ return (
877
+ <GestureDetector gesture={pan}>
878
+ <View style={StyleSheet.absoluteFill}>
879
+ <Animated.View
880
+ pointerEvents="none"
881
+ style={[
882
+ {
883
+ position: 'absolute',
884
+ left: 0,
885
+ top: plot.top,
886
+ width: 1,
887
+ height: plot.height,
888
+ backgroundColor: stroke,
889
+ },
890
+ crosshairStyle,
891
+ ]}
892
+ />
893
+ {series.map(([key, seriesColor]) => (
894
+ <TooltipDot
895
+ key={key}
896
+ values={numbersOf(data, key)}
897
+ plot={plot}
898
+ domainMin={domainMin}
899
+ domainMax={domainMax}
900
+ activeIndex={activeIndex}
901
+ color={seriesColor}
902
+ />
903
+ ))}
904
+
905
+ {showLabel ? (
906
+ <Animated.View
907
+ pointerEvents="none"
908
+ style={[
909
+ {
910
+ position: 'absolute',
911
+ left: 0,
912
+ top: Math.max(plot.top - 4, 0),
913
+ width: LABEL_WIDTH,
914
+ },
915
+ labelStyle,
916
+ ]}
917
+ >
918
+ <View className="items-center rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg">
919
+ {active ? (
920
+ <>
921
+ <Text size="xs" muted numberOfLines={1}>
922
+ {fmtX(active)}
923
+ </Text>
924
+ {series.map(([key, seriesColor]) => {
925
+ const value = active[key];
926
+ if (typeof value !== 'number') return null;
927
+ return (
928
+ <View key={key} className="flex-row items-center gap-1.5">
929
+ {series.length > 1 ? (
930
+ <View
931
+ style={{ backgroundColor: seriesColor }}
932
+ className="h-1.5 w-1.5 rounded-full"
933
+ />
934
+ ) : null}
935
+ <Text size="sm" weight="semibold" numberOfLines={1}>
936
+ {fmtValue(value, key)}
937
+ </Text>
938
+ </View>
939
+ );
940
+ })}
941
+ </>
942
+ ) : null}
943
+ </View>
944
+ </Animated.View>
945
+ ) : null}
946
+ </View>
947
+ </GestureDetector>
948
+ );
949
+ }
950
+ LineChartTooltip.displayName = 'LineChart.Tooltip';
951
+ LineChartTooltip.layer = 'overlay' as Layer;
952
+
953
+ const LABEL_WIDTH = 112;
954
+
955
+ /** A compact number for the crosshair label: 1204 → "1.2k", 24801 → "24.8k". */
956
+ function compactNumber(value: number): string {
957
+ const abs = Math.abs(value);
958
+ if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
959
+ if (abs >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
960
+ return `${Math.round(value)}`;
961
+ }
962
+
963
+ const DOT = 9;
964
+
965
+ /** The dot riding one series under the crosshair. */
966
+ function TooltipDot({
967
+ values,
968
+ plot,
969
+ domainMin,
970
+ domainMax,
971
+ activeIndex,
972
+ color,
973
+ }: {
974
+ values: (number | null)[];
975
+ plot: Plot;
976
+ domainMin: SharedValue<number>;
977
+ domainMax: SharedValue<number>;
978
+ activeIndex: SharedValue<number>;
979
+ color: string;
980
+ }) {
981
+ const style = useAnimatedStyle(() => {
982
+ const index = activeIndex.value;
983
+ const value = index < 0 ? null : values[index];
984
+ if (index < 0 || value === null || value === undefined) return { opacity: 0 };
985
+ return {
986
+ opacity: 1,
987
+ transform: [
988
+ { translateX: xOf(index, values.length, plot) - DOT / 2 },
989
+ { translateY: yOf(value, plot, domainMin.value, domainMax.value) - DOT / 2 },
990
+ ],
991
+ };
992
+ });
993
+
994
+ return (
995
+ <Animated.View
996
+ pointerEvents="none"
997
+ style={[
998
+ {
999
+ position: 'absolute',
1000
+ left: 0,
1001
+ top: 0,
1002
+ width: DOT,
1003
+ height: DOT,
1004
+ borderRadius: DOT / 2,
1005
+ backgroundColor: color,
1006
+ },
1007
+ style,
1008
+ ]}
1009
+ />
1010
+ );
1011
+ }
1012
+
1013
+ export interface LineChartLegendProps extends ViewProps {
1014
+ className?: string;
1015
+ /** Label per series key. A key with no label falls back to the key itself. */
1016
+ labels?: Record<string, string>;
1017
+ }
1018
+
1019
+ /**
1020
+ * A swatch and a name per registered series. Sits in the top-left of the plot
1021
+ * by default — move it with `className`.
1022
+ */
1023
+ function LineChartLegend({ className, labels, ...props }: LineChartLegendProps) {
1024
+ const { series } = useChart('LineChart.Legend');
1025
+
1026
+ return (
1027
+ <View
1028
+ pointerEvents="none"
1029
+ className={cn('absolute left-2.5 top-0 flex-row flex-wrap items-center gap-4', className)}
1030
+ {...props}
1031
+ >
1032
+ {series.map(([key, color]) => (
1033
+ <View key={key} className="flex-row items-center gap-1.5">
1034
+ <View style={{ backgroundColor: color }} className="h-2 w-2 rounded-full" />
1035
+ <Text size="xs" muted>
1036
+ {labels?.[key] ?? key}
1037
+ </Text>
1038
+ </View>
1039
+ ))}
1040
+ </View>
1041
+ );
1042
+ }
1043
+ LineChartLegend.displayName = 'LineChart.Legend';
1044
+ LineChartLegend.layer = 'overlay' as Layer;
1045
+
1046
+ /* -------------------------------------------------------------------------- */
1047
+ /* Scales and paths */
1048
+ /* -------------------------------------------------------------------------- */
1049
+
1050
+ /** Only reached if the theme CSS was never imported. */
1051
+ const FALLBACK_SERIES = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899'];
1052
+
1053
+ /** Series colour: an explicit one, else the `--color-chart-*` token. */
1054
+ function useSeriesColor(explicit: string | undefined, index: 1 | 2 | 3 | 4 | 5): string {
1055
+ const token = useCSSVariable(`--color-chart-${index}`);
1056
+ return explicit ?? (typeof token === 'string' ? token : FALLBACK_SERIES[index - 1]!);
1057
+ }
1058
+
1059
+ function numbersOf(data: LineChartDatum[], key: string): (number | null)[] {
1060
+ return data.map((row) => {
1061
+ const value = row[key];
1062
+ return typeof value === 'number' && !Number.isNaN(value) ? value : null;
1063
+ });
1064
+ }
1065
+
1066
+ function xOf(index: number, total: number, plot: Plot): number {
1067
+ 'worklet';
1068
+ if (total <= 1) return plot.left + plot.width / 2;
1069
+ return plot.left + (plot.width * index) / (total - 1);
1070
+ }
1071
+
1072
+ function yOf(value: number, plot: Plot, min: number, max: number): number {
1073
+ 'worklet';
1074
+ const span = max - min || 1;
1075
+ return plot.top + plot.height - ((value - min) / span) * plot.height;
1076
+ }
1077
+
1078
+ /**
1079
+ * Monotone cubic tangents.
1080
+ *
1081
+ * Written out rather than taken from a charting dependency, because it is forty
1082
+ * lines and it has to run inside a worklet — the path is rebuilt on the UI
1083
+ * thread on every frame the y-domain is tweening.
1084
+ *
1085
+ * Monotone is the right default for a time series. A plain cubic spline
1086
+ * overshoots between points, so a series that never goes below zero draws a dip
1087
+ * under the axis between two low values — a shape that is not in the data. This
1088
+ * one cannot, because the tangent at each point is clamped against the slopes
1089
+ * either side of it, and a local peak or trough is given a flat one.
1090
+ */
1091
+ function tangents(xs: number[], ys: number[]): number[] {
1092
+ 'worklet';
1093
+ const n = xs.length;
1094
+ const slopes: number[] = [];
1095
+ for (let i = 0; i < n - 1; i += 1) {
1096
+ slopes.push((ys[i + 1]! - ys[i]!) / (xs[i + 1]! - xs[i]! || 1));
1097
+ }
1098
+
1099
+ const result: number[] = new Array(n).fill(0);
1100
+ result[0] = slopes[0] ?? 0;
1101
+ result[n - 1] = slopes[n - 2] ?? 0;
1102
+
1103
+ for (let i = 1; i < n - 1; i += 1) {
1104
+ const previous = slopes[i - 1]!;
1105
+ const next = slopes[i]!;
1106
+ result[i] = previous * next <= 0 ? 0 : (previous + next) / 2;
1107
+ }
1108
+
1109
+ // Fritsch–Carlson: pull any tangent back inside the circle of radius 3, which
1110
+ // is the condition for the segment to stay monotone.
1111
+ for (let i = 0; i < n - 1; i += 1) {
1112
+ const slope = slopes[i]!;
1113
+ if (slope === 0) {
1114
+ result[i] = 0;
1115
+ result[i + 1] = 0;
1116
+ continue;
1117
+ }
1118
+ const a = result[i]! / slope;
1119
+ const b = result[i + 1]! / slope;
1120
+ const magnitude = Math.sqrt(a * a + b * b);
1121
+ if (magnitude > 3) {
1122
+ result[i] = (3 / magnitude) * a * slope;
1123
+ result[i + 1] = (3 / magnitude) * b * slope;
1124
+ }
1125
+ }
1126
+
1127
+ return result;
1128
+ }
1129
+
1130
+ /** Points, split at the gaps — a null breaks the series rather than crossing it. */
1131
+ function runsOf(
1132
+ values: (number | null)[],
1133
+ plot: Plot,
1134
+ min: number,
1135
+ max: number
1136
+ ): { x: number; y: number }[][] {
1137
+ 'worklet';
1138
+ const runs: { x: number; y: number }[][] = [];
1139
+ let run: { x: number; y: number }[] = [];
1140
+
1141
+ for (let i = 0; i < values.length; i += 1) {
1142
+ const value = values[i];
1143
+ if (value === null || value === undefined) {
1144
+ if (run.length) runs.push(run);
1145
+ run = [];
1146
+ continue;
1147
+ }
1148
+ run.push({ x: xOf(i, values.length, plot), y: yOf(value, plot, min, max) });
1149
+ }
1150
+ if (run.length) runs.push(run);
1151
+ return runs;
1152
+ }
1153
+
1154
+ function segment(points: { x: number; y: number }[], curve: LineChartCurve): string {
1155
+ 'worklet';
1156
+ if (!points.length) return '';
1157
+ if (points.length < 3 || curve === 'linear') {
1158
+ return points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
1159
+ }
1160
+
1161
+ const xs = points.map((p) => p.x);
1162
+ const ys = points.map((p) => p.y);
1163
+ const ms = tangents(xs, ys);
1164
+
1165
+ let d = `M${xs[0]},${ys[0]}`;
1166
+ for (let i = 0; i < points.length - 1; i += 1) {
1167
+ const dx = (xs[i + 1]! - xs[i]!) / 3;
1168
+ d += ` C${xs[i]! + dx},${ys[i]! + ms[i]! * dx} ${xs[i + 1]! - dx},${ys[i + 1]! - ms[i + 1]! * dx} ${xs[i + 1]},${ys[i + 1]}`;
1169
+ }
1170
+ return d;
1171
+ }
1172
+
1173
+ function linePath(
1174
+ values: (number | null)[],
1175
+ plot: Plot,
1176
+ min: number,
1177
+ max: number,
1178
+ curve: LineChartCurve,
1179
+ loading: boolean
1180
+ ): string {
1181
+ 'worklet';
1182
+ if (loading || plot.width <= 0) {
1183
+ // Flat down the middle: the shape the skeleton holds, and the shape the
1184
+ // real series grows out of once the data arrives.
1185
+ const y = plot.top + plot.height / 2;
1186
+ return `M${plot.left},${y} L${plot.left + plot.width},${y}`;
1187
+ }
1188
+ return runsOf(values, plot, min, max)
1189
+ .map((run) => segment(run, curve))
1190
+ .join(' ');
1191
+ }
1192
+
1193
+ function areaPath(
1194
+ values: (number | null)[],
1195
+ plot: Plot,
1196
+ min: number,
1197
+ max: number,
1198
+ curve: LineChartCurve,
1199
+ loading: boolean
1200
+ ): string {
1201
+ 'worklet';
1202
+ if (loading || plot.width <= 0) return '';
1203
+
1204
+ const baseline = plot.top + plot.height;
1205
+ return runsOf(values, plot, min, max)
1206
+ .map((run) => {
1207
+ const top = segment(run, curve);
1208
+ if (!top) return '';
1209
+ const first = run[0]!;
1210
+ const last = run[run.length - 1]!;
1211
+ return `${top} L${last.x},${baseline} L${first.x},${baseline} Z`;
1212
+ })
1213
+ .join(' ');
1214
+ }
1215
+
1216
+ export const LineChart = Object.assign(LineChartRoot, {
1217
+ Grid: LineChartGrid,
1218
+ Area: LineChartArea,
1219
+ Line: LineChartLine,
1220
+ Skeleton: LineChartSkeleton,
1221
+ XAxis: LineChartXAxis,
1222
+ Tooltip: LineChartTooltip,
1223
+ Legend: LineChartLegend,
1224
+ });