panelui-native 0.6.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.
- package/README.md +1 -1
- package/lib/module/components/accordion/index.js +7 -5
- package/lib/module/components/accordion/index.js.map +1 -1
- package/lib/module/components/alert/index.js +3 -6
- package/lib/module/components/alert/index.js.map +1 -1
- package/lib/module/components/attachment/index.js +219 -0
- package/lib/module/components/attachment/index.js.map +1 -0
- package/lib/module/components/bottom-sheet/index.js +82 -4
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/button/index.js +36 -1
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/dialog/index.js +6 -2
- package/lib/module/components/dialog/index.js.map +1 -1
- package/lib/module/components/empty-state/index.js +98 -22
- package/lib/module/components/empty-state/index.js.map +1 -1
- package/lib/module/components/inline-select/index.js +171 -46
- package/lib/module/components/inline-select/index.js.map +1 -1
- package/lib/module/components/input/index.js +28 -5
- package/lib/module/components/input/index.js.map +1 -1
- package/lib/module/components/input-group/index.js +0 -1
- package/lib/module/components/input-group/index.js.map +1 -1
- package/lib/module/components/item/index.js +359 -0
- package/lib/module/components/item/index.js.map +1 -0
- package/lib/module/components/label/index.js +5 -3
- package/lib/module/components/label/index.js.map +1 -1
- package/lib/module/components/line-chart/index.js +1024 -0
- package/lib/module/components/line-chart/index.js.map +1 -0
- package/lib/module/components/marker/index.js +190 -0
- package/lib/module/components/marker/index.js.map +1 -0
- package/lib/module/components/message/index.js +334 -0
- package/lib/module/components/message/index.js.map +1 -0
- package/lib/module/components/message-scroller/index.js +451 -0
- package/lib/module/components/message-scroller/index.js.map +1 -0
- package/lib/module/components/popover/index.js +500 -0
- package/lib/module/components/popover/index.js.map +1 -0
- package/lib/module/components/radio-group/index.js +104 -16
- package/lib/module/components/radio-group/index.js.map +1 -1
- package/lib/module/components/scroll-fade/index.js +119 -71
- package/lib/module/components/scroll-fade/index.js.map +1 -1
- package/lib/module/components/select/index.js +35 -6
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/separator/index.js +91 -0
- package/lib/module/components/separator/index.js.map +1 -0
- package/lib/module/components/shimmer/index.js +111 -64
- package/lib/module/components/shimmer/index.js.map +1 -1
- package/lib/module/components/surface/index.js +24 -4
- package/lib/module/components/surface/index.js.map +1 -1
- package/lib/module/components/switch/index.js +20 -1
- package/lib/module/components/switch/index.js.map +1 -1
- package/lib/module/components/toast/index.js +7 -8
- package/lib/module/components/toast/index.js.map +1 -1
- package/lib/module/components/typography/index.js +3 -3
- package/lib/module/hooks/index.js +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/use-breakpoint.js +2 -2
- package/lib/module/hooks/use-copy-to-clipboard.js +0 -2
- package/lib/module/hooks/use-copy-to-clipboard.js.map +1 -1
- package/lib/module/hooks/use-keyboard-avoidance.js +120 -0
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -0
- package/lib/module/icons/index.js +73 -4
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +13 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js +53 -0
- package/lib/module/native/index.js.map +1 -0
- package/lib/module/primitives/keyboard-avoider.js +40 -0
- package/lib/module/primitives/keyboard-avoider.js.map +1 -0
- package/lib/module/primitives/scrim.js +72 -0
- package/lib/module/primitives/scrim.js.map +1 -0
- package/lib/module/providers/panel-ui-provider.js +24 -4
- package/lib/module/providers/panel-ui-provider.js.map +1 -1
- package/lib/module/theme/use-theme.js +1 -1
- package/lib/typescript/src/components/accordion/index.d.ts +7 -5
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
- package/lib/typescript/src/components/alert/index.d.ts +3 -6
- package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
- package/lib/typescript/src/components/attachment/index.d.ts +93 -0
- package/lib/typescript/src/components/attachment/index.d.ts.map +1 -0
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +27 -2
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +11 -0
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/dialog/index.d.ts +7 -1
- package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
- package/lib/typescript/src/components/empty-state/index.d.ts +156 -7
- package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
- package/lib/typescript/src/components/inline-select/index.d.ts +29 -6
- package/lib/typescript/src/components/inline-select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input/index.d.ts +13 -0
- package/lib/typescript/src/components/input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input-group/index.d.ts +0 -1
- package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/item/index.d.ts +286 -0
- package/lib/typescript/src/components/item/index.d.ts.map +1 -0
- package/lib/typescript/src/components/label/index.d.ts +5 -3
- package/lib/typescript/src/components/label/index.d.ts.map +1 -1
- package/lib/typescript/src/components/line-chart/index.d.ts +247 -0
- package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/marker/index.d.ts +137 -0
- package/lib/typescript/src/components/marker/index.d.ts.map +1 -0
- package/lib/typescript/src/components/message/index.d.ts +177 -0
- package/lib/typescript/src/components/message/index.d.ts.map +1 -0
- package/lib/typescript/src/components/message-scroller/index.d.ts +150 -0
- package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -0
- package/lib/typescript/src/components/popover/index.d.ts +130 -0
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -0
- package/lib/typescript/src/components/radio-group/index.d.ts +32 -0
- package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/scroll-fade/index.d.ts +18 -9
- package/lib/typescript/src/components/scroll-fade/index.d.ts.map +1 -1
- package/lib/typescript/src/components/select/index.d.ts +16 -1
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/separator/index.d.ts +47 -0
- package/lib/typescript/src/components/separator/index.d.ts.map +1 -0
- package/lib/typescript/src/components/shimmer/index.d.ts +38 -20
- package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -1
- package/lib/typescript/src/components/surface/index.d.ts +51 -2
- package/lib/typescript/src/components/surface/index.d.ts.map +1 -1
- package/lib/typescript/src/components/switch/index.d.ts +10 -0
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
- package/lib/typescript/src/components/toast/index.d.ts +4 -4
- package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/index.d.ts +1 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-breakpoint.d.ts +2 -2
- package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts +0 -2
- package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +20 -0
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +10 -4
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +11 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +35 -0
- package/lib/typescript/src/native/index.d.ts.map +1 -0
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts +22 -0
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -0
- package/lib/typescript/src/primitives/scrim.d.ts +21 -0
- package/lib/typescript/src/primitives/scrim.d.ts.map +1 -0
- package/lib/typescript/src/providers/panel-ui-provider.d.ts +1 -1
- package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
- package/lib/typescript/src/theme/use-theme.d.ts +1 -1
- package/package.json +16 -3
- package/src/components/accordion/index.tsx +7 -5
- package/src/components/alert/index.tsx +3 -6
- package/src/components/attachment/index.tsx +279 -0
- package/src/components/bottom-sheet/index.tsx +102 -2
- package/src/components/button/index.tsx +52 -0
- package/src/components/dialog/index.tsx +10 -1
- package/src/components/empty-state/index.tsx +64 -19
- package/src/components/inline-select/index.tsx +191 -44
- package/src/components/input/index.tsx +39 -3
- package/src/components/input-group/index.tsx +0 -1
- package/src/components/item/index.tsx +387 -0
- package/src/components/label/index.tsx +5 -3
- package/src/components/line-chart/index.tsx +1224 -0
- package/src/components/marker/index.tsx +194 -0
- package/src/components/message/index.tsx +336 -0
- package/src/components/message-scroller/index.tsx +574 -0
- package/src/components/popover/index.tsx +599 -0
- package/src/components/radio-group/index.tsx +108 -17
- package/src/components/scroll-fade/index.tsx +163 -51
- package/src/components/select/index.tsx +54 -9
- package/src/components/separator/index.tsx +97 -0
- package/src/components/shimmer/index.tsx +151 -58
- package/src/components/surface/index.tsx +32 -5
- package/src/components/switch/index.tsx +27 -1
- package/src/components/toast/index.tsx +7 -8
- package/src/components/typography/index.tsx +3 -3
- package/src/hooks/index.ts +5 -0
- package/src/hooks/use-breakpoint.ts +2 -2
- package/src/hooks/use-copy-to-clipboard.ts +0 -2
- package/src/hooks/use-keyboard-avoidance.ts +140 -0
- package/src/icons/index.tsx +52 -4
- package/src/index.ts +92 -0
- package/src/native/index.ts +79 -0
- package/src/primitives/keyboard-avoider.tsx +49 -0
- package/src/primitives/scrim.tsx +95 -0
- package/src/providers/panel-ui-provider.tsx +30 -9
- package/src/theme/use-theme.ts +1 -1
- package/theme.css +63 -3
|
@@ -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
|