/** * MirrorAreaChart — two readings of one timeline, one above a baseline and one * below it. * * ```tsx * * * * * * * * * ``` * * ## Two scales, one timeline * * Each half has its own scale, because the two readings are usually in * different units — a count above and a percentage below, or requests above * and errors below. Both grow away from the baseline, so a peak in one lines * up with whatever the other did at the same moment, and neither is squeezed * into the other's range. * * Areas on the same side overlay in the order they are declared. Declare the * whole first and the part of it second, so the part is drawn inside the whole. */ import { Children, createContext, forwardRef, isValidElement, useContext, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, type ReactNode, } from 'react'; import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, cancelAnimation, runOnJS, useAnimatedProps, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withTiming, type SharedValue, } from 'react-native-reanimated'; import Svg, { Circle, Defs, G, Line as SvgLine, LinearGradient, Path, Rect, Stop, } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { Text } from '../../primitives/text'; import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility'; import { finiteChartDomain } from '../../primitives/finite-chart'; import { colorStop, columnValues, compactNumber, dotsPath, segment, useSeriesColor, xOf, type ChartCurve, type ChartPoint, type Plot, type SeriesColorIndex, } from '../../utils/chart'; import { cn } from '../../utils/cn'; const AnimatedPath = Animated.createAnimatedComponent(Path); const AnimatedCircle = Animated.createAnimatedComponent(Circle); const AnimatedLine = Animated.createAnimatedComponent(SvgLine); const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient); const PADDING = { top: 12, right: 10, bottom: 30, left: 10 }; /** Left gutter reserved when a `YAxis` is present. */ const Y_AXIS_WIDTH = 40; /** Gap between the value labels and the plot. */ const Y_AXIS_GUTTER = 6; /** Box each x label is centred in. */ const POINT_LABEL_WIDTH = 56; /** Line height of an `xs` label, for centring one on the line it names. */ const AXIS_LABEL_HEIGHT = 16; /** Width of the scrub readout. */ const READOUT_WIDTH = 168; /** Diameter of the dot riding the top edge under the crosshair. */ const DOT = 9; type Layer = 'svg' | 'series' | 'overlay' | 'header'; export type MirrorAreaChartStatus = 'loading' | 'ready'; export type MirrorAreaChartSide = 'top' | 'bottom'; export type MirrorAreaChartDatum = Record; interface MirrorSeries { key: string; color: string; label: string; side: MirrorAreaChartSide; } interface MirrorAreaChartContextValue { data: MirrorAreaChartDatum[]; xDataKey: string; plot: Plot; pad: typeof PADDING; /** The y of the line both halves grow away from. */ baseline: number; status: MirrorAreaChartStatus; curve: ChartCurve; series: MirrorSeries[]; registerSeries: (series: MirrorSeries) => void; unregisterSeries: (key: string) => void; /** The value drawn at the top and bottom edges of the plot, as they tween. */ topMax: SharedValue; bottomMax: SharedValue; /** The same two, settled, for labels that should not count up. */ topExtent: number; bottomExtent: number; /** `0` flat on the baseline, `1` drawn in full. */ grow: SharedValue; activeIndex: SharedValue; activeIndexJS: number; setActiveIndexJS: (index: number) => void; } const MirrorAreaChartContext = createContext(null); function useChart(component: string): MirrorAreaChartContextValue { const context = useContext(MirrorAreaChartContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** The row under the crosshair, for a header or a readout outside the chart. */ export function useMirrorAreaChart() { const { data, activeIndexJS, xDataKey } = useChart('useMirrorAreaChart'); return { activeIndex: activeIndexJS, activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null, xDataKey, }; } /** * A series' edge, `grow` of the way out from the baseline. `direction` is `-1` * for the top half and `1` for the bottom. A negative value is drawn on the * baseline: each half only grows away from it. */ function edgeRuns( values: (number | null)[], plot: Plot, baseline: number, height: number, max: number, grow: number, direction: number ): ChartPoint[][] { 'worklet'; const runs: ChartPoint[][] = []; let run: ChartPoint[] = []; const span = max > 0 ? max : 1; for (let i = 0; i < values.length; i += 1) { const value = values[i]; if (value === null || value === undefined) { if (run.length) runs.push(run); run = []; continue; } const reach = (Math.max(value, 0) / span) * height * grow; run.push({ x: xOf(i, values.length, plot), y: baseline + direction * reach }); } if (run.length) runs.push(run); return runs; } function edgePath(runs: ChartPoint[][], curve: ChartCurve): string { 'worklet'; let d = ''; for (const run of runs) d += `${segment(run, curve)} `; return d; } function fillPath(runs: ChartPoint[][], curve: ChartCurve, baseline: number): string { 'worklet'; let d = ''; for (const run of runs) { const edge = segment(run, curve); if (!edge) continue; const first = run[0]!; const last = run[run.length - 1]!; d += `${edge} L${last.x},${baseline} L${first.x},${baseline} Z `; } return d; } /** The largest value on one side, padded so a peak does not touch the edge. */ function sideExtent( data: MirrorAreaChartDatum[], keys: string[], explicit: [number, number] | undefined ): number { if (explicit) return Math.max(explicit[1], 0) || 1; let max = 0; for (const row of data) { for (const key of keys) { const value = row[key]; if (typeof value === 'number' && Number.isFinite(value) && value > max) max = value; } } return max > 0 ? max * 1.08 : 1; } export interface MirrorAreaChartProps extends ViewProps, ChartAccessibilityProps { className?: string; /** The rows. Each one is a point along the x-axis. */ data: MirrorAreaChartDatum[]; /** Key holding the x label. Used by the axis and the readout. */ xDataKey?: string; /** * How much of the plot's height sits above the baseline, from `0` to `1`. * Give the half you want read first the larger share. */ split?: number; /** * Fix the top half's scale instead of deriving it from the data. Only the * upper end is used: the baseline is always zero. */ topDomain?: [number, number]; /** Fix the bottom half's scale. Only the upper end is used. */ bottomDomain?: [number, number]; /** `monotone` never overshoots between points; `linear` joins them straight. */ curve?: ChartCurve; /** * `loading` holds the bands flat on the baseline and grows them out when it * turns `ready`. Add a `MirrorAreaChart.Skeleton` to show something meanwhile. */ status?: MirrorAreaChartStatus; /** Width ÷ height. */ aspectRatio?: number; /** Milliseconds for the bands to grow out on mount. */ animationDuration?: number; /** Milliseconds for either scale to settle after the data changes. */ domainDuration?: number; /** * The point under the crosshair as it moves, and `-1`/`null` when it lifts. * Fires when the index changes, not per frame. */ onActiveIndexChange?: (index: number, datum: MirrorAreaChartDatum | null) => void; /** Drop the axis padding so the bands reach the edges, for a sparkline. */ compact?: boolean; children?: ReactNode; } /** Imperative handle: re-run the reveal on demand, for a "replay" control. */ export interface MirrorAreaChartHandle { replay: () => void; } function ChildSlot({ children }: { children: ReactNode }) { return <>{children}; } function partition(children: ReactNode) { const svg: ReactNode[] = []; const series: ReactNode[] = []; const overlay: ReactNode[] = []; const header: ReactNode[] = []; Children.forEach(children, (child, index) => { if (!isValidElement(child)) return; const layer = (child.type as { layer?: Layer }).layer ?? 'svg'; const slot = {child}; const bucket = layer === 'header' ? header : layer === 'overlay' ? overlay : layer === 'series' ? series : svg; bucket.push(slot); }); return { svg, series, overlay, header }; } const MirrorAreaChartRoot = forwardRef( function MirrorAreaChartRoot( { className, data, xDataKey = 'date', split = 0.55, topDomain, bottomDomain, curve = 'monotone', status = 'ready', aspectRatio = 1.6, animationDuration = 700, domainDuration = 500, onActiveIndexChange, accessible, accessibilityLabel, accessibilityHint, accessibilityLabelForDatum, onAccessibilityDatumPress, compact = false, children, ...props }, ref ) { const [size, setSize] = useState({ width: 0, height: 0 }); const [registered, setRegistered] = useState([]); const [activeIndexJS, setActiveIndexJS] = useState(-1); const grow = useSharedValue(0); const topMax = useSharedValue(0); const bottomMax = useSharedValue(0); const activeIndex = useSharedValue(-1); const reducedMotion = useReducedMotion(); const registerSeries = useMemo( () => (entry: MirrorSeries) => setRegistered((current) => { const existing = current.find((item) => item.key === entry.key); if ( existing && existing.color === entry.color && existing.label === entry.label && existing.side === entry.side ) { return current; } return [...current.filter((item) => item.key !== entry.key), entry]; }), [] ); const unregisterSeries = useMemo( () => (key: string) => setRegistered((current) => current.filter((item) => item.key !== key)), [] ); /* * The order the areas were declared in, read from the children rather than * from registration: an area re-registers when its colour resolves, and * appending it again would reorder the legend and the readout. */ const { hasYAxis, order } = useMemo(() => { let axis = false; const keys: string[] = []; Children.forEach(children, (child) => { if (!isValidElement(child)) return; const type = child.type as { axis?: string; area?: boolean }; const childProps = child.props as { dataKey?: string }; if (type.axis === 'y') axis = true; if (type.area && typeof childProps.dataKey === 'string') keys.push(childProps.dataKey); }); return { hasYAxis: axis, order: keys.join('|') }; }, [children]); const series = useMemo(() => { const declared = order ? order.split('|') : []; const position = (key: string) => { const index = declared.indexOf(key); return index === -1 ? declared.length : index; }; return [...registered].sort((a, b) => position(a.key) - position(b.key)); }, [registered, order]); const pad = compact ? { top: 2, right: 1, bottom: 2, left: 1 } : { ...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), }; const share = Math.min(Math.max(Number.isFinite(split) ? split : 0.55, 0.1), 0.9); const baseline = plot.top + plot.height * share; const topKeys = series.filter((item) => item.side === 'top').map((item) => item.key).join('|'); const bottomKeys = series .filter((item) => item.side === 'bottom') .map((item) => item.key) .join('|'); const safeTop = finiteChartDomain(topDomain); const safeBottom = finiteChartDomain(bottomDomain); const topExtent = useMemo( () => sideExtent(data, topKeys ? topKeys.split('|') : [], safeTop), // eslint-disable-next-line react-hooks/exhaustive-deps [data, topKeys, safeTop?.[0], safeTop?.[1]] ); const bottomExtent = useMemo( () => sideExtent(data, bottomKeys ? bottomKeys.split('|') : [], safeBottom), // eslint-disable-next-line react-hooks/exhaustive-deps [data, bottomKeys, safeBottom?.[0], safeBottom?.[1]] ); const loading = status === 'loading'; useEffect(() => { if (loading) return; const first = topMax.value === 0 && bottomMax.value === 0; if (first || reducedMotion) { topMax.value = topExtent; bottomMax.value = bottomExtent; return; } topMax.value = withTiming(topExtent, { duration: domainDuration }); bottomMax.value = withTiming(bottomExtent, { duration: domainDuration }); }, [topExtent, bottomExtent, loading, reducedMotion, domainDuration, topMax, bottomMax]); const revealed = useRef(false); const playReveal = useMemo( () => () => { if (reducedMotion) { grow.value = 1; return; } grow.value = 0; grow.value = withTiming(1, { duration: animationDuration, easing: Easing.out(Easing.cubic), }); }, [reducedMotion, animationDuration, grow] ); useEffect(() => { // Going back to `loading` lays the bands flat again, so a refetch grows // them out from the baseline instead of appearing complete. if (loading) { revealed.current = false; cancelAnimation(grow); grow.value = 0; return; } if (revealed.current || plot.width <= 0 || !data.length) return; revealed.current = true; playReveal(); }, [loading, plot.width, data.length, playReveal, grow]); useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]); const handleActiveIndex = useMemo( () => (index: number) => { setActiveIndexJS(index); onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null); }, [onActiveIndexChange, data] ); 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( () => ({ data, xDataKey, plot, pad, baseline, status, curve, series, registerSeries, unregisterSeries, topMax, bottomMax, topExtent, bottomExtent, grow, activeIndex, activeIndexJS, setActiveIndexJS: handleActiveIndex, }), // `plot` and `pad` are rebuilt every render from `size`, so they are // compared by value. // eslint-disable-next-line react-hooks/exhaustive-deps [ data, xDataKey, plot.width, plot.height, plot.left, plot.top, pad.right, baseline, status, curve, series, registerSeries, unregisterSeries, topMax, bottomMax, topExtent, bottomExtent, grow, activeIndex, activeIndexJS, handleActiveIndex, ] ); const { svg, series: seriesLayer, overlay, header } = partition(children); return ( {header} [ [xDataKey, datum[xDataKey]], ...series.map((item) => [item.label, datum[item.key]] as [string, unknown]), ]} /> {plot.width > 0 ? ( <> {/* * One drawing, with the bands in their own group above the grid. * The bands grow out from the baseline by redrawing their paths * on the UI thread, so there is no clip to animate — and so * nothing that Android's SVG clip handling can drop. */} {svg} {seriesLayer} {overlay} ) : null} ); } ); MirrorAreaChartRoot.displayName = 'MirrorAreaChart'; /* -------------------------------------------------------------------------- */ /* SVG layer */ /* -------------------------------------------------------------------------- */ export interface MirrorAreaChartGridProps { /** `dots` is a field of points; `lines` is dashed rules across each half. */ variant?: 'dots' | 'lines'; /** Distance between dots, or how many rules each half gets. */ spacing?: number; color?: string; opacity?: number; } /** * The texture behind the bands. Dots by default, which mark the plot's extent * without drawing lines that compete with the baseline. */ function MirrorAreaChartGrid({ variant = 'dots', spacing, color, opacity = 1 }: MirrorAreaChartGridProps) { const { plot, baseline } = useChart('MirrorAreaChart.Grid'); const token = useCSSVariable('--color-border'); const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)'); const dots = useMemo(() => { if (variant !== 'dots') return ''; const gap = Math.max(spacing ?? 12, 4); const points: ChartPoint[] = []; const columns = Math.floor(plot.width / gap); const offsetX = plot.left + (plot.width - columns * gap) / 2; // Rows are laid out from the baseline in both directions, so one row // always lies on it and the pattern is symmetrical about it. for (let y = baseline; y >= plot.top - 0.5; y -= gap) { for (let c = 0; c <= columns; c += 1) points.push({ x: offsetX + c * gap, y }); } for (let y = baseline + gap; y <= plot.top + plot.height + 0.5; y += gap) { for (let c = 0; c <= columns; c += 1) points.push({ x: offsetX + c * gap, y }); } return dotsPath(points, 0.9); }, [variant, spacing, plot.left, plot.top, plot.width, plot.height, baseline]); if (variant === 'dots') { return ; } const rules = Math.max(Math.round(spacing ?? 2), 1); const lines: number[] = []; for (let i = 1; i <= rules; i += 1) { lines.push(baseline - ((baseline - plot.top) * i) / rules); lines.push(baseline + ((plot.top + plot.height - baseline) * i) / rules); } return ( {lines.map((y) => ( ))} ); } MirrorAreaChartGrid.displayName = 'MirrorAreaChart.Grid'; MirrorAreaChartGrid.layer = 'svg' as Layer; export interface MirrorAreaChartBaselineProps { color?: string; strokeWidth?: number; opacity?: number; } /** * The line both halves grow away from. Declare it after the areas so it is * drawn over their bases. */ function MirrorAreaChartBaseline({ color, strokeWidth = 1, opacity = 0.5 }: MirrorAreaChartBaselineProps) { const { plot, baseline } = useChart('MirrorAreaChart.Baseline'); const token = useCSSVariable('--color-muted-foreground'); const stroke = color ?? (typeof token === 'string' ? token : '#737373'); return ( ); } MirrorAreaChartBaseline.displayName = 'MirrorAreaChart.Baseline'; MirrorAreaChartBaseline.layer = 'series' as Layer; export interface MirrorAreaChartAreaProps { /** Column in the data holding this series' values. */ dataKey: string; /** Which half it is drawn in. */ side?: MirrorAreaChartSide; /** The name shown by the readout and the legend. Defaults to `dataKey`. */ label?: string; /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */ color?: string; /** Which of the five chart tokens to take. */ colorIndex?: SeriesColorIndex; /** * Draw it in the muted foreground colour instead, for a total that the * coloured areas are parts of. */ muted?: boolean; /** Opacity of the fill at the band's outer edge. */ fillOpacity?: number; /** Opacity of the fill at the baseline. Defaults to `fillOpacity`, a flat fill. */ gradientToOpacity?: number; /** Draw the line along the band's outer edge. */ showLine?: boolean; /** Thickness of that line. */ strokeWidth?: number; } /** * One band, growing away from the baseline on its side. Areas on one side * overlay in declaration order, so a whole declared first is drawn behind a * part of it declared second. */ function MirrorAreaChartArea({ dataKey, side = 'top', label, color, colorIndex = 1, muted = false, fillOpacity, gradientToOpacity, showLine = true, strokeWidth = 1.5, }: MirrorAreaChartAreaProps) { const { data, plot, baseline, curve, registerSeries, unregisterSeries, topMax, bottomMax, grow, } = useChart('MirrorAreaChart.Area'); const seriesColor = useSeriesColor(color, colorIndex); const mutedToken = useCSSVariable('--color-muted-foreground'); const stroke = muted && !color ? (typeof mutedToken === 'string' ? mutedToken : '#737373') : seriesColor; const name = label ?? dataKey; const gradientId = `panelui-mirror-fill-${useId().replace(/[^a-zA-Z0-9]/g, '')}`; const outer = fillOpacity ?? (muted ? 0.22 : 0.3); const inner = gradientToOpacity ?? outer; useEffect(() => { registerSeries({ key: dataKey, color: stroke, label: name, side }); }, [dataKey, stroke, name, side, registerSeries]); useEffect(() => () => unregisterSeries(dataKey), [dataKey, unregisterSeries]); const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]); const top = side === 'top'; const height = top ? baseline - plot.top : plot.top + plot.height - baseline; const fillProps = useAnimatedProps(() => { const runs = edgeRuns( values, plot, baseline, height, top ? topMax.value : bottomMax.value, grow.value, top ? -1 : 1 ); return { d: fillPath(runs, curve, baseline) }; }); const lineProps = useAnimatedProps(() => { const runs = edgeRuns( values, plot, baseline, height, top ? topMax.value : bottomMax.value, grow.value, top ? -1 : 1 ); // Hidden while the band is flat, or the edge lines of every area are // drawn along the baseline over the skeleton while the chart waits. return { d: edgePath(runs, curve), opacity: grow.value > 0.001 ? 1 : 0 }; }); return ( {/* Top to bottom in the band's own box: the outer edge is at the top of a top band and at the bottom of a bottom one. */} {showLine ? ( ) : null} ); } MirrorAreaChartArea.displayName = 'MirrorAreaChart.Area'; MirrorAreaChartArea.layer = 'series' as Layer; // Read by the root, for the order the areas were declared in. MirrorAreaChartArea.area = true as const; export interface MirrorAreaChartSkeletonProps { /** Milliseconds for one pass of the sweep. */ duration?: number; color?: string; } /** * The loading state: a low band either side of the baseline with a highlight * travelling across it. Flat, so it says where the bands will be and nothing * about their shape. */ function MirrorAreaChartSkeleton({ duration = 1400, color }: MirrorAreaChartSkeletonProps) { const { plot, baseline, status } = useChart('MirrorAreaChart.Skeleton'); const token = useCSSVariable('--color-skeleton'); const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)'); const highlight = useSeriesColor(undefined, 1); const sweep = useSharedValue(0); const reducedMotion = useReducedMotion(); const loading = status === 'loading'; useEffect(() => { if (!loading || reducedMotion) { cancelAnimation(sweep); sweep.value = 0; return; } sweep.value = 0; sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false); return () => cancelAnimation(sweep); }, [loading, reducedMotion, duration, sweep]); const animatedProps = useAnimatedProps(() => ({ x1: `${(sweep.value * 1.4 - 0.4) * 100}%`, x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`, })); if (!loading) return null; const above = (baseline - plot.top) * 0.18; const below = (plot.top + plot.height - baseline) * 0.18; const gradientId = 'panelui-mirror-skeleton'; return ( ); } MirrorAreaChartSkeleton.displayName = 'MirrorAreaChart.Skeleton'; MirrorAreaChartSkeleton.layer = 'svg' as Layer; /* -------------------------------------------------------------------------- */ /* Overlay layer */ /* -------------------------------------------------------------------------- */ export interface MirrorAreaChartXAxisProps { ticks?: number; format?: (datum: MirrorAreaChartDatum, index: number) => string; className?: string; } /** The x labels. Real text, so they follow the theme's font and text scaling. */ function MirrorAreaChartXAxis({ ticks = 4, format, className }: MirrorAreaChartXAxisProps) { const { data, xDataKey, plot, pad } = useChart('MirrorAreaChart.XAxis'); const labels = useMemo(() => { if (!data.length) return []; const count = Math.min(ticks, data.length); const step = count > 1 ? (data.length - 1) / (count - 1) : 0; return Array.from({ length: count }, (_unused, index) => { const dataIndex = Math.round(index * step); const datum = data[dataIndex]; if (!datum) return null; return { key: dataIndex, text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''), }; }).filter((label): label is { key: number; text: string } => label !== null); }, [data, ticks, format, xDataKey]); return ( {labels.map((label) => ( {label.text} ))} ); } MirrorAreaChartXAxis.displayName = 'MirrorAreaChart.XAxis'; MirrorAreaChartXAxis.layer = 'overlay' as Layer; export interface MirrorAreaChartYAxisProps { /** Format a value in the top half. */ formatTop?: (value: number) => string; /** Format a value in the bottom half. */ formatBottom?: (value: number) => string; className?: string; } /** * Three labels down the side: the top half's maximum, zero on the baseline, * and the bottom half's maximum. Two scales need two labels, and a column of * evenly spaced numbers would suggest one. */ function MirrorAreaChartYAxis({ formatTop, formatBottom, className }: MirrorAreaChartYAxisProps) { const { plot, baseline, topExtent, bottomExtent } = useChart('MirrorAreaChart.YAxis'); const width = Math.max(plot.left - Y_AXIS_GUTTER, 0); const fmtTop = formatTop ?? compactNumber; const fmtBottom = formatBottom ?? compactNumber; const labels = [ { key: 'top', y: plot.top, text: fmtTop(topExtent) }, { key: 'zero', y: baseline, text: '0' }, { key: 'bottom', y: plot.top + plot.height, text: fmtBottom(bottomExtent) }, ]; return ( {labels.map((label) => ( {label.text} ))} ); } MirrorAreaChartYAxis.displayName = 'MirrorAreaChart.YAxis'; MirrorAreaChartYAxis.layer = 'overlay' as Layer; // Read by the root, which leaves room for the labels before laying the plot out. MirrorAreaChartYAxis.axis = 'y' as const; export interface MirrorAreaChartTooltipProps { /** Colour of the crosshair and the dot. Defaults to the foreground token. */ color?: string; /** Format one series' value. Defaults to a compact number. */ formatValue?: (value: number, key: string) => string; /** Format the readout's heading from the row. Defaults to the value at xDataKey. */ formatX?: (datum: MirrorAreaChartDatum) => string; /** * One line to show instead of a row per series — "1,120 jobs · 78% high". * Use it when the series only make sense read together. */ formatSummary?: (datum: MirrorAreaChartDatum) => string; className?: string; } /** * A dashed crosshair, a dot on the outer edge of the top half, and a readout * beside them. The hit area is the whole plot. */ function MirrorAreaChartTooltip({ color, formatValue, formatX, formatSummary, className, }: MirrorAreaChartTooltipProps) { const { data, xDataKey, plot, baseline, series, topMax, bottomMax, grow, activeIndex, activeIndexJS, setActiveIndexJS, status, } = useChart('MirrorAreaChart.Tooltip'); const token = useCSSVariable('--color-foreground'); const stroke = color ?? (typeof token === 'string' ? token : '#888888'); const background = useCSSVariable('--color-background'); const ring = typeof background === 'string' ? background : 'white'; const total = data.length; const left = plot.left; const width = plot.width; // Declared inside the memo, next to its callers: a worklet may only call // another worklet. const pan = useMemo(() => { const resolve = (x: number) => { 'worklet'; if (total < 1 || width <= 0) return; const ratio = total < 2 ? 0 : (x - left) / width; const next = Math.round(Math.min(1, Math.max(0, ratio)) * (total - 1)); if (next === activeIndex.value) return; activeIndex.value = next; runOnJS(setActiveIndexJS)(next); }; return Gesture.Pan() .minDistance(0) .onBegin((event) => { 'worklet'; resolve(event.x); }) .onUpdate((event) => { 'worklet'; resolve(event.x); }) .onFinalize(() => { 'worklet'; activeIndex.value = -1; runOnJS(setActiveIndexJS)(-1); }); }, [total, left, width, activeIndex, setActiveIndexJS]); /* * The dot sits on the outermost edge on the top half — the largest value of * any top area at that point — or on the bottom half's when there is no top. */ const topSeries = series.filter((item) => item.side === 'top'); const dotSide: MirrorAreaChartSide = topSeries.length ? 'top' : 'bottom'; const dotValues = useMemo(() => { const keys = series.filter((item) => item.side === dotSide).map((item) => item.key); return data.map((row) => { let most: number | null = null; for (const key of keys) { const value = row[key]; if (typeof value === 'number' && Number.isFinite(value) && (most === null || value > most)) { most = value; } } return most; }); }, [data, series, dotSide]); const topHeight = baseline - plot.top; const bottomHeight = plot.top + plot.height - baseline; const dotProps = useAnimatedProps(() => { const index = activeIndex.value; const value = index >= 0 ? dotValues[index] : null; if (index < 0 || value === null || value === undefined) return { opacity: 0, cx: 0, cy: 0 }; const up = dotSide === 'top'; const max = up ? topMax.value : bottomMax.value; const reach = (Math.max(value, 0) / (max > 0 ? max : 1)) * (up ? topHeight : bottomHeight) * grow.value; return { opacity: 1, cx: xOf(index, total, plot), cy: baseline + (up ? -reach : reach) }; }); const crosshairProps = useAnimatedProps(() => { const index = activeIndex.value; const x = index < 0 ? 0 : xOf(index, total, plot); return { x1: x, x2: x, opacity: index < 0 ? 0 : 0.6 }; }); // Beside the crosshair, on whichever side has room, so it never covers the // point it describes. const readoutStyle = useAnimatedStyle(() => { const index = activeIndex.value; if (index < 0) return { opacity: 0 }; const x = xOf(index, total, plot); // Clear of the dot's radius and its ring, not only of the crosshair. const gap = DOT / 2 + 10; const fitsLeft = x - gap - READOUT_WIDTH >= plot.left; const at = fitsLeft ? x - gap - READOUT_WIDTH : Math.min(x + gap, plot.left + plot.width - READOUT_WIDTH); return { opacity: 1, transform: [{ translateX: at }] }; }); const active = activeIndexJS >= 0 ? data[activeIndexJS] : null; const fmtValue = formatValue ?? ((value: number) => compactNumber(value)); const fmtX = formatX ?? ((datum: MirrorAreaChartDatum) => String(datum[xDataKey] ?? '')); if (status === 'loading') return null; return ( {active ? ( {fmtX(active)} {formatSummary ? ( {formatSummary(active)} ) : ( series.map((item) => { const value = active[item.key]; if (typeof value !== 'number') return null; return ( {fmtValue(value, item.key)} {item.label} ); }) )} ) : null} ); } MirrorAreaChartTooltip.displayName = 'MirrorAreaChart.Tooltip'; MirrorAreaChartTooltip.layer = 'overlay' as Layer; export interface MirrorAreaChartLegendProps extends ViewProps { className?: string; } /** A swatch and a name per area, top half first. */ function MirrorAreaChartLegend({ className, ...props }: MirrorAreaChartLegendProps) { const { series } = useChart('MirrorAreaChart.Legend'); if (!series.length) return null; return ( {sidesInOrder(series).map((item) => ( ))} ); } MirrorAreaChartLegend.displayName = 'MirrorAreaChart.Legend'; MirrorAreaChartLegend.layer = 'overlay' as Layer; /** Top-half areas first, then the bottom half's, each in declaration order. */ function sidesInOrder(series: MirrorSeries[]): MirrorSeries[] { return [ ...series.filter((item) => item.side === 'top'), ...series.filter((item) => item.side === 'bottom'), ]; } /** One series' colour and name. Shared by the legend and the header. */ function SeriesSwatch({ color, label }: { color: string; label: string }) { return ( {label} ); } /* -------------------------------------------------------------------------- */ /* Header layer */ /* -------------------------------------------------------------------------- */ export interface MirrorAreaChartHeaderProps extends ViewProps { className?: string; /** Small line above the value — what the chart is of. */ title?: string; /** The readout. The largest thing on the card, and the first thing read. */ value?: string; /** One muted line under the value — a period, a comparison, a total. */ caption?: string; /** Draw a swatch and a name per area along the trailing edge. */ legend?: boolean; /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */ children?: ReactNode; } /** * The strip above the plot: what the chart is of and what it currently reads. * The value is not derived here — pass the formatted string, from * `onActiveIndexChange` if it follows the finger. */ function MirrorAreaChartHeader({ className, title, value, caption, legend = false, children, ...props }: MirrorAreaChartHeaderProps) { const { series } = useChart('MirrorAreaChart.Header'); const trailing = children ?? (legend && series.length ? ( {sidesInOrder(series).map((item) => ( ))} ) : null); return ( {title ? ( {title} ) : null} {value ? ( {value} ) : null} {caption ? ( {caption} ) : null} {trailing ? {trailing} : null} ); } MirrorAreaChartHeader.displayName = 'MirrorAreaChart.Header'; MirrorAreaChartHeader.layer = 'header' as Layer; export const MirrorAreaChart = Object.assign(MirrorAreaChartRoot, { Header: MirrorAreaChartHeader, Grid: MirrorAreaChartGrid, Area: MirrorAreaChartArea, Baseline: MirrorAreaChartBaseline, Skeleton: MirrorAreaChartSkeleton, XAxis: MirrorAreaChartXAxis, YAxis: MirrorAreaChartYAxis, Tooltip: MirrorAreaChartTooltip, Legend: MirrorAreaChartLegend, });