panelui-native 0.60.0 → 0.62.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 +3 -1
- package/lib/module/components/area-chart/index.js +86 -1
- package/lib/module/components/area-chart/index.js.map +1 -1
- package/lib/module/components/bar-chart/index.js +112 -2
- package/lib/module/components/bar-chart/index.js.map +1 -1
- package/lib/module/components/live-line-chart/index.js +1063 -0
- package/lib/module/components/live-line-chart/index.js.map +1 -0
- package/lib/module/components/polar-area-chart/index.js +719 -0
- package/lib/module/components/polar-area-chart/index.js.map +1 -0
- package/lib/module/components/treemap-chart/index.js +957 -0
- package/lib/module/components/treemap-chart/index.js.map +1 -0
- package/lib/module/index.js +3 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/chart.js +171 -4
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/area-chart/index.d.ts +28 -1
- package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bar-chart/index.d.ts +33 -3
- package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/live-line-chart/index.d.ts +293 -0
- package/lib/typescript/src/components/live-line-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/polar-area-chart/index.d.ts +259 -0
- package/lib/typescript/src/components/polar-area-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/treemap-chart/index.d.ts +317 -0
- package/lib/typescript/src/components/treemap-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +27 -1
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/area-chart/index.tsx +86 -1
- package/src/components/bar-chart/index.tsx +115 -4
- package/src/components/live-line-chart/index.tsx +1294 -0
- package/src/components/polar-area-chart/index.tsx +928 -0
- package/src/components/treemap-chart/index.tsx +1175 -0
- package/src/index.ts +52 -0
- package/src/utils/chart.ts +202 -4
|
@@ -0,0 +1,1294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiveLineChart — a reading that keeps arriving, against a window that keeps
|
|
3
|
+
* moving.
|
|
4
|
+
*
|
|
5
|
+
* ```tsx
|
|
6
|
+
* <LiveLineChart data={points} window={30}>
|
|
7
|
+
* <LiveLineChart.Grid />
|
|
8
|
+
* <LiveLineChart.Area />
|
|
9
|
+
* <LiveLineChart.Line />
|
|
10
|
+
* <LiveLineChart.Tip />
|
|
11
|
+
* <LiveLineChart.XAxis />
|
|
12
|
+
* </LiveLineChart>
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* ## What it is, against the line chart beside it
|
|
16
|
+
*
|
|
17
|
+
* `LineChart` places a point by its position in the list, which is right for a
|
|
18
|
+
* series of twelve months whatever the gaps between the dates behind them. Here
|
|
19
|
+
* a point is placed at the time it carries, against a domain that runs from
|
|
20
|
+
* `window` seconds ago to now — so the gaps are the subject, and a reading that
|
|
21
|
+
* arrived late sits where it arrived rather than one slot along.
|
|
22
|
+
*
|
|
23
|
+
* ## The clock, and what it costs
|
|
24
|
+
*
|
|
25
|
+
* This is the only thing in the library that animates without an interaction or
|
|
26
|
+
* a change of data. The window is tied to the wall clock, so the line drifts
|
|
27
|
+
* left whether or not anything is arriving, and a feed that stalls shows as a
|
|
28
|
+
* flat run reaching back from the tip rather than as a chart that has frozen.
|
|
29
|
+
* Those two look identical if the window only moves when a point lands, and
|
|
30
|
+
* they mean opposite things.
|
|
31
|
+
*
|
|
32
|
+
* The cost is a frame callback for as long as the chart is mounted. It is
|
|
33
|
+
* stopped by `paused`, by `status="loading"` and on unmount, and it is never
|
|
34
|
+
* started when the platform asks for reduced motion — in that case the window
|
|
35
|
+
* advances as each point arrives instead, which is the same picture sampled
|
|
36
|
+
* less often.
|
|
37
|
+
*
|
|
38
|
+
* ## Colour follows the recent direction
|
|
39
|
+
*
|
|
40
|
+
* With `momentumColors` set, the line, the fill and the tip take their colour
|
|
41
|
+
* from where the reading has been going rather than from a fixed hue. It is the
|
|
42
|
+
* one thing on the chart readable without looking at the axis, which is what a
|
|
43
|
+
* number being watched out of the corner of an eye needs.
|
|
44
|
+
*/
|
|
45
|
+
import {
|
|
46
|
+
Children,
|
|
47
|
+
createContext,
|
|
48
|
+
forwardRef,
|
|
49
|
+
isValidElement,
|
|
50
|
+
useContext,
|
|
51
|
+
useEffect,
|
|
52
|
+
useId,
|
|
53
|
+
useImperativeHandle,
|
|
54
|
+
useMemo,
|
|
55
|
+
useRef,
|
|
56
|
+
useState,
|
|
57
|
+
type ReactNode,
|
|
58
|
+
} from 'react';
|
|
59
|
+
import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
60
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
61
|
+
import Animated, {
|
|
62
|
+
runOnJS,
|
|
63
|
+
useAnimatedProps,
|
|
64
|
+
useAnimatedStyle,
|
|
65
|
+
useFrameCallback,
|
|
66
|
+
useReducedMotion,
|
|
67
|
+
useSharedValue,
|
|
68
|
+
withRepeat,
|
|
69
|
+
withTiming,
|
|
70
|
+
type SharedValue,
|
|
71
|
+
} from 'react-native-reanimated';
|
|
72
|
+
import Svg, {
|
|
73
|
+
ClipPath,
|
|
74
|
+
Defs,
|
|
75
|
+
G,
|
|
76
|
+
LinearGradient,
|
|
77
|
+
Path,
|
|
78
|
+
Rect,
|
|
79
|
+
Stop,
|
|
80
|
+
Line as SvgLine,
|
|
81
|
+
} from 'react-native-svg';
|
|
82
|
+
import { useCSSVariable } from 'uniwind';
|
|
83
|
+
import { Text } from '../../primitives/text';
|
|
84
|
+
import {
|
|
85
|
+
compactNumber,
|
|
86
|
+
segment,
|
|
87
|
+
useSeriesColor,
|
|
88
|
+
xAt,
|
|
89
|
+
yOf,
|
|
90
|
+
type ChartCurve,
|
|
91
|
+
type ChartPoint,
|
|
92
|
+
type Plot,
|
|
93
|
+
} from '../../utils/chart';
|
|
94
|
+
import { cn } from '../../utils/cn';
|
|
95
|
+
|
|
96
|
+
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
97
|
+
|
|
98
|
+
/** Room left around the plot for the axis labels. */
|
|
99
|
+
const PADDING = { top: 12, right: 14, bottom: 22, left: 10 };
|
|
100
|
+
|
|
101
|
+
/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */
|
|
102
|
+
const Y_AXIS_WIDTH = 44;
|
|
103
|
+
|
|
104
|
+
/** Gap between the value labels and the plot they sit beside. */
|
|
105
|
+
const Y_AXIS_GUTTER = 6;
|
|
106
|
+
|
|
107
|
+
/** Diameter of the dot at the leading end of the line. */
|
|
108
|
+
const TIP = 8;
|
|
109
|
+
|
|
110
|
+
/** How far the pulse ring grows past the dot. */
|
|
111
|
+
const PULSE = 3.2;
|
|
112
|
+
|
|
113
|
+
/** Line height of an `xs` label, for centring one on the line it names. */
|
|
114
|
+
const AXIS_LABEL_HEIGHT = 16;
|
|
115
|
+
|
|
116
|
+
const READOUT_WIDTH = 116;
|
|
117
|
+
|
|
118
|
+
/** Which layer a part belongs to. Read off the component itself. */
|
|
119
|
+
type Layer = 'svg' | 'overlay' | 'header';
|
|
120
|
+
|
|
121
|
+
/** Whether the chart is showing data or waiting for it. */
|
|
122
|
+
export type LiveLineChartStatus = 'loading' | 'ready';
|
|
123
|
+
|
|
124
|
+
/** Where the reading has been going, over the last few points. */
|
|
125
|
+
export type LiveLineMomentum = 'up' | 'down' | 'flat';
|
|
126
|
+
|
|
127
|
+
/** A colour per direction, for a chart that is read by its colour. */
|
|
128
|
+
export interface LiveLineMomentumColors {
|
|
129
|
+
up?: string;
|
|
130
|
+
down?: string;
|
|
131
|
+
flat?: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** One reading. `time` is a timestamp in milliseconds, as `Date.now()` gives. */
|
|
135
|
+
export interface LiveLinePoint {
|
|
136
|
+
time: number;
|
|
137
|
+
value: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface LiveLineChartContextValue {
|
|
141
|
+
plot: Plot;
|
|
142
|
+
status: LiveLineChartStatus;
|
|
143
|
+
curve: ChartCurve;
|
|
144
|
+
/** Timestamps and readings, as two number arrays — cheaper to hand the UI
|
|
145
|
+
* thread than an array of objects, and every worklet here walks them in step. */
|
|
146
|
+
times: SharedValue<number[]>;
|
|
147
|
+
values: SharedValue<number[]>;
|
|
148
|
+
/** The right-hand edge of the window, in milliseconds. */
|
|
149
|
+
now: SharedValue<number>;
|
|
150
|
+
/** How much time the plot spans, in milliseconds. */
|
|
151
|
+
windowMs: number;
|
|
152
|
+
/** Tweened y-domain. Read inside worklets to build the paths. */
|
|
153
|
+
domainMin: SharedValue<number>;
|
|
154
|
+
domainMax: SharedValue<number>;
|
|
155
|
+
/** What the tween is heading for. The axis labels read this rather than the
|
|
156
|
+
* shared values, which would re-render a label on every frame of a tween. */
|
|
157
|
+
extent: [number, number];
|
|
158
|
+
/** The moment under the finger, or -1. Pinned to a time rather than to a
|
|
159
|
+
* position, so the crosshair travels with the reading it named. */
|
|
160
|
+
activeTime: SharedValue<number>;
|
|
161
|
+
activePoint: LiveLinePoint | null;
|
|
162
|
+
setActivePoint: (point: LiveLinePoint | null) => void;
|
|
163
|
+
/** The latest reading, for anything drawing the leading end. */
|
|
164
|
+
latest: LiveLinePoint | null;
|
|
165
|
+
momentum: LiveLineMomentum;
|
|
166
|
+
color: string;
|
|
167
|
+
clipId: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const LiveLineChartContext = createContext<LiveLineChartContextValue | null>(null);
|
|
171
|
+
|
|
172
|
+
function useChart(component: string): LiveLineChartContextValue {
|
|
173
|
+
const context = useContext(LiveLineChartContext);
|
|
174
|
+
if (!context) {
|
|
175
|
+
throw new Error(`${component} must be used within a <LiveLineChart>`);
|
|
176
|
+
}
|
|
177
|
+
return context;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The reading under the crosshair, for something rendered inside the chart. */
|
|
181
|
+
export function useLiveLineChart() {
|
|
182
|
+
const { activePoint, latest, momentum, color } = useChart('useLiveLineChart');
|
|
183
|
+
return { activePoint, latest, momentum, color };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The visible run of readings, as coordinates.
|
|
188
|
+
*
|
|
189
|
+
* It starts one point *before* the window rather than at the first one inside
|
|
190
|
+
* it, so the line enters from the left edge instead of beginning wherever the
|
|
191
|
+
* oldest surviving reading happens to sit. The plot is clipped, so the part
|
|
192
|
+
* hanging off the edge is never drawn.
|
|
193
|
+
*/
|
|
194
|
+
function runOf(
|
|
195
|
+
times: number[],
|
|
196
|
+
values: number[],
|
|
197
|
+
plot: Plot,
|
|
198
|
+
xMin: number,
|
|
199
|
+
xMax: number,
|
|
200
|
+
yMin: number,
|
|
201
|
+
yMax: number
|
|
202
|
+
): ChartPoint[] {
|
|
203
|
+
'worklet';
|
|
204
|
+
const out: ChartPoint[] = [];
|
|
205
|
+
const count = times.length;
|
|
206
|
+
if (!count) return out;
|
|
207
|
+
|
|
208
|
+
let start = 0;
|
|
209
|
+
for (let i = 0; i < count; i += 1) {
|
|
210
|
+
if (times[i]! >= xMin) break;
|
|
211
|
+
start = i;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (let i = start; i < count; i += 1) {
|
|
215
|
+
const time = times[i]!;
|
|
216
|
+
if (time > xMax) break;
|
|
217
|
+
out.push({
|
|
218
|
+
x: xAt(time, plot, xMin, xMax),
|
|
219
|
+
y: yOf(values[i]!, plot, yMin, yMax),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface LiveLineChartProps extends ViewProps {
|
|
226
|
+
className?: string;
|
|
227
|
+
/** The readings so far, oldest first. Append to it as they arrive. */
|
|
228
|
+
data: LiveLinePoint[];
|
|
229
|
+
/** How much time the plot spans, in seconds. */
|
|
230
|
+
window?: number;
|
|
231
|
+
/** Freeze the window where it is. The readings still arrive; the clock stops. */
|
|
232
|
+
paused?: boolean;
|
|
233
|
+
/** Fix the y-axis instead of deriving it from what is visible. */
|
|
234
|
+
yDomain?: [number, number];
|
|
235
|
+
/** Milliseconds for the y-axis to settle after the range changes. */
|
|
236
|
+
domainDuration?: number;
|
|
237
|
+
/** `monotone` never overshoots between readings; `linear` joins them straight. */
|
|
238
|
+
curve?: ChartCurve;
|
|
239
|
+
/**
|
|
240
|
+
* The most readings kept. Older ones are dropped, since they are off the
|
|
241
|
+
* window and cannot come back — an unbounded feed otherwise grows an array
|
|
242
|
+
* for as long as the screen is open.
|
|
243
|
+
*/
|
|
244
|
+
maxPoints?: number;
|
|
245
|
+
/** Width ÷ height of the plot. */
|
|
246
|
+
aspectRatio?: number;
|
|
247
|
+
/** `loading` draws a flat placeholder and holds the clock. */
|
|
248
|
+
status?: LiveLineChartStatus;
|
|
249
|
+
/** Colour per direction. Left out, the chart draws in one hue throughout. */
|
|
250
|
+
momentumColors?: LiveLineMomentumColors;
|
|
251
|
+
/** Overrides the `--color-chart-1` token. Ignored when `momentumColors` is set. */
|
|
252
|
+
color?: string;
|
|
253
|
+
/** The reading under the crosshair as it moves, and `null` when the finger lifts. */
|
|
254
|
+
onActivePointChange?: (point: LiveLinePoint | null) => void;
|
|
255
|
+
children?: ReactNode;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface LiveLineChartHandle {
|
|
259
|
+
/** Jump the window to the current moment — after a pause, or a background. */
|
|
260
|
+
sync: () => void;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
|
|
264
|
+
function LiveLineChartRoot(
|
|
265
|
+
{
|
|
266
|
+
className,
|
|
267
|
+
data,
|
|
268
|
+
window: windowSeconds = 30,
|
|
269
|
+
paused = false,
|
|
270
|
+
yDomain,
|
|
271
|
+
domainDuration = 420,
|
|
272
|
+
curve = 'monotone',
|
|
273
|
+
maxPoints = 500,
|
|
274
|
+
aspectRatio = 2,
|
|
275
|
+
status = 'ready',
|
|
276
|
+
momentumColors,
|
|
277
|
+
color,
|
|
278
|
+
onActivePointChange,
|
|
279
|
+
children,
|
|
280
|
+
...props
|
|
281
|
+
},
|
|
282
|
+
ref
|
|
283
|
+
) {
|
|
284
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
285
|
+
const [activePoint, setActivePointState] = useState<LiveLinePoint | null>(null);
|
|
286
|
+
const times = useSharedValue<number[]>([]);
|
|
287
|
+
const values = useSharedValue<number[]>([]);
|
|
288
|
+
const now = useSharedValue(Date.now());
|
|
289
|
+
const domainMin = useSharedValue(0);
|
|
290
|
+
const domainMax = useSharedValue(0);
|
|
291
|
+
const activeTime = useSharedValue(-1);
|
|
292
|
+
const reducedMotion = useReducedMotion();
|
|
293
|
+
// Stripped of punctuation: `useId` returns something like `:r1:`, and a
|
|
294
|
+
// colon inside a `url(#…)` reference does not resolve — which in RN SVG is
|
|
295
|
+
// a clip that silently does nothing rather than an error.
|
|
296
|
+
const clipId = `panelui-live-clip-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
297
|
+
|
|
298
|
+
const windowMs = Math.max(windowSeconds, 1) * 1000;
|
|
299
|
+
const loading = status === 'loading';
|
|
300
|
+
|
|
301
|
+
const hasYAxis = useMemo(() => {
|
|
302
|
+
let found = false;
|
|
303
|
+
Children.forEach(children, (child) => {
|
|
304
|
+
if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {
|
|
305
|
+
found = true;
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
return found;
|
|
309
|
+
}, [children]);
|
|
310
|
+
|
|
311
|
+
const pad = { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };
|
|
312
|
+
const plot: Plot = {
|
|
313
|
+
left: pad.left,
|
|
314
|
+
top: pad.top,
|
|
315
|
+
width: Math.max(size.width - pad.left - pad.right, 0),
|
|
316
|
+
height: Math.max(size.height - pad.top - pad.bottom, 0),
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/*
|
|
320
|
+
* Trimmed here rather than by the caller. A live feed is written by whoever
|
|
321
|
+
* owns the socket, and asking them to also bound the array is asking for
|
|
322
|
+
* the one chart nobody bounded to be the one left running overnight.
|
|
323
|
+
*/
|
|
324
|
+
const points = useMemo(
|
|
325
|
+
() => (data.length > maxPoints ? data.slice(data.length - maxPoints) : data),
|
|
326
|
+
[data, maxPoints]
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
useEffect(() => {
|
|
330
|
+
times.value = points.map((point) => point.time);
|
|
331
|
+
values.value = points.map((point) => point.value);
|
|
332
|
+
}, [points, times, values]);
|
|
333
|
+
|
|
334
|
+
const liveLatest = points.length ? points[points.length - 1]! : null;
|
|
335
|
+
|
|
336
|
+
/*
|
|
337
|
+
* The extent of what is *visible*, not of everything kept: a spike that has
|
|
338
|
+
* scrolled off the left edge should stop holding the axis open, or a feed
|
|
339
|
+
* settles back to a flat line squeezed against the bottom of a plot scaled
|
|
340
|
+
* for something that happened a minute ago.
|
|
341
|
+
*/
|
|
342
|
+
const liveExtent = useMemo<[number, number]>(() => {
|
|
343
|
+
if (yDomain) return yDomain;
|
|
344
|
+
const from = (liveLatest?.time ?? Date.now()) - windowMs;
|
|
345
|
+
let min = Infinity;
|
|
346
|
+
let max = -Infinity;
|
|
347
|
+
for (const point of points) {
|
|
348
|
+
if (point.time < from) continue;
|
|
349
|
+
if (point.value < min) min = point.value;
|
|
350
|
+
if (point.value > max) max = point.value;
|
|
351
|
+
}
|
|
352
|
+
if (min === Infinity) return [0, 1];
|
|
353
|
+
if (min === max) return [min - 1, max + 1];
|
|
354
|
+
const headroom = (max - min) * 0.12;
|
|
355
|
+
return [min - headroom, max + headroom];
|
|
356
|
+
}, [points, liveLatest, windowMs, yDomain]);
|
|
357
|
+
|
|
358
|
+
/*
|
|
359
|
+
* `paused` has to hold the whole picture still, not only the clock.
|
|
360
|
+
*
|
|
361
|
+
* Readings keep arriving while it is held — that is the point of holding it
|
|
362
|
+
* — and both of these are derived from the newest one. Left live, the axis
|
|
363
|
+
* goes on rescaling under a frozen line and the tip goes on chasing a
|
|
364
|
+
* reading that is now off the right-hand edge, so a held chart carries on
|
|
365
|
+
* moving in two of the three ways it can.
|
|
366
|
+
*/
|
|
367
|
+
const held = useRef<{ extent: [number, number]; latest: LiveLinePoint | null } | null>(
|
|
368
|
+
null
|
|
369
|
+
);
|
|
370
|
+
const heldMomentum = useRef<LiveLineMomentum>('flat');
|
|
371
|
+
if (!paused) held.current = { extent: liveExtent, latest: liveLatest };
|
|
372
|
+
|
|
373
|
+
const extent = paused && held.current ? held.current.extent : liveExtent;
|
|
374
|
+
const latest = paused && held.current ? held.current.latest : liveLatest;
|
|
375
|
+
|
|
376
|
+
useEffect(() => {
|
|
377
|
+
if (loading) return;
|
|
378
|
+
const [min, max] = extent;
|
|
379
|
+
// The first domain lands without a tween: there is no previous scale to
|
|
380
|
+
// move from, and animating up from zero reads as the numbers changing.
|
|
381
|
+
const first = domainMin.value === 0 && domainMax.value === 0;
|
|
382
|
+
if (first || reducedMotion) {
|
|
383
|
+
domainMin.value = min;
|
|
384
|
+
domainMax.value = max;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
domainMin.value = withTiming(min, { duration: domainDuration });
|
|
388
|
+
domainMax.value = withTiming(max, { duration: domainDuration });
|
|
389
|
+
}, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);
|
|
390
|
+
|
|
391
|
+
const frame = useFrameCallback(() => {
|
|
392
|
+
'worklet';
|
|
393
|
+
now.value = Date.now();
|
|
394
|
+
}, false);
|
|
395
|
+
|
|
396
|
+
const running = !paused && !loading && !reducedMotion;
|
|
397
|
+
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
frame.setActive(running);
|
|
400
|
+
return () => frame.setActive(false);
|
|
401
|
+
}, [frame, running]);
|
|
402
|
+
|
|
403
|
+
/*
|
|
404
|
+
* The reduced-motion path, and the pause that ends: the window still has to
|
|
405
|
+
* land on the present, it just does it when something arrives rather than
|
|
406
|
+
* sixty times a second.
|
|
407
|
+
*/
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
if (running) return;
|
|
410
|
+
if (paused) return;
|
|
411
|
+
now.value = Date.now();
|
|
412
|
+
}, [running, paused, points, now]);
|
|
413
|
+
|
|
414
|
+
useImperativeHandle(
|
|
415
|
+
ref,
|
|
416
|
+
() => ({
|
|
417
|
+
sync: () => {
|
|
418
|
+
now.value = Date.now();
|
|
419
|
+
},
|
|
420
|
+
}),
|
|
421
|
+
[now]
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const liveMomentum = useMemo<LiveLineMomentum>(() => {
|
|
425
|
+
if (points.length < 2) return 'flat';
|
|
426
|
+
const recent = points.slice(-6);
|
|
427
|
+
const span = Math.abs(liveExtent[1] - liveExtent[0]) || 1;
|
|
428
|
+
const change = (recent[recent.length - 1]!.value - recent[0]!.value) / span;
|
|
429
|
+
return change > 0.04 ? 'up' : change < -0.04 ? 'down' : 'flat';
|
|
430
|
+
}, [points, liveExtent]);
|
|
431
|
+
|
|
432
|
+
// Held with the rest of the picture: a frozen line changing colour under
|
|
433
|
+
// readings that are not on it is the same bug wearing a different coat.
|
|
434
|
+
if (!paused) heldMomentum.current = liveMomentum;
|
|
435
|
+
const momentum = paused ? heldMomentum.current : liveMomentum;
|
|
436
|
+
|
|
437
|
+
const base = useSeriesColor(color, 1);
|
|
438
|
+
const successToken = useCSSVariable('--color-success');
|
|
439
|
+
const destructiveToken = useCSSVariable('--color-destructive');
|
|
440
|
+
const up =
|
|
441
|
+
momentumColors?.up ?? (typeof successToken === 'string' ? successToken : '#10b981');
|
|
442
|
+
const down =
|
|
443
|
+
momentumColors?.down ??
|
|
444
|
+
(typeof destructiveToken === 'string' ? destructiveToken : '#ef4444');
|
|
445
|
+
const resolved = momentumColors
|
|
446
|
+
? momentum === 'up'
|
|
447
|
+
? up
|
|
448
|
+
: momentum === 'down'
|
|
449
|
+
? down
|
|
450
|
+
: (momentumColors.flat ?? base)
|
|
451
|
+
: base;
|
|
452
|
+
|
|
453
|
+
const setActivePoint = useMemo(
|
|
454
|
+
() => (point: LiveLinePoint | null) => {
|
|
455
|
+
setActivePointState(point);
|
|
456
|
+
onActivePointChange?.(point);
|
|
457
|
+
},
|
|
458
|
+
[onActivePointChange]
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
462
|
+
const { width, height } = event.nativeEvent.layout;
|
|
463
|
+
setSize((current) =>
|
|
464
|
+
Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
|
|
465
|
+
? current
|
|
466
|
+
: { width, height }
|
|
467
|
+
);
|
|
468
|
+
props.onLayout?.(event);
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const context = useMemo<LiveLineChartContextValue>(
|
|
472
|
+
() => ({
|
|
473
|
+
plot,
|
|
474
|
+
status,
|
|
475
|
+
curve,
|
|
476
|
+
times,
|
|
477
|
+
values,
|
|
478
|
+
now,
|
|
479
|
+
windowMs,
|
|
480
|
+
domainMin,
|
|
481
|
+
domainMax,
|
|
482
|
+
extent,
|
|
483
|
+
activeTime,
|
|
484
|
+
activePoint,
|
|
485
|
+
setActivePoint,
|
|
486
|
+
latest,
|
|
487
|
+
momentum,
|
|
488
|
+
color: resolved,
|
|
489
|
+
clipId,
|
|
490
|
+
}),
|
|
491
|
+
// `plot` is rebuilt every render from `size`, so it is compared by value.
|
|
492
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
493
|
+
[
|
|
494
|
+
plot.width,
|
|
495
|
+
plot.height,
|
|
496
|
+
plot.left,
|
|
497
|
+
plot.top,
|
|
498
|
+
status,
|
|
499
|
+
curve,
|
|
500
|
+
times,
|
|
501
|
+
values,
|
|
502
|
+
now,
|
|
503
|
+
windowMs,
|
|
504
|
+
domainMin,
|
|
505
|
+
domainMax,
|
|
506
|
+
extent,
|
|
507
|
+
activeTime,
|
|
508
|
+
activePoint,
|
|
509
|
+
setActivePoint,
|
|
510
|
+
latest,
|
|
511
|
+
momentum,
|
|
512
|
+
resolved,
|
|
513
|
+
clipId,
|
|
514
|
+
]
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
const { svg, overlay, header } = partition(children);
|
|
518
|
+
|
|
519
|
+
return (
|
|
520
|
+
<LiveLineChartContext.Provider value={context}>
|
|
521
|
+
<View {...props} style={props.style} className={cn('w-full', className)}>
|
|
522
|
+
{header}
|
|
523
|
+
<View onLayout={onLayout} style={{ aspectRatio }} className="w-full">
|
|
524
|
+
{plot.width > 0 ? (
|
|
525
|
+
<>
|
|
526
|
+
<Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
|
|
527
|
+
<Defs>
|
|
528
|
+
{/*
|
|
529
|
+
* The run starts one reading outside the window so the line
|
|
530
|
+
* enters from the edge rather than starting inside the plot.
|
|
531
|
+
* This is what keeps that overhang from being drawn.
|
|
532
|
+
*/}
|
|
533
|
+
<ClipPath id={clipId}>
|
|
534
|
+
<Rect
|
|
535
|
+
x={plot.left}
|
|
536
|
+
y={0}
|
|
537
|
+
width={plot.width}
|
|
538
|
+
height={plot.top + plot.height}
|
|
539
|
+
/>
|
|
540
|
+
</ClipPath>
|
|
541
|
+
</Defs>
|
|
542
|
+
{svg}
|
|
543
|
+
</Svg>
|
|
544
|
+
{overlay}
|
|
545
|
+
</>
|
|
546
|
+
) : null}
|
|
547
|
+
</View>
|
|
548
|
+
</View>
|
|
549
|
+
</LiveLineChartContext.Provider>
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
);
|
|
553
|
+
LiveLineChartRoot.displayName = 'LiveLineChart';
|
|
554
|
+
|
|
555
|
+
/** Sorts the children into the SVG tree and the view layer over it. */
|
|
556
|
+
function partition(children: ReactNode) {
|
|
557
|
+
const svg: ReactNode[] = [];
|
|
558
|
+
const overlay: ReactNode[] = [];
|
|
559
|
+
const header: ReactNode[] = [];
|
|
560
|
+
|
|
561
|
+
Children.forEach(children, (child, index) => {
|
|
562
|
+
if (!isValidElement(child)) return;
|
|
563
|
+
const layer = (child.type as { layer?: Layer }).layer ?? 'overlay';
|
|
564
|
+
const bucket = layer === 'svg' ? svg : layer === 'header' ? header : overlay;
|
|
565
|
+
bucket.push(<Slot key={index}>{child}</Slot>);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
return { svg, overlay, header };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function Slot({ children }: { children: ReactNode }) {
|
|
572
|
+
return <>{children}</>;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export interface LiveLineChartGridProps {
|
|
576
|
+
/** How many bands the plot is divided into. */
|
|
577
|
+
rows?: number;
|
|
578
|
+
color?: string;
|
|
579
|
+
dashArray?: string;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** The horizontal rules the readings are judged against. */
|
|
583
|
+
function LiveLineChartGrid({ rows = 4, color, dashArray = '4,6' }: LiveLineChartGridProps) {
|
|
584
|
+
const { plot } = useChart('LiveLineChart.Grid');
|
|
585
|
+
const token = useCSSVariable('--color-border');
|
|
586
|
+
const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
|
|
587
|
+
|
|
588
|
+
return (
|
|
589
|
+
<G>
|
|
590
|
+
{Array.from({ length: rows + 1 }, (_, index) => {
|
|
591
|
+
const y = plot.top + (plot.height / rows) * index;
|
|
592
|
+
return (
|
|
593
|
+
<SvgLine
|
|
594
|
+
key={index}
|
|
595
|
+
x1={plot.left}
|
|
596
|
+
x2={plot.left + plot.width}
|
|
597
|
+
y1={y}
|
|
598
|
+
y2={y}
|
|
599
|
+
stroke={stroke}
|
|
600
|
+
strokeWidth={1}
|
|
601
|
+
strokeDasharray={dashArray}
|
|
602
|
+
/>
|
|
603
|
+
);
|
|
604
|
+
})}
|
|
605
|
+
</G>
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
LiveLineChartGrid.displayName = 'LiveLineChart.Grid';
|
|
609
|
+
LiveLineChartGrid.layer = 'svg' as Layer;
|
|
610
|
+
|
|
611
|
+
export interface LiveLineChartLineProps {
|
|
612
|
+
strokeWidth?: number;
|
|
613
|
+
/** Overrides the chart's colour, momentum included. */
|
|
614
|
+
color?: string;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** The line itself, rebuilt on the UI thread every frame the window moves. */
|
|
618
|
+
function LiveLineChartLine({ strokeWidth = 2, color }: LiveLineChartLineProps) {
|
|
619
|
+
const {
|
|
620
|
+
plot,
|
|
621
|
+
times,
|
|
622
|
+
values,
|
|
623
|
+
now,
|
|
624
|
+
windowMs,
|
|
625
|
+
domainMin,
|
|
626
|
+
domainMax,
|
|
627
|
+
curve,
|
|
628
|
+
status,
|
|
629
|
+
color: themed,
|
|
630
|
+
clipId,
|
|
631
|
+
} = useChart('LiveLineChart.Line');
|
|
632
|
+
|
|
633
|
+
const animatedProps = useAnimatedProps(() => {
|
|
634
|
+
const xMax = now.value;
|
|
635
|
+
const run = runOf(
|
|
636
|
+
times.value,
|
|
637
|
+
values.value,
|
|
638
|
+
plot,
|
|
639
|
+
xMax - windowMs,
|
|
640
|
+
xMax,
|
|
641
|
+
domainMin.value,
|
|
642
|
+
domainMax.value
|
|
643
|
+
);
|
|
644
|
+
return { d: run.length > 1 ? segment(run, curve) : '' };
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
if (status === 'loading') return null;
|
|
648
|
+
|
|
649
|
+
return (
|
|
650
|
+
<G clipPath={`url(#${clipId})`}>
|
|
651
|
+
<AnimatedPath
|
|
652
|
+
animatedProps={animatedProps}
|
|
653
|
+
fill="none"
|
|
654
|
+
stroke={color ?? themed}
|
|
655
|
+
strokeWidth={strokeWidth}
|
|
656
|
+
strokeLinecap="round"
|
|
657
|
+
strokeLinejoin="round"
|
|
658
|
+
/>
|
|
659
|
+
</G>
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
LiveLineChartLine.displayName = 'LiveLineChart.Line';
|
|
663
|
+
LiveLineChartLine.layer = 'svg' as Layer;
|
|
664
|
+
|
|
665
|
+
export interface LiveLineChartAreaProps {
|
|
666
|
+
/** Opacity at the top of the fill, fading to nothing at the baseline. */
|
|
667
|
+
opacity?: number;
|
|
668
|
+
/** Overrides the chart's colour, momentum included. */
|
|
669
|
+
color?: string;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* The fill under the line.
|
|
674
|
+
*
|
|
675
|
+
* Its own part rather than a flag on the line, so a chart that wants the shape
|
|
676
|
+
* without the weight of a filled band simply does not have one.
|
|
677
|
+
*/
|
|
678
|
+
function LiveLineChartArea({ opacity = 0.22, color }: LiveLineChartAreaProps) {
|
|
679
|
+
const {
|
|
680
|
+
plot,
|
|
681
|
+
times,
|
|
682
|
+
values,
|
|
683
|
+
now,
|
|
684
|
+
windowMs,
|
|
685
|
+
domainMin,
|
|
686
|
+
domainMax,
|
|
687
|
+
curve,
|
|
688
|
+
status,
|
|
689
|
+
color: themed,
|
|
690
|
+
clipId,
|
|
691
|
+
} = useChart('LiveLineChart.Area');
|
|
692
|
+
const gradientId = `panelui-live-fill-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
693
|
+
const fill = color ?? themed;
|
|
694
|
+
|
|
695
|
+
const animatedProps = useAnimatedProps(() => {
|
|
696
|
+
const xMax = now.value;
|
|
697
|
+
const run = runOf(
|
|
698
|
+
times.value,
|
|
699
|
+
values.value,
|
|
700
|
+
plot,
|
|
701
|
+
xMax - windowMs,
|
|
702
|
+
xMax,
|
|
703
|
+
domainMin.value,
|
|
704
|
+
domainMax.value
|
|
705
|
+
);
|
|
706
|
+
if (run.length < 2) return { d: '' };
|
|
707
|
+
|
|
708
|
+
const bottom = plot.top + plot.height;
|
|
709
|
+
const first = run[0]!;
|
|
710
|
+
const last = run[run.length - 1]!;
|
|
711
|
+
return { d: `${segment(run, curve)} L${last.x},${bottom} L${first.x},${bottom} Z` };
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
if (status === 'loading') return null;
|
|
715
|
+
|
|
716
|
+
return (
|
|
717
|
+
<G clipPath={`url(#${clipId})`}>
|
|
718
|
+
<Defs>
|
|
719
|
+
<LinearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
720
|
+
<Stop offset="0" stopColor={fill} stopOpacity={opacity} />
|
|
721
|
+
<Stop offset="1" stopColor={fill} stopOpacity={0} />
|
|
722
|
+
</LinearGradient>
|
|
723
|
+
</Defs>
|
|
724
|
+
<AnimatedPath animatedProps={animatedProps} fill={`url(#${gradientId})`} />
|
|
725
|
+
</G>
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
LiveLineChartArea.displayName = 'LiveLineChart.Area';
|
|
729
|
+
LiveLineChartArea.layer = 'svg' as Layer;
|
|
730
|
+
|
|
731
|
+
export interface LiveLineChartTipProps {
|
|
732
|
+
/**
|
|
733
|
+
* Show the current reading in a badge beside the dot.
|
|
734
|
+
*
|
|
735
|
+
* Off by default. The badge is a floating card, which is the shape a reader
|
|
736
|
+
* has learnt means "you touched something" — sitting there unasked it reads
|
|
737
|
+
* as a tooltip nobody opened. Turn it on where the chart has no header to put
|
|
738
|
+
* the reading in, and it becomes the only place the number is written.
|
|
739
|
+
*/
|
|
740
|
+
badge?: boolean;
|
|
741
|
+
/** Ring the dot with a repeating pulse. */
|
|
742
|
+
pulse?: boolean;
|
|
743
|
+
/** Format the badge. Defaults to a compact number. */
|
|
744
|
+
formatValue?: (value: number) => string;
|
|
745
|
+
className?: string;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* The dot at the leading end, and what it currently reads.
|
|
750
|
+
*
|
|
751
|
+
* It rides the newest reading rather than the right-hand edge, so a feed that
|
|
752
|
+
* stops is a dot drifting left with the rest of the line. Pinning it to the
|
|
753
|
+
* edge would hold it still and steady, which is the picture of a feed that is
|
|
754
|
+
* working.
|
|
755
|
+
*
|
|
756
|
+
* A view rather than an SVG node: it carries text and a pulse, and SVG text
|
|
757
|
+
* ignores the platform's text scaling and the theme's font.
|
|
758
|
+
*/
|
|
759
|
+
function LiveLineChartTip({
|
|
760
|
+
badge = false,
|
|
761
|
+
pulse = true,
|
|
762
|
+
formatValue,
|
|
763
|
+
className,
|
|
764
|
+
}: LiveLineChartTipProps) {
|
|
765
|
+
const {
|
|
766
|
+
plot,
|
|
767
|
+
times,
|
|
768
|
+
values,
|
|
769
|
+
now,
|
|
770
|
+
windowMs,
|
|
771
|
+
domainMin,
|
|
772
|
+
domainMax,
|
|
773
|
+
status,
|
|
774
|
+
color,
|
|
775
|
+
latest,
|
|
776
|
+
activePoint,
|
|
777
|
+
} = useChart('LiveLineChart.Tip');
|
|
778
|
+
const reducedMotion = useReducedMotion();
|
|
779
|
+
const beat = useSharedValue(0);
|
|
780
|
+
|
|
781
|
+
useEffect(() => {
|
|
782
|
+
if (!pulse || reducedMotion || status === 'loading') {
|
|
783
|
+
beat.value = 0;
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
beat.value = 0;
|
|
787
|
+
beat.value = withRepeat(withTiming(1, { duration: 1600 }), -1, false);
|
|
788
|
+
}, [pulse, reducedMotion, status, beat]);
|
|
789
|
+
|
|
790
|
+
const tipStyle = useAnimatedStyle(() => {
|
|
791
|
+
const count = times.value.length;
|
|
792
|
+
if (!count) return { opacity: 0 };
|
|
793
|
+
|
|
794
|
+
const xMax = now.value;
|
|
795
|
+
const xMin = xMax - windowMs;
|
|
796
|
+
|
|
797
|
+
// The newest reading *on the plot*, which is not the newest reading held: a
|
|
798
|
+
// paused window keeps taking them in behind its right-hand edge, and the
|
|
799
|
+
// line stops at the edge, so a tip tracking the array would walk away from
|
|
800
|
+
// the end of the line it is supposed to be the end of.
|
|
801
|
+
let index = -1;
|
|
802
|
+
for (let i = count - 1; i >= 0; i -= 1) {
|
|
803
|
+
if (times.value[i]! <= xMax) {
|
|
804
|
+
index = i;
|
|
805
|
+
break;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (index < 0) return { opacity: 0 };
|
|
809
|
+
|
|
810
|
+
const time = times.value[index]!;
|
|
811
|
+
// A feed that stopped longer ago than the window is wide has nothing left
|
|
812
|
+
// on the plot. The line is clipped away at that point, and a dot left
|
|
813
|
+
// hanging past the edge would be the only mark still claiming otherwise.
|
|
814
|
+
if (time < xMin) return { opacity: 0 };
|
|
815
|
+
|
|
816
|
+
const x = xAt(time, plot, xMin, xMax);
|
|
817
|
+
const y = yOf(values.value[index]!, plot, domainMin.value, domainMax.value);
|
|
818
|
+
return {
|
|
819
|
+
opacity: 1,
|
|
820
|
+
transform: [{ translateX: x - TIP / 2 }, { translateY: y - TIP / 2 }],
|
|
821
|
+
};
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
const pulseStyle = useAnimatedStyle(() => ({
|
|
825
|
+
opacity: (1 - beat.value) * 0.55,
|
|
826
|
+
transform: [{ scale: 1 + beat.value * (PULSE - 1) }],
|
|
827
|
+
}));
|
|
828
|
+
|
|
829
|
+
if (status === 'loading' || !latest) return null;
|
|
830
|
+
|
|
831
|
+
const format = formatValue ?? ((value: number) => compactNumber(value));
|
|
832
|
+
|
|
833
|
+
return (
|
|
834
|
+
<Animated.View
|
|
835
|
+
pointerEvents="none"
|
|
836
|
+
style={[
|
|
837
|
+
{ position: 'absolute', left: 0, top: 0, width: TIP, height: TIP },
|
|
838
|
+
tipStyle,
|
|
839
|
+
]}
|
|
840
|
+
>
|
|
841
|
+
{pulse ? (
|
|
842
|
+
<Animated.View
|
|
843
|
+
style={[
|
|
844
|
+
{
|
|
845
|
+
position: 'absolute',
|
|
846
|
+
width: TIP,
|
|
847
|
+
height: TIP,
|
|
848
|
+
borderRadius: TIP / 2,
|
|
849
|
+
backgroundColor: color,
|
|
850
|
+
},
|
|
851
|
+
pulseStyle,
|
|
852
|
+
]}
|
|
853
|
+
/>
|
|
854
|
+
) : null}
|
|
855
|
+
<View
|
|
856
|
+
style={{
|
|
857
|
+
width: TIP,
|
|
858
|
+
height: TIP,
|
|
859
|
+
borderRadius: TIP / 2,
|
|
860
|
+
backgroundColor: color,
|
|
861
|
+
}}
|
|
862
|
+
/>
|
|
863
|
+
{/*
|
|
864
|
+
* Never while the crosshair is out. That readout is the reading being
|
|
865
|
+
* asked for, and a second card a finger's width away answering a
|
|
866
|
+
* different question is two answers to one gesture.
|
|
867
|
+
*/}
|
|
868
|
+
{badge && !activePoint ? (
|
|
869
|
+
<View
|
|
870
|
+
style={{ position: 'absolute', right: TIP + 8, top: -6 }}
|
|
871
|
+
className={cn('rounded-md border border-border bg-popover px-1.5 py-0.5', className)}
|
|
872
|
+
>
|
|
873
|
+
<Text size="xs" weight="medium" numberOfLines={1}>
|
|
874
|
+
{format(latest.value)}
|
|
875
|
+
</Text>
|
|
876
|
+
</View>
|
|
877
|
+
) : null}
|
|
878
|
+
</Animated.View>
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
LiveLineChartTip.displayName = 'LiveLineChart.Tip';
|
|
882
|
+
LiveLineChartTip.layer = 'overlay' as Layer;
|
|
883
|
+
|
|
884
|
+
export interface LiveLineChartXAxisProps {
|
|
885
|
+
/** How many labels along the bottom. */
|
|
886
|
+
ticks?: number;
|
|
887
|
+
/** Rewrites a label. Given how many seconds back the tick is. */
|
|
888
|
+
formatTick?: (secondsAgo: number) => string;
|
|
889
|
+
className?: string;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* How far back the plot reaches, labelled along the bottom.
|
|
894
|
+
*
|
|
895
|
+
* The labels are offsets from now — `-30s`, `-15s`, `now` — rather than clock
|
|
896
|
+
* times, and they never change. A moving window labelled with wall-clock times
|
|
897
|
+
* would rewrite every one of them on every frame, which is a row of digits
|
|
898
|
+
* churning under a chart that is trying to be read.
|
|
899
|
+
*/
|
|
900
|
+
function LiveLineChartXAxis({ ticks = 4, formatTick, className }: LiveLineChartXAxisProps) {
|
|
901
|
+
const { plot, windowMs, status } = useChart('LiveLineChart.XAxis');
|
|
902
|
+
|
|
903
|
+
if (status === 'loading' || ticks < 1) return null;
|
|
904
|
+
|
|
905
|
+
const seconds = windowMs / 1000;
|
|
906
|
+
const format =
|
|
907
|
+
formatTick ?? ((secondsAgo: number) => (secondsAgo <= 0 ? 'now' : `-${Math.round(secondsAgo)}s`));
|
|
908
|
+
|
|
909
|
+
return (
|
|
910
|
+
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
|
|
911
|
+
{Array.from({ length: ticks + 1 }, (_, index) => {
|
|
912
|
+
const fraction = index / ticks;
|
|
913
|
+
const x = plot.left + plot.width * fraction;
|
|
914
|
+
return (
|
|
915
|
+
<View
|
|
916
|
+
key={index}
|
|
917
|
+
style={{
|
|
918
|
+
position: 'absolute',
|
|
919
|
+
top: plot.top + plot.height + 4,
|
|
920
|
+
left: x - 28,
|
|
921
|
+
width: 56,
|
|
922
|
+
}}
|
|
923
|
+
className={cn('items-center', className)}
|
|
924
|
+
>
|
|
925
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
926
|
+
{format(seconds * (1 - fraction))}
|
|
927
|
+
</Text>
|
|
928
|
+
</View>
|
|
929
|
+
);
|
|
930
|
+
})}
|
|
931
|
+
</View>
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
LiveLineChartXAxis.displayName = 'LiveLineChart.XAxis';
|
|
935
|
+
LiveLineChartXAxis.layer = 'overlay' as Layer;
|
|
936
|
+
|
|
937
|
+
export interface LiveLineChartYAxisProps {
|
|
938
|
+
/** How many labels up the side. */
|
|
939
|
+
ticks?: number;
|
|
940
|
+
/** Format a value. Defaults to a compact number. */
|
|
941
|
+
formatValue?: (value: number) => string;
|
|
942
|
+
className?: string;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* The scale up the left-hand side.
|
|
947
|
+
*
|
|
948
|
+
* Declaring one widens the left gutter, which the root reads off the children
|
|
949
|
+
* before anything is laid out — an axis given no room is drawn over the line,
|
|
950
|
+
* which loses both the numbers and the shape they were there to explain.
|
|
951
|
+
*
|
|
952
|
+
* The labels follow the domain the tween is heading for rather than the tween
|
|
953
|
+
* itself. A number re-rendered on every frame of a settle is thirty renders
|
|
954
|
+
* landing on the string it started on.
|
|
955
|
+
*/
|
|
956
|
+
function LiveLineChartYAxis({ ticks = 4, formatValue, className }: LiveLineChartYAxisProps) {
|
|
957
|
+
const { plot, extent, status } = useChart('LiveLineChart.YAxis');
|
|
958
|
+
|
|
959
|
+
if (status === 'loading' || ticks < 1) return null;
|
|
960
|
+
|
|
961
|
+
const [min, max] = extent;
|
|
962
|
+
const format = formatValue ?? ((value: number) => compactNumber(value));
|
|
963
|
+
|
|
964
|
+
return (
|
|
965
|
+
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
|
|
966
|
+
{Array.from({ length: ticks + 1 }, (_, index) => {
|
|
967
|
+
const fraction = index / ticks;
|
|
968
|
+
const y = plot.top + plot.height * fraction;
|
|
969
|
+
return (
|
|
970
|
+
<View
|
|
971
|
+
key={index}
|
|
972
|
+
style={{
|
|
973
|
+
position: 'absolute',
|
|
974
|
+
top: y - AXIS_LABEL_HEIGHT / 2,
|
|
975
|
+
left: 0,
|
|
976
|
+
width: plot.left - Y_AXIS_GUTTER,
|
|
977
|
+
}}
|
|
978
|
+
className={cn('items-end', className)}
|
|
979
|
+
>
|
|
980
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
981
|
+
{format(max - (max - min) * fraction)}
|
|
982
|
+
</Text>
|
|
983
|
+
</View>
|
|
984
|
+
);
|
|
985
|
+
})}
|
|
986
|
+
</View>
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
LiveLineChartYAxis.displayName = 'LiveLineChart.YAxis';
|
|
990
|
+
LiveLineChartYAxis.layer = 'overlay' as Layer;
|
|
991
|
+
LiveLineChartYAxis.axis = 'y' as const;
|
|
992
|
+
|
|
993
|
+
export interface LiveLineChartTooltipProps {
|
|
994
|
+
/** Format the value. Defaults to a compact number. */
|
|
995
|
+
formatValue?: (value: number) => string;
|
|
996
|
+
className?: string;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Drag back through the window to read a reading that has already gone past.
|
|
1001
|
+
*
|
|
1002
|
+
* The crosshair is pinned to the *moment* it was put on rather than to the
|
|
1003
|
+
* place on screen, so it travels left with the reading it named instead of
|
|
1004
|
+
* sitting still while the line slides out from under it.
|
|
1005
|
+
*/
|
|
1006
|
+
function LiveLineChartTooltip({ formatValue, className }: LiveLineChartTooltipProps) {
|
|
1007
|
+
const {
|
|
1008
|
+
plot,
|
|
1009
|
+
times,
|
|
1010
|
+
values,
|
|
1011
|
+
now,
|
|
1012
|
+
windowMs,
|
|
1013
|
+
domainMin,
|
|
1014
|
+
domainMax,
|
|
1015
|
+
activeTime,
|
|
1016
|
+
activePoint,
|
|
1017
|
+
setActivePoint,
|
|
1018
|
+
status,
|
|
1019
|
+
color,
|
|
1020
|
+
} = useChart('LiveLineChart.Tooltip');
|
|
1021
|
+
|
|
1022
|
+
const report = useMemo(
|
|
1023
|
+
() => (time: number, value: number) => {
|
|
1024
|
+
setActivePoint(Number.isFinite(time) && time > 0 ? { time, value } : null);
|
|
1025
|
+
},
|
|
1026
|
+
[setActivePoint]
|
|
1027
|
+
);
|
|
1028
|
+
|
|
1029
|
+
const pan = useMemo(() => {
|
|
1030
|
+
const resolve = (x: number) => {
|
|
1031
|
+
'worklet';
|
|
1032
|
+
const count = times.value.length;
|
|
1033
|
+
if (!count || plot.width <= 0) return;
|
|
1034
|
+
|
|
1035
|
+
const xMax = now.value;
|
|
1036
|
+
const xMin = xMax - windowMs;
|
|
1037
|
+
const clamped = Math.min(Math.max(x, plot.left), plot.left + plot.width);
|
|
1038
|
+
const at = xMin + ((clamped - plot.left) / plot.width) * windowMs;
|
|
1039
|
+
|
|
1040
|
+
// The nearest reading to the moment touched, which on a feed with gaps in
|
|
1041
|
+
// it is not the same as the one nearest in screen distance.
|
|
1042
|
+
let best = 0;
|
|
1043
|
+
let bestGap = Infinity;
|
|
1044
|
+
for (let i = 0; i < count; i += 1) {
|
|
1045
|
+
const gap = Math.abs(times.value[i]! - at);
|
|
1046
|
+
if (gap < bestGap) {
|
|
1047
|
+
bestGap = gap;
|
|
1048
|
+
best = i;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const time = times.value[best]!;
|
|
1053
|
+
if (time === activeTime.value) return;
|
|
1054
|
+
activeTime.value = time;
|
|
1055
|
+
runOnJS(report)(time, values.value[best]!);
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
return Gesture.Pan()
|
|
1059
|
+
.minDistance(0)
|
|
1060
|
+
.onBegin((event) => {
|
|
1061
|
+
'worklet';
|
|
1062
|
+
resolve(event.x);
|
|
1063
|
+
})
|
|
1064
|
+
.onUpdate((event) => {
|
|
1065
|
+
'worklet';
|
|
1066
|
+
resolve(event.x);
|
|
1067
|
+
})
|
|
1068
|
+
.onFinalize(() => {
|
|
1069
|
+
'worklet';
|
|
1070
|
+
activeTime.value = -1;
|
|
1071
|
+
runOnJS(report)(-1, 0);
|
|
1072
|
+
});
|
|
1073
|
+
}, [times, values, now, windowMs, plot, activeTime, report]);
|
|
1074
|
+
|
|
1075
|
+
const crosshairStyle = useAnimatedStyle(() => {
|
|
1076
|
+
const time = activeTime.value;
|
|
1077
|
+
if (time < 0) return { opacity: 0 };
|
|
1078
|
+
const xMax = now.value;
|
|
1079
|
+
return {
|
|
1080
|
+
opacity: 0.45,
|
|
1081
|
+
transform: [{ translateX: xAt(time, plot, xMax - windowMs, xMax) }],
|
|
1082
|
+
};
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
const dotStyle = useAnimatedStyle(() => {
|
|
1086
|
+
const time = activeTime.value;
|
|
1087
|
+
const count = times.value.length;
|
|
1088
|
+
if (time < 0 || !count) return { opacity: 0 };
|
|
1089
|
+
|
|
1090
|
+
// The reading the crosshair named can be trimmed away under it on a long
|
|
1091
|
+
// enough feed. Nothing to point at then, rather than the oldest one.
|
|
1092
|
+
let best = -1;
|
|
1093
|
+
for (let i = 0; i < count; i += 1) {
|
|
1094
|
+
if (times.value[i] === time) {
|
|
1095
|
+
best = i;
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (best < 0) return { opacity: 0 };
|
|
1100
|
+
|
|
1101
|
+
const xMax = now.value;
|
|
1102
|
+
return {
|
|
1103
|
+
opacity: 1,
|
|
1104
|
+
transform: [
|
|
1105
|
+
{ translateX: xAt(time, plot, xMax - windowMs, xMax) - TIP / 2 },
|
|
1106
|
+
{ translateY: yOf(values.value[best]!, plot, domainMin.value, domainMax.value) - TIP / 2 },
|
|
1107
|
+
],
|
|
1108
|
+
};
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
const readoutStyle = useAnimatedStyle(() => {
|
|
1112
|
+
const time = activeTime.value;
|
|
1113
|
+
if (time < 0) return { opacity: 0 };
|
|
1114
|
+
const xMax = now.value;
|
|
1115
|
+
const x = xAt(time, plot, xMax - windowMs, xMax);
|
|
1116
|
+
const half = READOUT_WIDTH / 2;
|
|
1117
|
+
const clamped = Math.min(
|
|
1118
|
+
plot.left + plot.width - half,
|
|
1119
|
+
Math.max(plot.left + half, x)
|
|
1120
|
+
);
|
|
1121
|
+
return { opacity: 1, transform: [{ translateX: clamped - half }] };
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
const format = formatValue ?? ((value: number) => compactNumber(value));
|
|
1125
|
+
const secondsAgo = activePoint ? Math.max(0, Math.round((Date.now() - activePoint.time) / 1000)) : 0;
|
|
1126
|
+
|
|
1127
|
+
if (status === 'loading') return null;
|
|
1128
|
+
|
|
1129
|
+
return (
|
|
1130
|
+
<GestureDetector gesture={pan}>
|
|
1131
|
+
<View style={StyleSheet.absoluteFill}>
|
|
1132
|
+
<Animated.View
|
|
1133
|
+
pointerEvents="none"
|
|
1134
|
+
style={[
|
|
1135
|
+
{
|
|
1136
|
+
position: 'absolute',
|
|
1137
|
+
left: 0,
|
|
1138
|
+
top: plot.top,
|
|
1139
|
+
width: 1,
|
|
1140
|
+
height: plot.height,
|
|
1141
|
+
backgroundColor: color,
|
|
1142
|
+
},
|
|
1143
|
+
crosshairStyle,
|
|
1144
|
+
]}
|
|
1145
|
+
/>
|
|
1146
|
+
<Animated.View
|
|
1147
|
+
pointerEvents="none"
|
|
1148
|
+
style={[
|
|
1149
|
+
{
|
|
1150
|
+
position: 'absolute',
|
|
1151
|
+
left: 0,
|
|
1152
|
+
top: 0,
|
|
1153
|
+
width: TIP,
|
|
1154
|
+
height: TIP,
|
|
1155
|
+
borderRadius: TIP / 2,
|
|
1156
|
+
backgroundColor: color,
|
|
1157
|
+
},
|
|
1158
|
+
dotStyle,
|
|
1159
|
+
]}
|
|
1160
|
+
/>
|
|
1161
|
+
{activePoint ? (
|
|
1162
|
+
<Animated.View
|
|
1163
|
+
pointerEvents="none"
|
|
1164
|
+
style={[
|
|
1165
|
+
{ position: 'absolute', left: 0, top: 0, width: READOUT_WIDTH },
|
|
1166
|
+
readoutStyle,
|
|
1167
|
+
]}
|
|
1168
|
+
className={cn(
|
|
1169
|
+
'items-center rounded-lg border border-border bg-popover px-2 py-1 shadow-sm',
|
|
1170
|
+
className
|
|
1171
|
+
)}
|
|
1172
|
+
>
|
|
1173
|
+
<Text size="xs" weight="medium" numberOfLines={1}>
|
|
1174
|
+
{format(activePoint.value)}
|
|
1175
|
+
</Text>
|
|
1176
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
1177
|
+
{secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`}
|
|
1178
|
+
</Text>
|
|
1179
|
+
</Animated.View>
|
|
1180
|
+
) : null}
|
|
1181
|
+
</View>
|
|
1182
|
+
</GestureDetector>
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
LiveLineChartTooltip.displayName = 'LiveLineChart.Tooltip';
|
|
1186
|
+
LiveLineChartTooltip.layer = 'overlay' as Layer;
|
|
1187
|
+
|
|
1188
|
+
export interface LiveLineChartSkeletonProps {
|
|
1189
|
+
color?: string;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* The waiting state: a flat line down the middle of the plot.
|
|
1194
|
+
*
|
|
1195
|
+
* The shape the real line grows out of once readings arrive, rather than a
|
|
1196
|
+
* placeholder series — an invented run of readings is indistinguishable from a
|
|
1197
|
+
* real one until it changes under the reader.
|
|
1198
|
+
*/
|
|
1199
|
+
function LiveLineChartSkeleton({ color }: LiveLineChartSkeletonProps) {
|
|
1200
|
+
const { plot, status } = useChart('LiveLineChart.Skeleton');
|
|
1201
|
+
const token = useCSSVariable('--color-skeleton');
|
|
1202
|
+
const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
|
|
1203
|
+
|
|
1204
|
+
if (status !== 'loading' || plot.width <= 0) return null;
|
|
1205
|
+
|
|
1206
|
+
const y = plot.top + plot.height / 2;
|
|
1207
|
+
|
|
1208
|
+
return (
|
|
1209
|
+
<SvgLine
|
|
1210
|
+
x1={plot.left}
|
|
1211
|
+
x2={plot.left + plot.width}
|
|
1212
|
+
y1={y}
|
|
1213
|
+
y2={y}
|
|
1214
|
+
stroke={stroke}
|
|
1215
|
+
strokeWidth={2}
|
|
1216
|
+
strokeLinecap="round"
|
|
1217
|
+
/>
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
LiveLineChartSkeleton.displayName = 'LiveLineChart.Skeleton';
|
|
1221
|
+
LiveLineChartSkeleton.layer = 'svg' as Layer;
|
|
1222
|
+
|
|
1223
|
+
export interface LiveLineChartHeaderProps extends ViewProps {
|
|
1224
|
+
className?: string;
|
|
1225
|
+
/** Small line above the value — what is being watched. */
|
|
1226
|
+
title?: string;
|
|
1227
|
+
/** The readout. Left out, it shows the current reading. */
|
|
1228
|
+
value?: string;
|
|
1229
|
+
/** One muted line under the value. */
|
|
1230
|
+
caption?: string;
|
|
1231
|
+
/** Format the derived value. Defaults to a compact number. */
|
|
1232
|
+
formatValue?: (value: number) => string;
|
|
1233
|
+
/** Trailing slot — a pause control, a badge, a unit. */
|
|
1234
|
+
children?: ReactNode;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* The strip above the plot: what is being watched and what it currently reads.
|
|
1239
|
+
*
|
|
1240
|
+
* The value falls back to the reading under the crosshair, then to the latest
|
|
1241
|
+
* one, so a drag back through the window reads out here without the caller
|
|
1242
|
+
* wiring anything up.
|
|
1243
|
+
*/
|
|
1244
|
+
function LiveLineChartHeader({
|
|
1245
|
+
className,
|
|
1246
|
+
title,
|
|
1247
|
+
value,
|
|
1248
|
+
caption,
|
|
1249
|
+
formatValue,
|
|
1250
|
+
children,
|
|
1251
|
+
...props
|
|
1252
|
+
}: LiveLineChartHeaderProps) {
|
|
1253
|
+
const { activePoint, latest } = useChart('LiveLineChart.Header');
|
|
1254
|
+
const format = formatValue ?? ((reading: number) => compactNumber(reading));
|
|
1255
|
+
const shown = activePoint ?? latest;
|
|
1256
|
+
|
|
1257
|
+
return (
|
|
1258
|
+
<View
|
|
1259
|
+
{...props}
|
|
1260
|
+
className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
|
|
1261
|
+
>
|
|
1262
|
+
<View className="flex-1 gap-0.5">
|
|
1263
|
+
{title ? (
|
|
1264
|
+
<Text size="xs" muted>
|
|
1265
|
+
{title}
|
|
1266
|
+
</Text>
|
|
1267
|
+
) : null}
|
|
1268
|
+
<Text size="xl" weight="bold">
|
|
1269
|
+
{value ?? (shown ? format(shown.value) : '—')}
|
|
1270
|
+
</Text>
|
|
1271
|
+
{caption ? (
|
|
1272
|
+
<Text size="xs" muted>
|
|
1273
|
+
{caption}
|
|
1274
|
+
</Text>
|
|
1275
|
+
) : null}
|
|
1276
|
+
</View>
|
|
1277
|
+
{children ? <View className="max-w-[55%] shrink pt-1">{children}</View> : null}
|
|
1278
|
+
</View>
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
LiveLineChartHeader.displayName = 'LiveLineChart.Header';
|
|
1282
|
+
LiveLineChartHeader.layer = 'header' as Layer;
|
|
1283
|
+
|
|
1284
|
+
export const LiveLineChart = Object.assign(LiveLineChartRoot, {
|
|
1285
|
+
Header: LiveLineChartHeader,
|
|
1286
|
+
Grid: LiveLineChartGrid,
|
|
1287
|
+
Area: LiveLineChartArea,
|
|
1288
|
+
Line: LiveLineChartLine,
|
|
1289
|
+
Tip: LiveLineChartTip,
|
|
1290
|
+
XAxis: LiveLineChartXAxis,
|
|
1291
|
+
YAxis: LiveLineChartYAxis,
|
|
1292
|
+
Tooltip: LiveLineChartTooltip,
|
|
1293
|
+
Skeleton: LiveLineChartSkeleton,
|
|
1294
|
+
});
|