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