data visualization
Forecast Envelope
A Victory-powered forecast combines actuals, animated uncertainty bounds, scenario lines, and scrubbable values.
iOSAndroidExpo Go
victoryskiagesture handlerreanimatedworklets
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add victory-native @shopify/react-native-skia react-native-gesture-handler react-native-reanimated react-native-workletsData structure
Each point provides lower, expected, and upper values, with optional
historic actual values. forecastStartIndex marks where projections begin.
Behavior
- The parent controls the chart size.
scenarioselects the emphasized forecast path.- Dragging snaps the cursor to the nearest point.
Install tailwind variants
pnpm add tailwind-variantsUsage
ForecastEnvelopeExample.tsximport { ForecastEnvelope } from "@animations/ui/components/data-visualization/forecast-envelope/uniwind";const data = [ { actual: 42, expected: 42, label: "Apr", lower: 40, upper: 44 }, { actual: 48, expected: 48, label: "May", lower: 45, upper: 51 }, { actual: 54, expected: 54, label: "Jun", lower: 50, upper: 58 }, { expected: 61, label: "Jul", lower: 53, upper: 69 }, { expected: 68, label: "Aug", lower: 55, upper: 81 },];export function ForecastEnvelopeExample() { return ( <ForecastEnvelope className="h-70 w-full" data={data} forecastStartIndex={2} formatValue={(value) => `${value}k`} scenario="expected" /> );}forecast-envelope/artwork.tsximport { Circle, DashPathEffect, Line as SkiaLine, vec,} from "@shopify/react-native-skia";import { useMemo } from "react";import { StyleSheet, View } from "react-native";import { interpolate, useDerivedValue, type SharedValue,} from "react-native-reanimated";import { AreaRange, CartesianChart, Line, type ChartBounds, type PointsArray,} from "victory-native";import { FORECAST_ENVELOPE_CONFIG } from "./config";import type { ForecastEnvelopePoint } from "./types";type ForecastChartDatum = { actual: number | null; expected: number | null; index: number; lower: number | null; upper: number | null;};type ForecastEnvelopeArtworkProps = { actualColor: string; cursorX: Readonly<SharedValue<number>>; data: readonly ForecastEnvelopePoint[]; dividerColor: string; envelopeColor: string; expectedColor: string; forecastStartIndex: number; gridColor: string; optimisticColor: string; pessimisticColor: string; scenarioProgress: Readonly<SharedValue<number>>;};type ForecastCursorProps = { actualColor: string; chartBounds: ChartBounds; cursorX: Readonly<SharedValue<number>>; expectedPoints: PointsArray; lowerPoints: PointsArray; actualPoints: PointsArray; scenarioProgress: Readonly<SharedValue<number>>; upperPoints: PointsArray;};function getPointY( points: PointsArray, index: number, fallbackPoints: PointsArray, fallback: number,) { const value = points[index]?.y; const fallbackValue = fallbackPoints[index]?.y; if (typeof value === "number") { return value; } return typeof fallbackValue === "number" ? fallbackValue : fallback;}function ForecastCursor({ actualColor, actualPoints, chartBounds, cursorX, expectedPoints, lowerPoints, scenarioProgress, upperPoints,}: ForecastCursorProps) { const coordinates = useMemo( () => ({ expected: expectedPoints.map((_, index) => getPointY( expectedPoints, index, actualPoints, chartBounds.bottom, ), ), lower: lowerPoints.map((_, index) => getPointY( lowerPoints, index, actualPoints, chartBounds.bottom, ), ), upper: upperPoints.map((_, index) => getPointY( upperPoints, index, actualPoints, chartBounds.bottom, ), ), x: expectedPoints.map((point) => point.x), }), [ actualPoints, chartBounds.bottom, expectedPoints, lowerPoints, upperPoints, ], ); const cursorY = useDerivedValue(() => { const x = cursorX.get(); const lowerY = interpolate( x, coordinates.x, coordinates.lower, ); const expectedY = interpolate( x, coordinates.x, coordinates.expected, ); const upperY = interpolate( x, coordinates.x, coordinates.upper, ); return interpolate( scenarioProgress.get(), [0, 1, 2], [lowerY, expectedY, upperY], ); }); const lineStart = useDerivedValue(() => vec(cursorX.get(), chartBounds.top), ); const lineEnd = useDerivedValue(() => vec(cursorX.get(), chartBounds.bottom), ); return ( <> <SkiaLine color={actualColor} opacity={0.3} p1={lineStart} p2={lineEnd} strokeWidth={1} /> <Circle color="#ffffff" cx={cursorX} cy={cursorY} r={7} /> <Circle color={actualColor} cx={cursorX} cy={cursorY} r={4} /> </> );}export function ForecastEnvelopeArtwork({ actualColor, cursorX, data, dividerColor, envelopeColor, expectedColor, forecastStartIndex, gridColor, optimisticColor, pessimisticColor, scenarioProgress,}: ForecastEnvelopeArtworkProps) { const chartData = useMemo<ForecastChartDatum[]>( () => data.map((point, index) => ({ actual: index <= forecastStartIndex ? (point.actual ?? point.expected) : null, expected: index >= forecastStartIndex ? point.expected : null, index, lower: index >= forecastStartIndex ? point.lower : null, upper: index >= forecastStartIndex ? point.upper : null, })), [data, forecastStartIndex], ); const pessimisticOpacity = useDerivedValue(() => interpolate( scenarioProgress.get(), [0, 1, 2], [1, 0.18, 0.08], ), ); const expectedOpacity = useDerivedValue(() => interpolate( scenarioProgress.get(), [0, 1, 2], [0.18, 1, 0.18], ), ); const optimisticOpacity = useDerivedValue(() => interpolate( scenarioProgress.get(), [0, 1, 2], [0.08, 0.18, 1], ), ); return ( <View pointerEvents="none" style={StyleSheet.absoluteFill}> <CartesianChart data={chartData} domainPadding={{ bottom: 8, top: 8 }} frame={{ lineWidth: 0 }} padding={FORECAST_ENVELOPE_CONFIG.plotPadding} xAxis={{ lineWidth: 0 }} xKey="index" yAxis={[ { lineColor: gridColor, lineWidth: 1, tickCount: 3, }, ]} yKeys={["actual", "lower", "expected", "upper"]} > {({ chartBounds, points }) => { const dividerX = points.expected[forecastStartIndex]?.x ?? chartBounds.left; return ( <> <AreaRange animate={FORECAST_ENVELOPE_CONFIG.pathAnimation} color={envelopeColor} connectMissingData={false} curveType="linear" lowerPoints={points.lower} opacity={0.14} upperPoints={points.upper} /> <Line animate={FORECAST_ENVELOPE_CONFIG.pathAnimation} color={pessimisticColor} connectMissingData={false} opacity={pessimisticOpacity} points={points.lower} strokeCap="round" strokeJoin="round" strokeWidth={3} /> <Line animate={FORECAST_ENVELOPE_CONFIG.pathAnimation} color={expectedColor} connectMissingData={false} opacity={expectedOpacity} points={points.expected} strokeCap="round" strokeJoin="round" strokeWidth={3} /> <Line animate={FORECAST_ENVELOPE_CONFIG.pathAnimation} color={optimisticColor} connectMissingData={false} opacity={optimisticOpacity} points={points.upper} strokeCap="round" strokeJoin="round" strokeWidth={3} /> <Line animate={FORECAST_ENVELOPE_CONFIG.pathAnimation} color={actualColor} connectMissingData={false} points={points.actual} strokeCap="round" strokeJoin="round" strokeWidth={3.5} /> <SkiaLine color={dividerColor} p1={vec(dividerX, chartBounds.top)} p2={vec(dividerX, chartBounds.bottom)} strokeWidth={1} > <DashPathEffect intervals={[5, 5]} /> </SkiaLine> <ForecastCursor actualColor={actualColor} actualPoints={points.actual} chartBounds={chartBounds} cursorX={cursorX} expectedPoints={points.expected} lowerPoints={points.lower} scenarioProgress={scenarioProgress} upperPoints={points.upper} /> </> ); }} </CartesianChart> </View> );}forecast-envelope/config.tsexport const FORECAST_ENVELOPE_COLORS = { actual: "#171713", divider: "#b8b7af", envelope: "#6d5dfc", grid: "#e7e6df", optimistic: "#2aa67a", pessimistic: "#d68a25", expected: "#6d5dfc",} as const;export const FORECAST_ENVELOPE_CONFIG = { pathAnimation: { duration: 280, type: "timing", } as const, plotPadding: { bottom: 24, left: 18, right: 18, top: 68, }, spring: { damping: 20, mass: 0.7, stiffness: 220, }, tooltipWidth: 148, transitionDuration: 280,} as const;forecast-envelope/types.tsexport const FORECAST_ENVELOPE_SCENARIOS = [ "pessimistic", "expected", "optimistic",] as const;export type ForecastEnvelopeScenario = (typeof FORECAST_ENVELOPE_SCENARIOS)[number];export type ForecastEnvelopePoint = { actual?: number; expected: number; label: string; lower: number; upper: number;};export type ForecastEnvelopeBaseProps = { accessibilityLabel?: string; actualColor?: string; data: readonly ForecastEnvelopePoint[]; defaultSelectedIndex?: number; disabled?: boolean; dividerColor?: string; envelopeColor?: string; expectedColor?: string; forecastStartIndex: number; formatValue?: (value: number) => string; gridColor?: string; onSelectedIndexChange?: (index: number) => void; optimisticColor?: string; pessimisticColor?: string; scenario?: ForecastEnvelopeScenario; selectedIndex?: number;};forecast-envelope/uniwind/index.tsximport { Text, View } from "react-native";import { GestureDetector } from "react-native-gesture-handler";import Animated from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import { ForecastEnvelopeArtwork } from "../artwork";import { FORECAST_ENVELOPE_COLORS,} from "../config";import type { ForecastEnvelopeBaseProps,} from "../types";import { useForecastEnvelope } from "../use-forecast-envelope";import { type ForecastEnvelopeSlot, forecastEnvelopeVariants,} from "./variants";export type ForecastEnvelopeProps = ForecastEnvelopeBaseProps & { className?: string; classNames?: SlotsToClasses<ForecastEnvelopeSlot>; };function defaultFormatValue(value: number) { return String(value);}export function ForecastEnvelope({ accessibilityLabel = "Forecast envelope", actualColor = FORECAST_ENVELOPE_COLORS.actual, className, classNames, data, defaultSelectedIndex, disabled, dividerColor = FORECAST_ENVELOPE_COLORS.divider, envelopeColor = FORECAST_ENVELOPE_COLORS.envelope, expectedColor = FORECAST_ENVELOPE_COLORS.expected, forecastStartIndex, formatValue = defaultFormatValue, gridColor = FORECAST_ENVELOPE_COLORS.grid, onSelectedIndexChange, optimisticColor = FORECAST_ENVELOPE_COLORS.optimistic, pessimisticColor = FORECAST_ENVELOPE_COLORS.pessimistic, scenario = "expected", selectedIndex,}: ForecastEnvelopeProps) { const { accessibilityActions, currentIndex, cursorX, gesture, onAccessibilityAction, onLayout, scenarioProgress, selectedPoint, selectedValue, tooltipAnimatedStyle, } = useForecastEnvelope({ data, defaultSelectedIndex, disabled, forecastStartIndex, onSelectedIndexChange, scenario, selectedIndex, }); const { container, label, range, tooltip, value, } = forecastEnvelopeVariants({ disabled }); return ( <GestureDetector gesture={gesture}> <View accessibilityActions={accessibilityActions} accessibilityLabel={accessibilityLabel} accessibilityRole="adjustable" accessibilityState={{ disabled }} accessibilityValue={{ text: `${selectedPoint.label}, ${formatValue(selectedValue)}, range ${formatValue(selectedPoint.lower)} to ${formatValue(selectedPoint.upper)}`, }} accessible className={container({ className: [className, classNames?.container], })} onAccessibilityAction={onAccessibilityAction} onLayout={onLayout} > <Animated.View className={tooltip({ className: classNames?.tooltip, })} pointerEvents="none" style={tooltipAnimatedStyle} > <Text className={label({ className: classNames?.label, })} > {selectedPoint.label.toUpperCase()} </Text> <Text className={value({ className: classNames?.value, })} > {formatValue(selectedValue)} </Text> <Text className={range({ className: classNames?.range, })} > {formatValue(selectedPoint.lower)} —{" "} {formatValue(selectedPoint.upper)} </Text> </Animated.View> <ForecastEnvelopeArtwork actualColor={actualColor} cursorX={cursorX} data={data} dividerColor={dividerColor} envelopeColor={envelopeColor} expectedColor={expectedColor} forecastStartIndex={forecastStartIndex} gridColor={gridColor} optimisticColor={optimisticColor} pessimisticColor={pessimisticColor} scenarioProgress={scenarioProgress} /> </View> </GestureDetector> );}forecast-envelope/uniwind/variants.tsimport { tv } from "tailwind-variants";export const forecastEnvelopeSlots = { container: "relative overflow-hidden", label: "text-[10px] font-black tracking-[1px] text-[#a9a89f]", range: "text-[10px] font-extrabold text-[#b9b0ff]", tooltip: "absolute top-2 z-10 w-37 items-center gap-0.5 rounded-2xl bg-[#171713] px-3 py-2", value: "text-[15px] font-black text-white",} as const;export type ForecastEnvelopeSlot = keyof typeof forecastEnvelopeSlots;export const forecastEnvelopeVariants = tv({ slots: forecastEnvelopeSlots, variants: { disabled: { true: { container: "opacity-50", }, }, },});forecast-envelope/use-forecast-envelope.tsimport { useCallback, useMemo, useState } from "react";import type { AccessibilityActionEvent, LayoutChangeEvent,} from "react-native";import { Gesture } from "react-native-gesture-handler";import { runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useReducedMotion, useSharedValue, withSpring, withTiming,} from "react-native-reanimated";import { FORECAST_ENVELOPE_CONFIG } from "./config";import type { ForecastEnvelopeBaseProps, ForecastEnvelopeScenario,} from "./types";type ChartLayout = { height: number; width: number;};const SCENARIO_INDEX: Record<ForecastEnvelopeScenario, number> = { pessimistic: 0, expected: 1, optimistic: 2,};function clamp(value: number, minimum: number, maximum: number) { "worklet"; return Math.min(maximum, Math.max(minimum, value));}function getSelectedValue( point: ForecastEnvelopeBaseProps["data"][number], scenario: ForecastEnvelopeScenario,) { if (point.actual !== undefined) { return point.actual; } if (scenario === "pessimistic") { return point.lower; } if (scenario === "optimistic") { return point.upper; } return point.expected;}export function useForecastEnvelope({ data, defaultSelectedIndex, disabled = false, forecastStartIndex, onSelectedIndexChange, scenario = "expected", selectedIndex,}: Pick< ForecastEnvelopeBaseProps, | "data" | "defaultSelectedIndex" | "disabled" | "forecastStartIndex" | "onSelectedIndexChange" | "scenario" | "selectedIndex">) { if (data.length < 3) { throw new Error( "ForecastEnvelope requires at least three data points.", ); } if ( forecastStartIndex < 1 || forecastStartIndex >= data.length ) { throw new Error( "ForecastEnvelope forecastStartIndex must be within the data range.", ); } const lastIndex = data.length - 1; const initialIndex = clamp( defaultSelectedIndex ?? forecastStartIndex, 0, lastIndex, ); const [internalIndex, setInternalIndex] = useState(initialIndex); const [layout, setLayout] = useState<ChartLayout>({ height: 0, width: 0, }); const isControlled = selectedIndex !== undefined; const currentIndex = clamp( selectedIndex ?? internalIndex, 0, lastIndex, ); const selectedPoint = data[currentIndex]; const selectedValue = getSelectedValue( selectedPoint, scenario, ); const reducedMotion = useReducedMotion(); const scenarioProgress = useDerivedValue(() => reducedMotion ? SCENARIO_INDEX[scenario] : withTiming(SCENARIO_INDEX[scenario], { duration: FORECAST_ENVELOPE_CONFIG.transitionDuration, }), ); const plotStart = FORECAST_ENVELOPE_CONFIG.plotPadding.left; const plotEnd = Math.max( plotStart, layout.width - FORECAST_ENVELOPE_CONFIG.plotPadding.right, ); const targetX = plotStart + (currentIndex / lastIndex) * (plotEnd - plotStart); const cursorX = useSharedValue(targetX); const dragging = useSharedValue(false); const lastPreviewedIndex = useSharedValue(currentIndex); const tooltipAnimatedStyle = useAnimatedStyle(() => ({ transform: [ { translateX: clamp( cursorX.get() - FORECAST_ENVELOPE_CONFIG.tooltipWidth / 2, 0, Math.max( 0, layout.width - FORECAST_ENVELOPE_CONFIG.tooltipWidth, ), ), }, ], })); const selectIndex = useCallback( (nextIndex: number) => { const boundedIndex = Math.min( lastIndex, Math.max(0, nextIndex), ); if (!isControlled) { setInternalIndex(boundedIndex); } onSelectedIndexChange?.(boundedIndex); }, [isControlled, lastIndex, onSelectedIndexChange], ); useAnimatedReaction( () => ({ dragging: dragging.get(), targetX, }), (current, previous) => { if ( current.dragging || current.targetX === previous?.targetX ) { return; } cursorX.set( reducedMotion ? current.targetX : withSpring( current.targetX, FORECAST_ENVELOPE_CONFIG.spring, ), ); lastPreviewedIndex.set(currentIndex); }, [currentIndex, reducedMotion, targetX], ); const gesture = useMemo( () => { const panGesture = Gesture.Pan() .enabled(!disabled) .activeOffsetX([-4, 4]) .failOffsetY([-12, 12]) .onStart((event) => { dragging.set(true); cursorX.set(clamp(event.x, plotStart, plotEnd)); }) .onUpdate((event) => { const nextX = clamp(event.x, plotStart, plotEnd); const nextIndex = Math.round( ((nextX - plotStart) / Math.max(1, plotEnd - plotStart)) * lastIndex, ); cursorX.set(nextX); if (nextIndex !== lastPreviewedIndex.get()) { lastPreviewedIndex.set(nextIndex); runOnJS(selectIndex)(nextIndex); } }) .onFinalize(() => { dragging.set(false); }); const tapGesture = Gesture.Tap() .enabled(!disabled) .onEnd((event) => { const nextX = clamp(event.x, plotStart, plotEnd); const nextIndex = Math.round( ((nextX - plotStart) / Math.max(1, plotEnd - plotStart)) * lastIndex, ); cursorX.set( reducedMotion ? nextX : withTiming(nextX, { duration: FORECAST_ENVELOPE_CONFIG.transitionDuration, }), ); lastPreviewedIndex.set(nextIndex); runOnJS(selectIndex)(nextIndex); }); return Gesture.Race(panGesture, tapGesture); }, [ cursorX, disabled, dragging, lastIndex, lastPreviewedIndex, plotEnd, plotStart, reducedMotion, selectIndex, ], ); const onLayout = useCallback((event: LayoutChangeEvent) => { const { height, width } = event.nativeEvent.layout; setLayout((currentLayout) => { if ( currentLayout.height === height && currentLayout.width === width ) { return currentLayout; } return { height, width }; }); }, []); const onAccessibilityAction = useCallback( (event: AccessibilityActionEvent) => { if (event.nativeEvent.actionName === "increment") { selectIndex(currentIndex + 1); } if (event.nativeEvent.actionName === "decrement") { selectIndex(currentIndex - 1); } }, [currentIndex, selectIndex], ); return { accessibilityActions: [ { name: "increment" as const }, { name: "decrement" as const }, ], currentIndex, cursorX, gesture, onAccessibilityAction, onLayout, scenarioProgress, selectedPoint, selectedValue, tooltipAnimatedStyle, };}