orn-ui
Organism

Chart

Requires <UIProvider> — Getting Started →

Installation

Install just this component (copies the source into your project, no npm dependency)

npx orn-ui add chart

Or install the whole package and import it

pnpm add orn-ui
import { Chart } from 'orn-ui/chart';
Depends on:core (theme + icons)segmented-control

Usage

  • when to use — Six chart families in one component, with no native dependency. React Native has no vector canvas across Expo SDK 54–57, so everything here is composed out of View: bars, a rotated rectangle per line segment, and a clipped half-disc per slice.
  • type — bar, line, area, pie, donut and radar. The prop is a discriminated union, so innerRadius on a bar chart does not compile. bar adds orientation and stacked; line and area add smooth, dots and decimate; pie and donut differ only in innerRadius; radar measures from the centre, so its minimum is always zero.
  • data / series / xKey — Rows in, one column per series. In a pie the categories are the rows rather than the columns, so only the first series is read — and the legend lists the rows instead.
  • legend — The cheapest filter a chart has. 'toggle' (the default with two or more series) turns a series off on tap; it repeats the series colour, so it never has to explain what it turns off.
  • filters — Renders your controls above the chart and reports the change. Chart does not filter anything — what "last 7 days" means belongs to whoever has the data.
  • selected / onSelect — A tap selects a whole category: the tooltip shows every visible series and everything else dims. The touch target is the band, not the mark — tapping a 2px line is impossible. Tapping the same point again lets it go.
  • selectBy — Pair it with window whenever the data moves. Without it the selection is held by index, and in a sliding window the index shifts: the highlight silently ends up on a different datum.
  • domain / window / decimate — The three that make a live chart readable. domain='sticky' stops the axis rearranging on every tick; window keeps the last N rows; decimate thins a line with LTTB down to what the measured width can show. Bars are never thinned — a missing bar reads as missing data.
  • colors — Derived from the theme, not listed: the first series reuse its accents and the rest rotate the primary's hue. Every one clears 3:1 against surface, the WCAG minimum for a graphical object. Override one series at a time with series[].color.
  • accessibilityLabel — Required: a chart has no text of its own to describe it. Each band also carries its category and its numbers, so a screen reader can walk the data.
Demo clip not recorded yet — see MEDIA.md

Variants

bar: horizontal

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

line

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

area

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

donut

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

radar

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

bar: grouped

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

bar: stacked

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

bar: negative values

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

line: smooth

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

area: stacked

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

pie

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

no data

<Card>
  <Chart type="bar" data={[]} series={SERIES} emptyText="No sales yet" accessibilityLabel="Sales per month" />
</Card>
Full demo source
/**
 * 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

Also accepts testID, forwarded to the root node. It exists for end-to-end tests (Maestro drives the app by the accessibility tree) and has no effect on how the component looks or behaves.

NameTypeDefaultDescription
dataChartRow[]The rows. Each one is a category on the x axis plus a number per series.
seriesChartSeries[]The series to draw. In 'pie'/'donut' only the first is used: the categories of a pie are its rows, not its columns.
xKey?stringxThe column holding each row's label.
selectBy?(row: ChartRow, index: number) => stringStable 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?numberDraws 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?number220Height 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[]) => voidReports 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?ChartSelectionThe highlighted point, controlled. null means none.
onSelect?(selection: ChartSelection) => voidReports the point that was tapped, or null when the selection is released.
tooltip?booleantrueBalloon with the values of the category that was tapped.
formatValue?(value: number) => stringcompact — 1.2k, 3.4MFormats every number on the axis and in the tooltip.
animate?booleantrueBars travel to their new value instead of jumping. Turn it off for high-frequency updates.
duration?number500Milliseconds the marks take to animate.
emptyText?stringShown when there is nothing left to draw.
accessibilityLabelstringRequired: 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?booleanfalseStacks the series instead of placing them side by side.
barRadius?number4Radius 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?booleantrueReference lines on the value axis.
ticks?number4Ticks wanted on the value axis; rounding may give one more or one less.
xAxis?booleantrueLabels on the horizontal axis.
yAxis?booleantrueLabels on the vertical axis.
decimate?number | falsethe measured width — one point per two pixelsHow many points to draw at most, chosen with LTTB. false draws them all.
smooth?booleanfalseCatmull-Rom instead of straight segments. The curve passes through every original point.
thickness?number2Line thickness in pixels.
dots?booleantrue on 'line', false on 'area'A dot on each datum.
fillOpacity?number0.22Opacity of the fill under the line.
innerRadius?number0 for 'pie', 0.6 for 'donut'Hole as a fraction of the radius, 0–0.9.
sliceLabels?'none' | 'percent' | 'value'percentWhat to write on each slice. A slice narrower than 18° gets none — the label would land on its neighbours.
levels?number4Rings of the web.
fill?booleantrueFills the polygon as well as drawing its outline.