# Brush Chart

> A long series with an overview strip: drag the window or its handles and the chart above zooms to it.

- Category: Data
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `@shopify/react-native-skia`, `react-native-gesture-handler`, `react-native-reanimated`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add brush-chart
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/brush-chart.json
```

## Usage

```tsx
import { BrushChart } from '@/components/kinetik/brush-chart';

<BrushChart
  accessibilityLabel="Daily visitors"
  data={visitors}
  labels={dates}
  initialWindow={[0.7, 1]}
  onWindowChange={([from, to]) => load(from, to)}
/>;
```

## When to use

- Long time series where people need both the shape of the whole and the detail of a part: traffic, prices, sensor readings.
- Dashboards where the visible range drives other figures, via `onWindowChange`.

## When not to use

- A handful of points. Use Line Chart or Bar Chart.
- Comparing several series. Use Line Chart with a legend.

## Notes

- Drag inside the window to move it, pull either handle to zoom, or tap outside it to jump there.
- The main chart is rebuilt on the UI thread from the window and rescales to the visible range; the overview is drawn once per size.
- The readout shows the last visible value and the change across the window, and updates when the window settles. The range is an adjustable element: screen readers step it by half a window.
- `onWindowChange` receives the first and last visible point indices when the window settles.

## BrushChartProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `data` | `readonly number[]` |  | The series, oldest first. |
| `accessibilityLabel` | `string` |  | What the series is, e.g. "Daily visitors". |
| `labels`? | `readonly string[]` |  | One label per point, e.g. dates. Defaults to the point numbers. |
| `initialWindow`? | `readonly [number, number]` | `[2 / 3, 1]` | The part shown at first, as fractions of the series. Defaults to the last third. |
| `onWindowChange`? | `(range: [number, number]) => void` |  |  |
| `formatValue`? | `(value: number) => string` | `(v) => String(Math.round(v))` | Formats values in the readout and for screen readers. |
| `height`? | `number` | `180` | Main chart height. Defaults to 180. |
| `haptics`? | `boolean` | `true` | A tick when the window settles. Defaults to true. |
| `testID`? | `string` |  |  |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: Screen reader steps move the window at once instead of springing.
- Screen readers: The chart is an image that reads the visible range and its first and last values; the strip is an adjustable range.
- Touch target: The whole strip is the target, 52 pt tall; handles respond 22 pt either side.

## Performance

Two Skia canvases. The visible part is rebuilt on the UI thread while dragging; the overview path is built once per size. Nothing runs at rest.

- Measured: Pixel 9 emulator, API 35, release build · interaction median 0.94× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 163 MB app memory. Before the window stopped re-rendering React on every drag frame this was 32 ms median with 36% janky frames.
- Real-device measurement pending.

## Source

`components/kinetik/brush-chart.tsx`

```tsx
import {
  Canvas,
  Circle,
  Group,
  Line,
  LinearGradient,
  Path,
  Rect,
  RoundedRect,
  Skia,
  vec,
  type SkPath,
} from '@shopify/react-native-skia';
import { useMemo, useState } from 'react';
import {
  StyleSheet,
  Text,
  View,
  type AccessibilityActionEvent,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { useDerivedValue, useSharedValue, withSpring } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';

export type BrushChartProps = {
  /** The series, oldest first. */
  data: readonly number[];
  /** One label per point, e.g. dates. Defaults to the point numbers. */
  labels?: readonly string[];
  /** The part shown at first, as fractions of the series. Defaults to the last third. */
  initialWindow?: readonly [number, number];
  onWindowChange?: (range: [number, number]) => void;
  /** Formats values in the readout and for screen readers. */
  formatValue?: (value: number) => string;
  /** Main chart height. Defaults to 180. */
  height?: number;
  /** What the series is, e.g. "Daily visitors". */
  accessibilityLabel: string;
  /** A tick when the window settles. Defaults to true. */
  haptics?: boolean;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const OVERVIEW = 52;
const HANDLE = 22;
const PAD_Y = 14;

type Window = { start: number; end: number };

/** The first and last point indices inside a window of fractions. */
export function windowIndices(n: number, start: number, end: number): [number, number] {
  'worklet';
  const last = Math.max(0, n - 1);
  return [
    Math.max(0, Math.floor(start * last + 1e-6)),
    Math.min(last, Math.ceil(end * last - 1e-6)),
  ];
}

function linePath(
  data: readonly number[],
  i0: number,
  i1: number,
  x: (i: number) => number,
  y: (v: number) => number,
): SkPath {
  'worklet';
  const b = Skia.PathBuilder.Make();
  for (let i = i0; i <= i1; i++) {
    if (i === i0) b.moveTo(x(i), y(data[i]!));
    else b.lineTo(x(i), y(data[i]!));
  }
  return b.detach();
}

/**
 * A dense series with an overview strip. Drag the window in the strip to
 * move through time, pull its handles to zoom, or tap outside it to jump;
 * the main chart above redraws the part inside the window on the UI thread,
 * rescaling as it goes. React only hears about the window when it settles.
 */
export function BrushChart({
  data,
  labels,
  initialWindow = [2 / 3, 1],
  onWindowChange,
  formatValue = (v) => String(Math.round(v)),
  height = 180,
  accessibilityLabel,
  haptics = true,
  testID,
  style,
}: BrushChartProps) {
  const { colors, font, radius, space } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const [width, setWidth] = useState(0);
  const n = data.length;
  const minSpan = n > 1 ? Math.max(4 / (n - 1), 0.04) : 1;

  const start = useSharedValue(initialWindow[0]);
  const end = useSharedValue(initialWindow[1]);
  const begin = useSharedValue<Window>({ start: 0, end: 1 });
  // The strip width for the gesture, which runs on the UI thread.
  const strip = useSharedValue(0);
  const mode = useSharedValue<'move' | 'left' | 'right'>('move');
  const [range, setRange] = useState<[number, number]>(() =>
    windowIndices(n, initialWindow[0], initialWindow[1]),
  );

  const label = (i: number) => labels?.[i] ?? String(i + 1);
  const report = (i0: number, i1: number) => {
    setRange((r) => (r[0] === i0 && r[1] === i1 ? r : [i0, i1]));
  };
  const commit = (i0: number, i1: number) => {
    haptic('tick');
    report(i0, i1);
    onWindowChange?.([i0, i1]);
  };

  // The whole series in the overview strip, drawn once per size.
  const overview = useMemo(() => {
    if (width === 0 || n < 2) return null;
    const lo = Math.min(...data);
    const hi = Math.max(...data);
    const span = hi - lo || 1;
    const x = (i: number) => (i / (n - 1)) * width;
    const y = (v: number) => 6 + (1 - (v - lo) / span) * (OVERVIEW - 12);
    return linePath(data, 0, n - 1, x, y);
  }, [data, n, width]);

  // The main chart: the window's points, scaled to the window's own range.
  const main = useDerivedValue(() => {
    const [i0, i1] = windowIndices(n, start.get(), end.get());
    if (width === 0 || i1 <= i0)
      return {
        line: Skia.PathBuilder.Make().detach(),
        area: Skia.PathBuilder.Make().detach(),
        lastX: 0,
        lastY: 0,
      };
    let lo = Infinity;
    let hi = -Infinity;
    for (let i = i0; i <= i1; i++) {
      lo = Math.min(lo, data[i]!);
      hi = Math.max(hi, data[i]!);
    }
    const span = hi - lo || 1;
    const s = start.get();
    const k = width / Math.max(1e-6, end.get() - s);
    const x = (i: number) => (i / (n - 1) - s) * k;
    const y = (v: number) => PAD_Y + (1 - (v - lo) / span) * (height - PAD_Y * 2);
    const line = linePath(data, i0, i1, x, y);
    const area = Skia.PathBuilder.Make()
      .addPath(line)
      .lineTo(x(i1), height)
      .lineTo(x(i0), height)
      .close()
      .detach();
    return { line, area, lastX: x(i1), lastY: y(data[i1]!) };
  });
  const line = useDerivedValue(() => main.get().line);
  const area = useDerivedValue(() => main.get().area);
  const lastX = useDerivedValue(() => main.get().lastX);
  const lastY = useDerivedValue(() => main.get().lastY);

  const winX = useDerivedValue(() => start.get() * width);
  const winW = useDerivedValue(() => (end.get() - start.get()) * width);
  const leftShade = useDerivedValue(() => start.get() * width);
  const rightX = useDerivedValue(() => end.get() * width);
  const rightW = useDerivedValue(() => (1 - end.get()) * width);
  const handleL = useDerivedValue(() => start.get() * width - 2);
  const handleR = useDerivedValue(() => end.get() * width - 2);

  const pan = Gesture.Pan()
    .withTestId('brush-chart')
    .minDistance(0)
    .onBegin((e) => {
      const width = strip.get();
      if (width === 0) return;
      const sx = start.get() * width;
      const ex = end.get() * width;
      if (Math.abs(e.x - sx) <= HANDLE && Math.abs(e.x - sx) <= Math.abs(e.x - ex)) {
        mode.set('left');
      } else if (Math.abs(e.x - ex) <= HANDLE) {
        mode.set('right');
      } else {
        mode.set('move');
        if (e.x < sx || e.x > ex) {
          // A tap outside the window centres it there.
          const half = (end.get() - start.get()) / 2;
          const c = Math.max(half, Math.min(1 - half, e.x / width));
          start.set(c - half);
          end.set(c + half);
        }
      }
      begin.set({ start: start.get(), end: end.get() });
    })
    .onUpdate((e) => {
      const width = strip.get();
      if (width === 0) return;
      const d = e.translationX / width;
      const b = begin.get();
      const m = mode.get();
      if (m === 'move') {
        const span = b.end - b.start;
        const s = Math.max(0, Math.min(1 - span, b.start + d));
        start.set(s);
        end.set(s + span);
      } else if (m === 'left') {
        start.set(Math.max(0, Math.min(b.end - minSpan, b.start + d)));
      } else {
        end.set(Math.min(1, Math.max(b.start + minSpan, b.end + d)));
      }
    })
    .onFinalize(() => {
      const [i0, i1] = windowIndices(n, start.get(), end.get());
      scheduleOnRN(commit, i0, i1);
    });

  const nudge = (e: AccessibilityActionEvent) => {
    const span = end.get() - start.get();
    const step = span * 0.5 * (e.nativeEvent.actionName === 'increment' ? 1 : -1);
    const s = Math.max(0, Math.min(1 - span, start.get() + step));
    start.set(reduced ? s : withSpring(s, spring('smooth')));
    end.set(reduced ? s + span : withSpring(s + span, spring('smooth')));
    const [i0, i1] = windowIndices(n, s, s + span);
    commit(i0, i1);
  };

  const [a, b] = range;
  const first = data[a];
  const last = data[b];
  const change =
    first !== undefined && last !== undefined && first !== 0 ? (last - first) / Math.abs(first) : 0;
  const summary = `${label(a)} to ${label(b)}`;

  return (
    <View testID={testID} style={[{ gap: space[3] }, style]}>
      <View style={styles.readout}>
        <View style={styles.grow}>
          <Text style={{ color: colors.textSubtle, fontSize: font.size.xs }}>{summary}</Text>
          <Text
            style={{
              color: colors.text,
              fontSize: font.size.xl,
              fontWeight: font.weight.semibold,
              fontVariant: ['tabular-nums'],
            }}
          >
            {last !== undefined ? formatValue(last) : '–'}
          </Text>
        </View>
        <Text
          style={{
            color: change >= 0 ? colors.success : colors.danger,
            fontSize: font.size.sm,
            fontWeight: font.weight.semibold,
            fontVariant: ['tabular-nums'],
          }}
        >
          {change >= 0 ? '+' : '−'}
          {Math.abs(change * 100).toFixed(1)}%
        </Text>
      </View>
      <View
        accessible
        accessibilityRole="image"
        accessibilityLabel={`${accessibilityLabel}, ${summary}, from ${first !== undefined ? formatValue(first) : '–'} to ${last !== undefined ? formatValue(last) : '–'}`}
        style={{ height }}
        onLayout={(e) => {
          setWidth(e.nativeEvent.layout.width);
          strip.set(e.nativeEvent.layout.width);
        }}
      >
        {width > 0 ? (
          <Canvas style={{ width, height }}>
            {[0.25, 0.5, 0.75].map((f) => (
              <Line
                key={f}
                p1={vec(0, height * f)}
                p2={vec(width, height * f)}
                color={colors.border}
                strokeWidth={1}
                style="stroke"
              />
            ))}
            <Path path={area}>
              <LinearGradient
                start={vec(0, 0)}
                end={vec(0, height)}
                colors={[withAlpha(colors.azure, 0.28), withAlpha(colors.azure, 0)]}
              />
            </Path>
            <Path
              path={line}
              style="stroke"
              strokeWidth={2}
              strokeJoin="round"
              color={colors.azure}
            />
            <Circle cx={lastX} cy={lastY} r={4} color={colors.azure} />
          </Canvas>
        ) : null}
      </View>
      <GestureDetector gesture={pan}>
        <View
          collapsable={false}
          accessible
          accessibilityRole="adjustable"
          accessibilityLabel={`${accessibilityLabel} range`}
          accessibilityValue={{ text: summary }}
          accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
          onAccessibilityAction={nudge}
          style={[styles.overview, { backgroundColor: colors.surface, borderRadius: radius.sm }]}
        >
          {width > 0 && overview ? (
            <Canvas style={{ width, height: OVERVIEW }}>
              <Path path={overview} style="stroke" strokeWidth={1.25} color={colors.textSubtle} />
              <Rect
                x={0}
                y={0}
                width={leftShade}
                height={OVERVIEW}
                color={withAlpha(colors.background, 0.55)}
              />
              <Rect
                x={rightX}
                y={0}
                width={rightW}
                height={OVERVIEW}
                color={withAlpha(colors.background, 0.55)}
              />
              <Group>
                <Rect
                  x={winX}
                  y={0}
                  width={winW}
                  height={OVERVIEW}
                  color={withAlpha(colors.azure, 0.12)}
                />
                <Rect
                  x={winX}
                  y={0.75}
                  width={winW}
                  height={OVERVIEW - 1.5}
                  color={colors.azure}
                  style="stroke"
                  strokeWidth={1.5}
                />
              </Group>
              <RoundedRect
                x={handleL}
                y={OVERVIEW / 2 - 10}
                width={4}
                height={20}
                r={2}
                color={colors.azure}
              />
              <RoundedRect
                x={handleR}
                y={OVERVIEW / 2 - 10}
                width={4}
                height={20}
                r={2}
                color={colors.azure}
              />
            </Canvas>
          ) : null}
        </View>
      </GestureDetector>
    </View>
  );
}

const styles = StyleSheet.create({
  readout: { flexDirection: 'row', alignItems: 'flex-end', gap: 12 },
  grow: { flex: 1, gap: 2 },
  overview: { height: OVERVIEW, overflow: 'hidden' },
});
```
