panelui-native 0.61.1 → 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.
@@ -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
+ });