Veltrix

data visualization

Forecast Envelope

A Victory-powered forecast combines actuals, animated uncertainty bounds, scenario lines, and scrubbable values.

AppleiOSAndroidAndroidExpoExpo Go
victoryskiagesture handlerreanimatedworklets
GitHubOpen in GitHub

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-worklets

Data 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.
  • scenario selects the emphasized forecast path.
  • Dragging snaps the cursor to the nearest point.

Install tailwind variants

pnpm add tailwind-variants

Usage

ForecastEnvelopeExample.tsx
import { 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"    />  );}

Component files

6 files

forecast-envelope/artwork.tsx
import {  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.ts
export 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.ts
export 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.tsx
import { 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.ts
import { 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.ts
import { 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,  };}

API reference

Generated directly from the exported component props.

ForecastEnvelope