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