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