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.
Files changed (37) hide show
  1. package/README.md +3 -1
  2. package/lib/module/components/area-chart/index.js +86 -1
  3. package/lib/module/components/area-chart/index.js.map +1 -1
  4. package/lib/module/components/bar-chart/index.js +112 -2
  5. package/lib/module/components/bar-chart/index.js.map +1 -1
  6. package/lib/module/components/live-line-chart/index.js +1063 -0
  7. package/lib/module/components/live-line-chart/index.js.map +1 -0
  8. package/lib/module/components/polar-area-chart/index.js +719 -0
  9. package/lib/module/components/polar-area-chart/index.js.map +1 -0
  10. package/lib/module/components/treemap-chart/index.js +957 -0
  11. package/lib/module/components/treemap-chart/index.js.map +1 -0
  12. package/lib/module/index.js +3 -0
  13. package/lib/module/index.js.map +1 -1
  14. package/lib/module/utils/chart.js +171 -4
  15. package/lib/module/utils/chart.js.map +1 -1
  16. package/lib/typescript/src/components/area-chart/index.d.ts +28 -1
  17. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  18. package/lib/typescript/src/components/bar-chart/index.d.ts +33 -3
  19. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  20. package/lib/typescript/src/components/live-line-chart/index.d.ts +293 -0
  21. package/lib/typescript/src/components/live-line-chart/index.d.ts.map +1 -0
  22. package/lib/typescript/src/components/polar-area-chart/index.d.ts +259 -0
  23. package/lib/typescript/src/components/polar-area-chart/index.d.ts.map +1 -0
  24. package/lib/typescript/src/components/treemap-chart/index.d.ts +317 -0
  25. package/lib/typescript/src/components/treemap-chart/index.d.ts.map +1 -0
  26. package/lib/typescript/src/index.d.ts +3 -0
  27. package/lib/typescript/src/index.d.ts.map +1 -1
  28. package/lib/typescript/src/utils/chart.d.ts +27 -1
  29. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/components/area-chart/index.tsx +86 -1
  32. package/src/components/bar-chart/index.tsx +115 -4
  33. package/src/components/live-line-chart/index.tsx +1294 -0
  34. package/src/components/polar-area-chart/index.tsx +928 -0
  35. package/src/components/treemap-chart/index.tsx +1175 -0
  36. package/src/index.ts +52 -0
  37. 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