Organism
Chart
Requiere <UIProvider> — Primeros pasos →
Instalación
Instala solo este componente (copia el código fuente a tu proyecto, sin dependencia npm)
npx orn-ui add chartO instala el paquete completo e impórtalo
pnpm add orn-ui
import { Chart } from 'orn-ui/chart';Uso
- cuándo usarlo — Seis familias de gráfico en un solo componente, sin dependencia nativa. React Native no trae lienzo vectorial en el rango soportado (Expo SDK 54–57), así que acá todo se compone con View: barras, un rectángulo rotado por tramo de línea y un medio disco recortado por porción.
- type — bar, line, area, pie, donut y radar. Es una unión discriminada, así que un innerRadius en un chart de barras no compila. bar agrega orientation y stacked; line y area agregan smooth, dots y decimate; pie y donut se diferencian sólo en innerRadius; el radar mide desde el centro, y por eso su mínimo es siempre cero.
- data / series / xKey — Filas adentro, una columna por serie. En una torta las categorías son las filas y no las columnas, así que sólo se lee la primera serie — y la leyenda lista las filas.
- legend — El filtro más barato que tiene un chart. 'toggle' (el default con dos o más series) apaga una serie al tocarla; repite el color de la serie, así que nunca tiene que explicar qué apaga.
- filters — Dibuja tus controles arriba del chart y avisa el cambio. Chart no filtra nada — qué significa "últimos 7 días" es de quien tiene los datos.
- selected / onSelect — Un toque elige la categoría entera: el globo muestra todas las series visibles y el resto se atenúa. El blanco táctil es la banda, no la marca — tocar una línea de 2px es imposible. Tocar el mismo punto otra vez lo suelta.
- selectBy — Va junto con window siempre que los datos se muevan. Sin él la selección se guarda por índice, y en una ventana deslizante el índice corre: el resaltado termina, en silencio, sobre otro dato.
- domain / window / decimate — Las tres que hacen legible un chart en vivo. domain='sticky' evita que el eje se reacomode en cada tick; window se queda con las últimas N filas; decimate adelgaza una línea con LTTB hasta lo que el ancho medido puede mostrar. Las barras nunca se adelgazan — una barra que falta se lee como un dato que falta.
- colores — Derivados del theme, no listados: las primeras series reusan sus acentos y el resto rota el tono del primario. Todos superan 3:1 contra surface, el mínimo de WCAG para un objeto gráfico. Se pisa serie por serie con series[].color.
- accessibilityLabel — Obligatorio: un chart no tiene texto propio que lo describa. Cada banda además lleva su categoría y sus números, así un lector de pantalla puede recorrer los datos.
Clip del demo todavía sin grabar — ver MEDIA.md
Variantes
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>Código completo del demo
/**
* 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
Además acepta testID, que se reenvía al nodo raíz. Existe para los tests end-to-end (Maestro maneja la app por el árbol de accesibilidad) y no cambia en nada cómo se ve ni cómo se comporta el componente.
| Nombre | Tipo | Por defecto | Descripción |
|---|---|---|---|
data | ChartRow[] | — | — |
series | ChartSeries[] | — | Series a dibujar. En 'pie'/'donut' sólo se usa la primera: las categorías de una torta son las filas, no las columnas. |
xKey? | string | x | Columna con la etiqueta de cada fila. |
selectBy? | (row: ChartRow, index: number) => string | — | Identidad estable de una fila. Sin esto la selección se guarda por índice, y en una ventana deslizante el índice corre: el punto resaltado pasa a señalar otro dato sin que nadie lo toque. Con esto el resaltado sigue al dato, y desaparece solo cuando el dato sale de la ventana. |
window? | number | — | Dibuja sólo las últimas N filas. Es la ventana deslizante de un chart en vivo: quien empuja los datos no tiene que recortarlos en cada tick. |
height? | number | 220 | Alto del área de dibujo, sin contar filtros ni leyenda. |
legend? | 'none' | 'auto' | 'static' | 'toggle' | 'auto' — toggle con dos o más series, none con una | 'toggle' convierte la leyenda en el filtro más barato que tiene un chart: tocar una serie la apaga. |
hiddenKeys? | string[] | — | Series apagadas, controlado. Sin esto, Chart se las acuerda solo. |
defaultHiddenKeys? | string[] | [] | — |
onHiddenKeysChange? | (keys: string[]) => void | — | — |
filters? | ChartFilter[] | — | Controles arriba del chart, uno por filtro. |
selected? | ChartSelection | — | Punto resaltado, controlado. `null` es "ninguno". |
onSelect? | (selection: ChartSelection) => void | — | — |
tooltip? | boolean | true | Globo con los valores de la categoría tocada. |
formatValue? | (value: number) => string | compacto — 1.2k, 3.4M | — |
animate? | boolean | true | — |
duration? | number | 500 | ms de la animación de las marcas. |
emptyText? | string | — | Texto cuando no queda nada que dibujar. |
accessibilityLabel | string | — | Obligatorio: un chart no tiene texto que lo describa. |
style? | StyleProp<ViewStyle> | — | — |
type | 'bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar' | — | — |
orientation? | 'vertical' | 'horizontal' | vertical | 'horizontal' pone las categorías en el eje vertical. |
stacked? | boolean | false | Apila las series en vez de ponerlas lado a lado. |
barRadius? | number | 4 | Radio del extremo libre de cada barra. |
domain? | [number, number] | "sticky" | — | Fija el eje de valores. Por defecto se recalcula con cada dato, que es lo correcto para un chart estático y lo peor para uno en vivo: llega un valor más alto, la escala entera se reacomoda y la línea salta aunque el dato no se haya movido. - `[min, max]` — exacto, sin redondear a números lindos. - `'sticky'` — arranca del dato y crece cuando algo no entra, pero no se vuelve a achicar. Fija el alcance de los ejes. El radar mide desde el centro, así que el mínimo es siempre 0 y de `[min, max]` sólo se usa `max`. |
grid? | boolean | true | Líneas de referencia del eje de valores. |
ticks? | number | 4 | Ticks buscados en el eje de valores; el redondeo puede dar uno más o uno menos. |
xAxis? | boolean | true | Etiquetas del eje horizontal. |
yAxis? | boolean | true | Etiquetas del eje vertical. |
decimate? | number | false | el ancho medido — un punto cada dos píxeles | Cuántos puntos dibujar como mucho, eligiéndolos con LTTB. Por defecto sale del ancho medido: más de un punto cada dos píxeles no se ve y sí se paga. `false` los dibuja todos. Igual que en 'line'. |
smooth? | boolean | false | Catmull-Rom en vez de tramos rectos. |
thickness? | number | 2 | — |
dots? | boolean | true en 'line', false en 'area' | Punto en cada dato. |
fillOpacity? | number | 0.22 | Opacidad del relleno bajo la línea. |
innerRadius? | number | 0 en 'pie', 0.6 en 'donut' | Agujero como fracción del radio, 0–0.9. |
sliceLabels? | 'none' | 'percent' | 'value' | percent | Qué escribir sobre cada porción. |
levels? | number | 4 | Anillos de la telaraña. |
fill? | boolean | true | Rellena el polígono además de dibujar su contorno. |