# Chart

> Organism — orn-ui

> ⚠️ Requires <UIProvider>: This component (like every orn-ui component) must render inside a <UIProvider> ancestor, or it throws at runtime. See https://orn-ui-docs.vercel.app/getting-started.md

> Runs on Expo SDK 54, 55, 56 and 57, and on bare React Native >=0.81 with react >=19.1. No native modules — works in Expo Go, no prebuild.

## Installation

Install just this component (copies the source into your project, no npm dependency)
```bash
npx orn-ui add chart
```

Or install the whole package and import it
```tsx
pnpm add orn-ui
import { Chart } from 'orn-ui/chart';
```

Depends on: core (theme + icons), segmented-control

## Variants

### bar: horizontal

```tsx
<Card>
  <Chart
    type="bar"
    orientation="horizontal"
    data={SHARE}
    series={[{ key: 'users', label: 'Users' }]}
    accessibilityLabel="Users per platform"
  />
</Card>
```

### line

```tsx
<Card>
  <Chart type="line" data={MONTHS} series={SERIES} accessibilityLabel="Sales and costs per month" />
</Card>
```

### area

```tsx
<Card>
  <Chart type="area" smooth data={MONTHS} series={[{ key: 'sales', label: 'Sales' }]} accessibilityLabel="Sales per month" />
</Card>
```

### donut

```tsx
<Card>
  <Chart type="donut" data={SHARE} series={[{ key: 'users' }]} sliceLabels="value" accessibilityLabel="Users per platform" />
</Card>
```

### radar

```tsx
<Card>
  <Chart
    type="radar"
    height={260}
    data={SKILLS}
    series={[
      { key: 'mine', label: 'orn-ui' },
      { key: 'theirs', label: 'Other' },
    ]}
    accessibilityLabel="Library comparison"
  />
</Card>
```

### bar: grouped

```tsx
<Card>
  <Chart type="bar" data={MONTHS} series={SERIES} accessibilityLabel="Sales and costs per month" />
</Card>
```

### bar: stacked

```tsx
<Card>
  <Chart type="bar" stacked data={MONTHS} series={SERIES} accessibilityLabel="Sales and costs stacked per month" />
</Card>
```

### bar: negative values

```tsx
<Card>
  <Chart type="bar" data={NET} series={[{ key: 'net', label: 'Net' }]} accessibilityLabel="Net result per quarter" />
</Card>
```

### line: smooth

```tsx
<Card>
  <Chart type="line" smooth data={MONTHS} series={SERIES} accessibilityLabel="Sales and costs per month, smoothed" />
</Card>
```

### area: stacked

```tsx
<Card>
  <Chart type="area" stacked data={MONTHS} series={SERIES} accessibilityLabel="Sales and costs stacked per month" />
</Card>
```

### pie

```tsx
<Card>
  <Chart type="pie" data={SHARE} series={[{ key: 'users' }]} accessibilityLabel="Share of users per platform" />
</Card>
```

### no data

```tsx
<Card>
  <Chart type="bar" data={[]} series={SERIES} emptyText="No sales yet" accessibilityLabel="Sales per month" />
</Card>
```

## Full demo source

```tsx
/**
 * Un chart de verdad: filtro de rango arriba, leyenda que apaga series y un
 * eco de la selección debajo. El eco existe para el flow de Maestro — la
 * barra resaltada y el globo son píxeles, y un assert no los ve.
 */
function Interactive() {
  const [range, setRange] = useState('6');
  const [picked, setPicked] = useState<ChartSelection | null>(null);
  const rows = useMemo(() => MONTHS.slice(-Number(range)), [range]);

  // Cambiar el rango suelta la selección: el mes elegido puede no estar en el
  // recorte nuevo, y dejar el eco hablando de un mes que ya no se ve miente.
  const changeRange = (next: string) => {
    setRange(next);
    setPicked(null);
  };

  return (
    <View style={{ gap: 12 }}>
      <Card>
        <Chart
          type="bar"
          data={rows}
          series={SERIES}
          onSelect={setPicked}
          formatValue={(value) => `$${value}`}
          filters={[{ key: 'range', value: range, onChange: changeRange, options: RANGES }]}
          accessibilityLabel="Sales and costs per month"
        />
      </Card>
      <Body>{picked ? `${picked.label}: ${picked.seriesKey} $${picked.value}` : 'Tap a month'}</Body>
    </View>
  );
}

/**
 * Datos que llegan solos, que es lo que hace un WebSocket o un SSE: acá el
 * que empuja es un `setInterval` porque un demo no debería depender de una
 * red, pero el cableado con Chart es el mismo — `setRows` y nada más.
 *
 * Las tres props que hacen que un chart en vivo se lea:
 * - `window`, la ventana deslizante;
 * - `domain="sticky"`, para que el eje no se reacomode en cada tick y haga
 *   saltar la línea aunque el dato no se haya movido;
 * - `selectBy`, para que el punto elegido siga a su dato mientras la ventana
 *   corre, en vez de quedarse pegado a un índice que ya es de otro.
 */
function LiveChart() {
  const [rows, setRows] = useState<ChartRow[]>([{ t: 0, value: 50 }]);
  const [picked, setPicked] = useState<ChartSelection | null>(null);
  const tick = useRef(0);

  useEffect(() => {
    const id = setInterval(() => {
      setRows((previous) => {
        const last = Number(previous[previous.length - 1]?.value ?? 50);
        tick.current += 1;
        // Paseo aleatorio acotado: sube y baja como una métrica de verdad, sin
        // irse a un número que haga ilegible al resto.
        const next = Math.max(5, Math.min(95, last + (Math.random() - 0.5) * 24));
        return [...previous, { t: tick.current, value: Math.round(next) }];
      });
    }, 350);
    return () => clearInterval(id);
  }, []);

  return (
    <View style={{ gap: 12 }}>
      <Card>
        <Chart
          type="area"
          smooth
          window={60}
          domain="sticky"
          selectBy={(row) => String(row.t)}
          xAxis={false}
          data={rows}
          series={[{ key: 'value', label: 'Requests/s' }]}
          onSelect={setPicked}
          animate={false}
          accessibilityLabel="Requests per second, live"
        />
      </Card>
      <Body>{picked ? `Held at t${picked.id}: ${picked.value}` : 'Tap a point — it follows its datum as the window slides'}</Body>
    </View>
  );
}

export function ChartDemo() {
  const variants: VariantDef[] = [
    { label: 'interactive: filter, legend, tooltip', content: <Interactive /> },
    {
      label: 'bar: horizontal',
      content: (
        <Card>
          <Chart
            type="bar"
            orientation="horizontal"
            data={SHARE}
            series={[{ key: 'users', label: 'Users' }]}
            accessibilityLabel="Users per platform"
          />
        </Card>
      ),
    },
    {
      label: 'line',
      content: (
        <Card>
          <Chart type="line" data={MONTHS} series={SERIES} accessibilityLabel="Sales and costs per month" />
        </Card>
      ),
    },
    {
      label: 'area',
      content: (
        <Card>
          <Chart type="area" smooth data={MONTHS} series={[{ key: 'sales', label: 'Sales' }]} accessibilityLabel="Sales per month" />
        </Card>
      ),
    },
    {
      label: 'donut',
      content: (
        <Card>
          <Chart type="donut" data={SHARE} series={[{ key: 'users' }]} sliceLabels="value" accessibilityLabel="Users per platform" />
        </Card>
      ),
    },
    {
      label: 'radar',
      content: (
        <Card>
          <Chart
            type="radar"
            height={260}
            data={SKILLS}
            series={[
              { key: 'mine', label: 'orn-ui' },
              { key: 'theirs', label: 'Other' },
            ]}
            accessibilityLabel="Library comparison"
          />
        </Card>
      ),
    },
    {
      label: 'bar: grouped',
      content: (
        <Card>
          <Chart type="bar" data={MONTHS} series={SERIES} accessibilityLabel="Sales and costs per month" />
        </Card>
      ),
    },
    {
      label: 'bar: stacked',
      content: (
        <Card>
          <Chart type="bar" stacked data={MONTHS} series={SERIES} accessibilityLabel="Sales and costs stacked per month" />
        </Card>
      ),
    },
    {
      label: 'bar: negative values',
      content: (
        <Card>
          <Chart type="bar" data={NET} series={[{ key: 'net', label: 'Net' }]} accessibilityLabel="Net result per quarter" />
        </Card>
      ),
    },
    {
      label: 'line: smooth',
      content: (
        <Card>
          <Chart type="line" smooth data={MONTHS} series={SERIES} accessibilityLabel="Sales and costs per month, smoothed" />
        </Card>
      ),
    },
    {
      label: 'area: stacked',
      content: (
        <Card>
          <Chart type="area" stacked data={MONTHS} series={SERIES} accessibilityLabel="Sales and costs stacked per month" />
        </Card>
      ),
    },
    {
      label: 'pie',
      content: (
        <Card>
          <Chart type="pie" data={SHARE} series={[{ key: 'users' }]} accessibilityLabel="Share of users per platform" />
        </Card>
      ),
    },
    { label: 'live: window, sticky domain, selectBy', content: <LiveChart /> },
    {
      label: 'no data',
      content: (
        <Card>
          <Chart type="bar" data={[]} series={SERIES} emptyText="No sales yet" accessibilityLabel="Sales per month" />
        </Card>
      ),
    },
  ];
  return <VariantList variants={variants} />;
```

## Props

| Name | Type | Default | Description |
|---|---|---|---|
| `data` | `ChartRow[]` | — | The rows. Each one is a category on the x axis plus a number per series. |
| `series` | `ChartSeries[]` | — | The series to draw. In 'pie'/'donut' only the first is used: the categories of a pie are its rows, not its columns. |
| `xKey?` | `string` | x | The column holding each row's label. |
| `selectBy?` | `(row: ChartRow, index: number) => string` | — | Stable identity for a row. Without it the selection is held by index, and in a sliding window the index moves: the highlight ends up pointing at another datum with nobody touching it. With it the highlight follows its datum, and disappears only once the datum leaves the window. |
| `window?` | `number` | — | Draws only the last N rows — the sliding window of a live chart, so whoever pushes the data does not have to trim it on every tick. |
| `height?` | `number` | 220 | Height of the drawing area, not counting filters or legend. |
| `legend?` | `'none' \| 'auto' \| 'static' \| 'toggle'` | 'auto' — toggle with two or more series, none with one | 'toggle' turns the legend into the cheapest filter a chart has: tapping a series turns it off. |
| `hiddenKeys?` | `string[]` | — | Series that are off, controlled. Without it Chart remembers them on its own. |
| `defaultHiddenKeys?` | `string[]` | [] | Series that start off, uncontrolled. |
| `onHiddenKeysChange?` | `(keys: string[]) => void` | — | Reports the new set of hidden series. |
| `filters?` | `ChartFilter[]` | — | Controls above the chart, one per filter. Chart shows them and reports the change; it does not filter anything itself. |
| `selected?` | `ChartSelection` | — | The highlighted point, controlled. null means none. |
| `onSelect?` | `(selection: ChartSelection) => void` | — | Reports the point that was tapped, or null when the selection is released. |
| `tooltip?` | `boolean` | true | Balloon with the values of the category that was tapped. |
| `formatValue?` | `(value: number) => string` | compact — 1.2k, 3.4M | Formats every number on the axis and in the tooltip. |
| `animate?` | `boolean` | true | Bars travel to their new value instead of jumping. Turn it off for high-frequency updates. |
| `duration?` | `number` | 500 | Milliseconds the marks take to animate. |
| `emptyText?` | `string` | — | Shown when there is nothing left to draw. |
| `accessibilityLabel` | `string` | — | Required: a chart has no text of its own to describe it. |
| `style?` | `StyleProp<ViewStyle>` | — | — |
| `type` | `'bar' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'radar'` | — | The family of mark to draw. A discriminated union: the props of one type do not typecheck on another. |
| `orientation?` | `'vertical' \| 'horizontal'` | vertical | 'horizontal' puts the categories on the vertical axis. |
| `stacked?` | `boolean` | false | Stacks the series instead of placing them side by side. |
| `barRadius?` | `number` | 4 | Radius of the free end of each bar. |
| `domain?` | `[number, number] \| "sticky"` | — | Fixes the value axis. By default it is recomputed with every datum, which is right for a static chart and worst for a live one: a higher value arrives, the whole scale rearranges and the line jumps although the datum did not move. [min, max] is exact, with no rounding; 'sticky' starts from the data and grows when something does not fit, but never gives the room back. |
| `grid?` | `boolean` | true | Reference lines on the value axis. |
| `ticks?` | `number` | 4 | Ticks wanted on the value axis; rounding may give one more or one less. |
| `xAxis?` | `boolean` | true | Labels on the horizontal axis. |
| `yAxis?` | `boolean` | true | Labels on the vertical axis. |
| `decimate?` | `number \| false` | the measured width — one point per two pixels | How many points to draw at most, chosen with LTTB. false draws them all. |
| `smooth?` | `boolean` | false | Catmull-Rom instead of straight segments. The curve passes through every original point. |
| `thickness?` | `number` | 2 | Line thickness in pixels. |
| `dots?` | `boolean` | true on 'line', false on 'area' | A dot on each datum. |
| `fillOpacity?` | `number` | 0.22 | Opacity of the fill under the line. |
| `innerRadius?` | `number` | 0 for 'pie', 0.6 for 'donut' | Hole as a fraction of the radius, 0–0.9. |
| `sliceLabels?` | `'none' \| 'percent' \| 'value'` | percent | What to write on each slice. A slice narrower than 18° gets none — the label would land on its neighbours. |
| `levels?` | `number` | 4 | Rings of the web. |
| `fill?` | `boolean` | true | Fills the polygon as well as drawing its outline. |
