/** * LiveLineChart — a reading that keeps arriving, against a window that keeps * moving. * * ```tsx * * * * * * * * ``` * * ## What it is, against the line chart beside it * * `LineChart` places a point by its position in the list, which is right for a * series of twelve months whatever the gaps between the dates behind them. Here * a point is placed at the time it carries, against a domain that runs from * `window` seconds ago to now — so the gaps are the subject, and a reading that * arrived late sits where it arrived rather than one slot along. * * ## The clock, and what it costs * * This is the only thing in the library that animates without an interaction or * a change of data. The window is tied to the wall clock, so the line drifts * left whether or not anything is arriving, and a feed that stalls shows as a * flat run reaching back from the tip rather than as a chart that has frozen. * Those two look identical if the window only moves when a point lands, and * they mean opposite things. * * The cost is a frame callback for as long as the chart is mounted. It is * stopped by `paused`, by `status="loading"`, while the app is backgrounded * and on unmount, and it is never started when the platform asks for reduced * motion — in that case the window advances as each point arrives instead, * which is the same picture sampled less often. * * Screen readers receive one image-role snapshot: its name, current or * selected value, direction, time window and paused state. It changes when the * React data changes, never on the UI-thread clock frame, and requests no live * announcement. The visual axes, badges and tooltip repeat that snapshot and * stay out of the accessibility tree; controls placed in Header remain normal * controls. * * ## Colour follows the recent direction * * With `momentumColors` set, the line, the fill and the tip take their colour * from where the reading has been going rather than from a fixed hue. It is the * one thing on the chart readable without looking at the axis, which is what a * number being watched out of the corner of an eye needs. */ import { Children, createContext, forwardRef, isValidElement, useContext, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, type ReactNode, } from 'react'; import { AppState, StyleSheet, View, type LayoutChangeEvent, type ViewProps, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedProps, useAnimatedStyle, useFrameCallback, useReducedMotion, useSharedValue, withRepeat, withTiming, type SharedValue, } from 'react-native-reanimated'; import Svg, { ClipPath, Defs, G, LinearGradient, Path, Rect, Stop, Line as SvgLine, } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { Text } from '../../primitives/text'; import { compactNumber, segment, useSeriesColor, xAt, yOf, type ChartCurve, type ChartPoint, type Plot, } from '../../utils/chart'; import { cn } from '../../utils/cn'; import { liveLineAccessibility } from './live-line-accessibility'; import { liveLineClockRuns, normalizeLiveLinePoints, normalizeLiveLineWindow, reconcileLiveLineActivePoint, } from './live-line-lifecycle'; const AnimatedPath = Animated.createAnimatedComponent(Path); /** Room left around the plot for the axis labels. */ const PADDING = { top: 12, right: 14, bottom: 22, left: 10 }; /** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */ const Y_AXIS_WIDTH = 44; /** Gap between the value labels and the plot they sit beside. */ const Y_AXIS_GUTTER = 6; /** Diameter of the dot at the leading end of the line. */ const TIP = 8; /** How far the pulse ring grows past the dot. */ const PULSE = 3.2; /** Line height of an `xs` label, for centring one on the line it names. */ const AXIS_LABEL_HEIGHT = 16; const READOUT_WIDTH = 116; /** Which layer a part belongs to. Read off the component itself. */ type Layer = 'svg' | 'overlay' | 'header'; /** Whether the chart is showing data or waiting for it. */ export type LiveLineChartStatus = 'loading' | 'ready'; /** Where the reading has been going, over the last few points. */ export type LiveLineMomentum = 'up' | 'down' | 'flat'; /** A colour per direction, for a chart that is read by its colour. */ export interface LiveLineMomentumColors { up?: string; down?: string; flat?: string; } /** One reading. `time` is a timestamp in milliseconds, as `Date.now()` gives. */ export interface LiveLinePoint { time: number; value: number; } interface LiveLineChartContextValue { plot: Plot; status: LiveLineChartStatus; curve: ChartCurve; /** Timestamps and readings, as two number arrays — cheaper to hand the UI * thread than an array of objects, and every worklet here walks them in step. */ times: SharedValue; values: SharedValue; /** The right-hand edge of the window, in milliseconds. */ now: SharedValue; /** How much time the plot spans, in milliseconds. */ windowMs: number; /** Tweened y-domain. Read inside worklets to build the paths. */ domainMin: SharedValue; domainMax: SharedValue; /** What the tween is heading for. The axis labels read this rather than the * shared values, which would re-render a label on every frame of a tween. */ extent: [number, number]; /** The moment under the finger, or -1. Pinned to a time rather than to a * position, so the crosshair travels with the reading it named. */ activeTime: SharedValue; activePoint: LiveLinePoint | null; setActivePoint: (point: LiveLinePoint | null) => void; /** The latest reading, for anything drawing the leading end. */ latest: LiveLinePoint | null; momentum: LiveLineMomentum; color: string; clipId: string; } const LiveLineChartContext = createContext(null); function useChart(component: string): LiveLineChartContextValue { const context = useContext(LiveLineChartContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** The reading under the crosshair, for something rendered inside the chart. */ export function useLiveLineChart() { const { activePoint, latest, momentum, color } = useChart('useLiveLineChart'); return { activePoint, latest, momentum, color }; } /** * The visible run of readings, as coordinates. * * It starts one point *before* the window rather than at the first one inside * it, so the line enters from the left edge instead of beginning wherever the * oldest surviving reading happens to sit. The plot is clipped, so the part * hanging off the edge is never drawn. */ function runOf( times: number[], values: number[], plot: Plot, xMin: number, xMax: number, yMin: number, yMax: number ): ChartPoint[] { 'worklet'; const out: ChartPoint[] = []; const count = times.length; if (!count) return out; let start = 0; for (let i = 0; i < count; i += 1) { if (times[i]! >= xMin) break; start = i; } for (let i = start; i < count; i += 1) { const time = times[i]!; if (time > xMax) break; out.push({ x: xAt(time, plot, xMin, xMax), y: yOf(values[i]!, plot, yMin, yMax), }); } return out; } export interface LiveLineChartProps extends ViewProps { className?: string; /** * Names the chart's single screen-reader snapshot. Falls back to the Header * title, then to "Live line chart". */ accessibilityLabel?: string; /** Additional guidance after the snapshot. No gesture is invented for it. */ accessibilityHint?: string; /** The readings so far. Invalid values are dropped and timestamps are ordered. */ data: LiveLinePoint[]; /** How much time the plot spans, in seconds. Invalid values use 30. */ window?: number; /** Freeze the window where it is. The readings still arrive; the clock stops. */ paused?: boolean; /** Fix the y-axis instead of deriving it from what is visible. */ yDomain?: [number, number]; /** Milliseconds for the y-axis to settle after the range changes. */ domainDuration?: number; /** `monotone` never overshoots between readings; `linear` joins them straight. */ curve?: ChartCurve; /** * The most readings kept. Older ones are dropped, since they are off the * window and cannot come back — an unbounded feed otherwise grows an array * for as long as the screen is open. Must be positive and finite. */ maxPoints?: number; /** Width ÷ height of the plot. */ aspectRatio?: number; /** `loading` draws a flat placeholder and holds the clock. */ status?: LiveLineChartStatus; /** Colour per direction. Left out, the chart draws in one hue throughout. */ momentumColors?: LiveLineMomentumColors; /** Overrides the `--color-chart-1` token. Ignored when `momentumColors` is set. */ color?: string; /** The reading under the crosshair as it moves, and `null` when the finger lifts. */ onActivePointChange?: (point: LiveLinePoint | null) => void; children?: ReactNode; } export interface LiveLineChartHandle { /** Jump the window to the current moment — after a pause, or a background. */ sync: () => void; } const LiveLineChartRoot = forwardRef( function LiveLineChartRoot( { className, data, window: windowSeconds = 30, paused = false, yDomain, domainDuration = 420, curve = 'monotone', maxPoints = 500, aspectRatio = 2, status = 'ready', momentumColors, color, onActivePointChange, accessibilityLabel, accessibilityHint, children, ...props }, ref ) { const [size, setSize] = useState({ width: 0, height: 0 }); const [activePoint, setActivePointState] = useState(null); const times = useSharedValue([]); const values = useSharedValue([]); const now = useSharedValue(Date.now()); const domainMin = useSharedValue(0); const domainMax = useSharedValue(0); const activeTime = useSharedValue(-1); const reducedMotion = useReducedMotion(); const [appState, setAppState] = useState(AppState.currentState ?? 'active'); // Stripped of punctuation: `useId` returns something like `:r1:`, and a // colon inside a `url(#…)` reference does not resolve — which in RN SVG is // a clip that silently does nothing rather than an error. const clipId = `panelui-live-clip-${useId().replace(/[^a-zA-Z0-9]/g, '')}`; const windowMs = normalizeLiveLineWindow(windowSeconds) * 1000; const loading = status === 'loading'; const hasYAxis = useMemo(() => { let found = false; Children.forEach(children, (child) => { if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') { found = true; } }); return found; }, [children]); const semanticParts = useMemo(() => { let title: string | undefined; let value: string | undefined; let headerFormat: ((reading: number) => string) | undefined; let tipFormat: ((reading: number) => string) | undefined; let tooltipFormat: ((reading: number) => string) | undefined; Children.forEach(children, (child) => { if (!isValidElement(child)) return; const part = (child.type as { displayName?: string }).displayName; if (part === 'LiveLineChart.Header') { const header = child.props as LiveLineChartHeaderProps; title ??= header.title; value ??= header.value; headerFormat ??= header.formatValue; } else if (part === 'LiveLineChart.Tip') { tipFormat ??= (child.props as LiveLineChartTipProps).formatValue; } else if (part === 'LiveLineChart.Tooltip') { tooltipFormat ??= (child.props as LiveLineChartTooltipProps).formatValue; } }); return { title, value, formatLatest: headerFormat ?? tipFormat, formatActive: tooltipFormat ?? headerFormat ?? tipFormat, }; }, [children]); const pad = { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left }; const plot: Plot = { left: pad.left, top: pad.top, width: Math.max(size.width - pad.left - pad.right, 0), height: Math.max(size.height - pad.top - pad.bottom, 0), }; /* * Trimmed here rather than by the caller. A live feed is written by whoever * owns the socket, and asking them to also bound the array is asking for * the one chart nobody bounded to be the one left running overnight. */ const points = useMemo( () => normalizeLiveLinePoints(data, maxPoints), [data, maxPoints] ); useEffect(() => { times.value = points.map((point) => point.time); values.value = points.map((point) => point.value); }, [points, times, values]); useEffect(() => { const subscription = AppState.addEventListener('change', setAppState); return () => subscription.remove(); }, []); const liveLatest = points.length ? points[points.length - 1]! : null; /* * The extent of what is *visible*, not of everything kept: a spike that has * scrolled off the left edge should stop holding the axis open, or a feed * settles back to a flat line squeezed against the bottom of a plot scaled * for something that happened a minute ago. */ const liveExtent = useMemo<[number, number]>(() => { if (yDomain) return yDomain; const from = (liveLatest?.time ?? Date.now()) - windowMs; let min = Infinity; let max = -Infinity; for (const point of points) { if (point.time < from) continue; if (point.value < min) min = point.value; if (point.value > max) max = point.value; } if (min === Infinity) return [0, 1]; if (min === max) return [min - 1, max + 1]; const headroom = (max - min) * 0.12; return [min - headroom, max + headroom]; }, [points, liveLatest, windowMs, yDomain]); /* * `paused` has to hold the whole picture still, not only the clock. * * Readings keep arriving while it is held — that is the point of holding it * — and both of these are derived from the newest one. Left live, the axis * goes on rescaling under a frozen line and the tip goes on chasing a * reading that is now off the right-hand edge, so a held chart carries on * moving in two of the three ways it can. */ const held = useRef<{ extent: [number, number]; latest: LiveLinePoint | null } | null>( null ); const heldMomentum = useRef('flat'); if (!paused) held.current = { extent: liveExtent, latest: liveLatest }; const extent = paused && held.current ? held.current.extent : liveExtent; const latest = paused && held.current ? held.current.latest : liveLatest; useEffect(() => { if (loading) return; const [min, max] = extent; // The first domain lands without a tween: there is no previous scale to // move from, and animating up from zero reads as the numbers changing. const first = domainMin.value === 0 && domainMax.value === 0; if (first || reducedMotion) { domainMin.value = min; domainMax.value = max; return; } domainMin.value = withTiming(min, { duration: domainDuration }); domainMax.value = withTiming(max, { duration: domainDuration }); }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]); const frame = useFrameCallback(() => { 'worklet'; now.value = Date.now(); }, false); const running = liveLineClockRuns({ paused, loading, reducedMotion, appState }); useEffect(() => { frame.setActive(running); return () => frame.setActive(false); }, [frame, running]); /* * The reduced-motion path, and the pause that ends: the window still has to * land on the present, it just does it when something arrives rather than * sixty times a second. */ useEffect(() => { if (running) return; if (paused) return; now.value = Date.now(); }, [running, paused, points, now]); // A frame callback is suspended with the app. Land on the wall clock as // soon as it becomes active instead of showing the backgrounded window for // one frame and relying on the platform to schedule a callback promptly. useEffect(() => { if (appState === 'active' && !paused) now.value = Date.now(); }, [appState, paused, now]); useImperativeHandle( ref, () => ({ sync: () => { now.value = Date.now(); }, }), [now] ); const liveMomentum = useMemo(() => { if (points.length < 2) return 'flat'; const recent = points.slice(-6); const span = Math.abs(liveExtent[1] - liveExtent[0]) || 1; const change = (recent[recent.length - 1]!.value - recent[0]!.value) / span; return change > 0.04 ? 'up' : change < -0.04 ? 'down' : 'flat'; }, [points, liveExtent]); // Held with the rest of the picture: a frozen line changing colour under // readings that are not on it is the same bug wearing a different coat. if (!paused) heldMomentum.current = liveMomentum; const momentum = paused ? heldMomentum.current : liveMomentum; const defaultFormat = (reading: number) => compactNumber(reading); const semantic = liveLineAccessibility({ name: accessibilityLabel ?? semanticParts.title, status, latest, activePoint, momentum, windowSeconds: windowMs / 1000, paused, now: Date.now(), valueOverride: semanticParts.value, formatLatest: semanticParts.formatLatest ?? defaultFormat, formatActive: semanticParts.formatActive ?? defaultFormat, }); const base = useSeriesColor(color, 1); const successToken = useCSSVariable('--color-success'); const destructiveToken = useCSSVariable('--color-destructive'); const up = momentumColors?.up ?? (typeof successToken === 'string' ? successToken : '#10b981'); const down = momentumColors?.down ?? (typeof destructiveToken === 'string' ? destructiveToken : '#ef4444'); const resolved = momentumColors ? momentum === 'up' ? up : momentum === 'down' ? down : (momentumColors.flat ?? base) : base; const setActivePoint = useMemo( () => (point: LiveLinePoint | null) => { setActivePointState(point); onActivePointChange?.(point); }, [onActivePointChange] ); useEffect(() => { const next = reconcileLiveLineActivePoint(activePoint, points); if (next === activePoint) return; if (!next) activeTime.value = -1; setActivePoint(next); }, [activePoint, points, activeTime, setActivePoint]); const onLayout = (event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; setSize((current) => Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1 ? current : { width, height } ); props.onLayout?.(event); }; const context = useMemo( () => ({ plot, status, curve, times, values, now, windowMs, domainMin, domainMax, extent, activeTime, activePoint, setActivePoint, latest, momentum, color: resolved, clipId, }), // `plot` is rebuilt every render from `size`, so it is compared by value. // eslint-disable-next-line react-hooks/exhaustive-deps [ plot.width, plot.height, plot.left, plot.top, status, curve, times, values, now, windowMs, domainMin, domainMax, extent, activeTime, activePoint, setActivePoint, latest, momentum, resolved, clipId, ] ); const { svg, overlay, header } = partition(children); return ( {header} {plot.width > 0 ? ( <> {/* * The run starts one reading outside the window so the line * enters from the edge rather than starting inside the plot. * This is what keeps that overhang from being drawn. */} {svg} {overlay} ) : null} ); } ); LiveLineChartRoot.displayName = 'LiveLineChart'; /** Sorts the children into the SVG tree and the view layer over it. */ function partition(children: ReactNode) { const svg: ReactNode[] = []; const overlay: ReactNode[] = []; const header: ReactNode[] = []; Children.forEach(children, (child, index) => { if (!isValidElement(child)) return; const layer = (child.type as { layer?: Layer }).layer ?? 'overlay'; const bucket = layer === 'svg' ? svg : layer === 'header' ? header : overlay; bucket.push({child}); }); return { svg, overlay, header }; } function Slot({ children }: { children: ReactNode }) { return <>{children}; } export interface LiveLineChartGridProps { /** How many bands the plot is divided into. */ rows?: number; color?: string; dashArray?: string; } /** The horizontal rules the readings are judged against. */ function LiveLineChartGrid({ rows = 4, color, dashArray = '4,6' }: LiveLineChartGridProps) { const { plot } = useChart('LiveLineChart.Grid'); const token = useCSSVariable('--color-border'); const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)'); return ( {Array.from({ length: rows + 1 }, (_, index) => { const y = plot.top + (plot.height / rows) * index; return ( ); })} ); } LiveLineChartGrid.displayName = 'LiveLineChart.Grid'; LiveLineChartGrid.layer = 'svg' as Layer; export interface LiveLineChartLineProps { strokeWidth?: number; /** Overrides the chart's colour, momentum included. */ color?: string; } /** The line itself, rebuilt on the UI thread every frame the window moves. */ function LiveLineChartLine({ strokeWidth = 2, color }: LiveLineChartLineProps) { const { plot, times, values, now, windowMs, domainMin, domainMax, curve, status, color: themed, clipId, } = useChart('LiveLineChart.Line'); const animatedProps = useAnimatedProps(() => { const xMax = now.value; const run = runOf( times.value, values.value, plot, xMax - windowMs, xMax, domainMin.value, domainMax.value ); return { d: run.length > 1 ? segment(run, curve) : '' }; }); if (status === 'loading') return null; return ( ); } LiveLineChartLine.displayName = 'LiveLineChart.Line'; LiveLineChartLine.layer = 'svg' as Layer; export interface LiveLineChartAreaProps { /** Opacity at the top of the fill, fading to nothing at the baseline. */ opacity?: number; /** Overrides the chart's colour, momentum included. */ color?: string; } /** * The fill under the line. * * Its own part rather than a flag on the line, so a chart that wants the shape * without the weight of a filled band simply does not have one. */ function LiveLineChartArea({ opacity = 0.22, color }: LiveLineChartAreaProps) { const { plot, times, values, now, windowMs, domainMin, domainMax, curve, status, color: themed, clipId, } = useChart('LiveLineChart.Area'); const gradientId = `panelui-live-fill-${useId().replace(/[^a-zA-Z0-9]/g, '')}`; const fill = color ?? themed; const animatedProps = useAnimatedProps(() => { const xMax = now.value; const run = runOf( times.value, values.value, plot, xMax - windowMs, xMax, domainMin.value, domainMax.value ); if (run.length < 2) return { d: '' }; const bottom = plot.top + plot.height; const first = run[0]!; const last = run[run.length - 1]!; return { d: `${segment(run, curve)} L${last.x},${bottom} L${first.x},${bottom} Z` }; }); if (status === 'loading') return null; return ( ); } LiveLineChartArea.displayName = 'LiveLineChart.Area'; LiveLineChartArea.layer = 'svg' as Layer; export interface LiveLineChartTipProps { /** * Show the current reading in a badge beside the dot. * * Off by default. The badge is a floating card, which is the shape a reader * has learnt means "you touched something" — sitting there unasked it reads * as a tooltip nobody opened. Turn it on where the chart has no header to put * the reading in, and it becomes the only place the number is written. */ badge?: boolean; /** Ring the dot with a repeating pulse. */ pulse?: boolean; /** Format the badge. Defaults to a compact number. */ formatValue?: (value: number) => string; className?: string; } /** * The dot at the leading end, and what it currently reads. * * It rides the newest reading rather than the right-hand edge, so a feed that * stops is a dot drifting left with the rest of the line. Pinning it to the * edge would hold it still and steady, which is the picture of a feed that is * working. * * A view rather than an SVG node: it carries text and a pulse, and SVG text * ignores the platform's text scaling and the theme's font. */ function LiveLineChartTip({ badge = false, pulse = true, formatValue, className, }: LiveLineChartTipProps) { const { plot, times, values, now, windowMs, domainMin, domainMax, status, color, latest, activePoint, } = useChart('LiveLineChart.Tip'); const reducedMotion = useReducedMotion(); const beat = useSharedValue(0); useEffect(() => { if (!pulse || reducedMotion || status === 'loading') { beat.value = 0; return; } beat.value = 0; beat.value = withRepeat(withTiming(1, { duration: 1600 }), -1, false); }, [pulse, reducedMotion, status, beat]); const tipStyle = useAnimatedStyle(() => { const count = times.value.length; if (!count) return { opacity: 0 }; const xMax = now.value; const xMin = xMax - windowMs; // The newest reading *on the plot*, which is not the newest reading held: a // paused window keeps taking them in behind its right-hand edge, and the // line stops at the edge, so a tip tracking the array would walk away from // the end of the line it is supposed to be the end of. let index = -1; for (let i = count - 1; i >= 0; i -= 1) { if (times.value[i]! <= xMax) { index = i; break; } } if (index < 0) return { opacity: 0 }; const time = times.value[index]!; // A feed that stopped longer ago than the window is wide has nothing left // on the plot. The line is clipped away at that point, and a dot left // hanging past the edge would be the only mark still claiming otherwise. if (time < xMin) return { opacity: 0 }; const x = xAt(time, plot, xMin, xMax); const y = yOf(values.value[index]!, plot, domainMin.value, domainMax.value); return { opacity: 1, transform: [{ translateX: x - TIP / 2 }, { translateY: y - TIP / 2 }], }; }); const pulseStyle = useAnimatedStyle(() => ({ opacity: (1 - beat.value) * 0.55, transform: [{ scale: 1 + beat.value * (PULSE - 1) }], })); if (status === 'loading' || !latest) return null; const format = formatValue ?? ((value: number) => compactNumber(value)); return ( {pulse ? ( ) : null} {/* * Never while the crosshair is out. That readout is the reading being * asked for, and a second card a finger's width away answering a * different question is two answers to one gesture. */} {badge && !activePoint ? ( {format(latest.value)} ) : null} ); } LiveLineChartTip.displayName = 'LiveLineChart.Tip'; LiveLineChartTip.layer = 'overlay' as Layer; export interface LiveLineChartXAxisProps { /** How many labels along the bottom. */ ticks?: number; /** Rewrites a label. Given how many seconds back the tick is. */ formatTick?: (secondsAgo: number) => string; className?: string; } /** * How far back the plot reaches, labelled along the bottom. * * The labels are offsets from now — `-30s`, `-15s`, `now` — rather than clock * times, and they never change. A moving window labelled with wall-clock times * would rewrite every one of them on every frame, which is a row of digits * churning under a chart that is trying to be read. */ function LiveLineChartXAxis({ ticks = 4, formatTick, className }: LiveLineChartXAxisProps) { const { plot, windowMs, status } = useChart('LiveLineChart.XAxis'); if (status === 'loading' || ticks < 1) return null; const seconds = windowMs / 1000; const format = formatTick ?? ((secondsAgo: number) => (secondsAgo <= 0 ? 'now' : `-${Math.round(secondsAgo)}s`)); return ( {Array.from({ length: ticks + 1 }, (_, index) => { const fraction = index / ticks; const x = plot.left + plot.width * fraction; return ( {format(seconds * (1 - fraction))} ); })} ); } LiveLineChartXAxis.displayName = 'LiveLineChart.XAxis'; LiveLineChartXAxis.layer = 'overlay' as Layer; export interface LiveLineChartYAxisProps { /** How many labels up the side. */ ticks?: number; /** Format a value. Defaults to a compact number. */ formatValue?: (value: number) => string; className?: string; } /** * The scale up the left-hand side. * * Declaring one widens the left gutter, which the root reads off the children * before anything is laid out — an axis given no room is drawn over the line, * which loses both the numbers and the shape they were there to explain. * * The labels follow the domain the tween is heading for rather than the tween * itself. A number re-rendered on every frame of a settle is thirty renders * landing on the string it started on. */ function LiveLineChartYAxis({ ticks = 4, formatValue, className }: LiveLineChartYAxisProps) { const { plot, extent, status } = useChart('LiveLineChart.YAxis'); if (status === 'loading' || ticks < 1) return null; const [min, max] = extent; const format = formatValue ?? ((value: number) => compactNumber(value)); return ( {Array.from({ length: ticks + 1 }, (_, index) => { const fraction = index / ticks; const y = plot.top + plot.height * fraction; return ( {format(max - (max - min) * fraction)} ); })} ); } LiveLineChartYAxis.displayName = 'LiveLineChart.YAxis'; LiveLineChartYAxis.layer = 'overlay' as Layer; LiveLineChartYAxis.axis = 'y' as const; export interface LiveLineChartTooltipProps { /** Format the value. Defaults to a compact number. */ formatValue?: (value: number) => string; className?: string; } /** * Drag back through the window to read a reading that has already gone past. * * The crosshair is pinned to the *moment* it was put on rather than to the * place on screen, so it travels left with the reading it named instead of * sitting still while the line slides out from under it. */ function LiveLineChartTooltip({ formatValue, className }: LiveLineChartTooltipProps) { const { plot, times, values, now, windowMs, domainMin, domainMax, activeTime, activePoint, setActivePoint, status, color, } = useChart('LiveLineChart.Tooltip'); const report = useMemo( () => (time: number, value: number) => { setActivePoint(Number.isFinite(time) && time > 0 ? { time, value } : null); }, [setActivePoint] ); useEffect( () => () => { activeTime.value = -1; setActivePoint(null); }, [activeTime, setActivePoint] ); const pan = useMemo(() => { const resolve = (x: number) => { 'worklet'; const count = times.value.length; if (!count || plot.width <= 0) return; const xMax = now.value; const xMin = xMax - windowMs; const clamped = Math.min(Math.max(x, plot.left), plot.left + plot.width); const at = xMin + ((clamped - plot.left) / plot.width) * windowMs; // The nearest reading to the moment touched, which on a feed with gaps in // it is not the same as the one nearest in screen distance. let best = 0; let bestGap = Infinity; for (let i = 0; i < count; i += 1) { const gap = Math.abs(times.value[i]! - at); if (gap < bestGap) { bestGap = gap; best = i; } } const time = times.value[best]!; if (time === activeTime.value) return; activeTime.value = time; runOnJS(report)(time, values.value[best]!); }; return Gesture.Pan() .minDistance(0) .onBegin((event) => { 'worklet'; resolve(event.x); }) .onUpdate((event) => { 'worklet'; resolve(event.x); }) .onFinalize(() => { 'worklet'; activeTime.value = -1; runOnJS(report)(-1, 0); }); }, [times, values, now, windowMs, plot, activeTime, report]); const crosshairStyle = useAnimatedStyle(() => { const time = activeTime.value; if (time < 0) return { opacity: 0 }; const xMax = now.value; return { opacity: 0.45, transform: [{ translateX: xAt(time, plot, xMax - windowMs, xMax) }], }; }); const dotStyle = useAnimatedStyle(() => { const time = activeTime.value; const count = times.value.length; if (time < 0 || !count) return { opacity: 0 }; // The reading the crosshair named can be trimmed away under it on a long // enough feed. Nothing to point at then, rather than the oldest one. let best = -1; for (let i = 0; i < count; i += 1) { if (times.value[i] === time) { best = i; break; } } if (best < 0) return { opacity: 0 }; const xMax = now.value; return { opacity: 1, transform: [ { translateX: xAt(time, plot, xMax - windowMs, xMax) - TIP / 2 }, { translateY: yOf(values.value[best]!, plot, domainMin.value, domainMax.value) - TIP / 2 }, ], }; }); const readoutStyle = useAnimatedStyle(() => { const time = activeTime.value; if (time < 0) return { opacity: 0 }; const xMax = now.value; const x = xAt(time, plot, xMax - windowMs, xMax); const half = READOUT_WIDTH / 2; const clamped = Math.min( plot.left + plot.width - half, Math.max(plot.left + half, x) ); return { opacity: 1, transform: [{ translateX: clamped - half }] }; }); const format = formatValue ?? ((value: number) => compactNumber(value)); const secondsAgo = activePoint ? Math.max(0, Math.round((Date.now() - activePoint.time) / 1000)) : 0; if (status === 'loading') return null; return ( {activePoint ? ( {format(activePoint.value)} {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`} ) : null} ); } LiveLineChartTooltip.displayName = 'LiveLineChart.Tooltip'; LiveLineChartTooltip.layer = 'overlay' as Layer; export interface LiveLineChartSkeletonProps { color?: string; } /** * The waiting state: a flat line down the middle of the plot. * * The shape the real line grows out of once readings arrive, rather than a * placeholder series — an invented run of readings is indistinguishable from a * real one until it changes under the reader. */ function LiveLineChartSkeleton({ color }: LiveLineChartSkeletonProps) { const { plot, status } = useChart('LiveLineChart.Skeleton'); const token = useCSSVariable('--color-skeleton'); const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)'); if (status !== 'loading' || plot.width <= 0) return null; const y = plot.top + plot.height / 2; return ( ); } LiveLineChartSkeleton.displayName = 'LiveLineChart.Skeleton'; LiveLineChartSkeleton.layer = 'svg' as Layer; export interface LiveLineChartHeaderProps extends ViewProps { className?: string; /** Small line above the value — what is being watched. */ title?: string; /** The readout. Left out, it shows the current reading. */ value?: string; /** One muted line under the value. */ caption?: string; /** Format the derived value. Defaults to a compact number. */ formatValue?: (value: number) => string; /** Trailing slot — a pause control, a badge, a unit. */ children?: ReactNode; } /** * The strip above the plot: what is being watched and what it currently reads. * * The value falls back to the reading under the crosshair, then to the latest * one, so a drag back through the window reads out here without the caller * wiring anything up. */ function LiveLineChartHeader({ className, title, value, caption, formatValue, children, ...props }: LiveLineChartHeaderProps) { const { activePoint, latest } = useChart('LiveLineChart.Header'); const format = formatValue ?? ((reading: number) => compactNumber(reading)); const shown = activePoint ?? latest; return ( {title ? ( {title} ) : null} {value ?? (shown ? format(shown.value) : '—')} {caption ? ( {caption} ) : null} {children ? {children} : null} ); } LiveLineChartHeader.displayName = 'LiveLineChart.Header'; LiveLineChartHeader.layer = 'header' as Layer; export const LiveLineChart = Object.assign(LiveLineChartRoot, { Header: LiveLineChartHeader, Grid: LiveLineChartGrid, Area: LiveLineChartArea, Line: LiveLineChartLine, Tip: LiveLineChartTip, XAxis: LiveLineChartXAxis, YAxis: LiveLineChartYAxis, Tooltip: LiveLineChartTooltip, Skeleton: LiveLineChartSkeleton, });