Organism
Chart
Requires <UIProvider> — Getting Started →
Installation
Install just this component (copies the source into your project, no npm dependency)
npx orn-ui add chartOr install the whole package and import it
pnpm add orn-ui
import { Chart } from 'orn-ui/chart';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.
| 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. |