Visualización de datos con gráficos: patrones de tablero
Un excelente panel de control no son solo números en una tabla: son patrones visuales que revelan tendencias, anomalías y oportunidades de un vistazo. Recharts es la biblioteca de gráficos más utilizada en el ecosistema React: construida sobre los fundamentos de D3.js, completamente escrita en TypeScript, componible y responsiva desde el primer momento. Maneja la complejidad de SVG para que puedas concentrarte en la historia de los datos.
Esta guía cubre los patrones de Recharts para paneles de producción (tendencias de ingresos, embudos de conversión, métricas de series temporales, distribución geográfica y transmisión de datos en tiempo real) con soporte para modo oscuro, paletas de colores accesibles y diseños responsivos.
Conclusiones clave
- Siempre ajuste los gráficos en
ResponsiveContainerconwidth="100%"y unheightfijo; nunca codifique anchos de píxeles- Utilice componentes
CustomTooltipen lugar de los predeterminados: control total sobre el formato y el estilo- Los parámetros de devolución de llamada de Recharts
formatterNUNCA deben escribirse explícitamente; useNumber(value)para convertir- El modo oscuro requiere valores de color dinámicos: lectura de variables CSS, no colores hexadecimales codificados
- Para datos de series de tiempo, use un
tickFormatteren el eje X para formatear las marcas de tiempo de manera consistente- Accesibilidad: agregue
aria-labelal contenedor del gráfico y usetabIndexen los elementos de la leyenda- Rendimiento: memorizar datos del gráfico con
useMemo— Los gráficos se vuelven a renderizar en cada renderizado principal- Para datos en tiempo real, actualice la referencia de la matriz de datos: Recharts detecta cambios por referencia
Configuración
pnpm add recharts
pnpm add -D @types/recharts # Usually not needed — recharts ships its own types
Recharts 2.x incluye tipos TypeScript. No se requiere ningún paquete @types separado.
Gráfico de área de ingresos
// src/components/charts/revenue-area-chart.tsx
'use client';
import { useMemo } from 'react';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer,
} from 'recharts';
import { format, parseISO } from 'date-fns';
interface RevenueDataPoint {
date: string; // ISO date string
revenue: number;
refunds: number;
net: number;
}
interface CustomTooltipProps {
active?: boolean;
payload?: Array<{ name: string; value: number; color: string }>;
label?: string;
}
function CustomTooltip({ active, payload, label }: CustomTooltipProps) {
if (!active || !payload || !label) return null;
return (
<div className="rounded-lg border bg-background p-3 shadow-md">
<p className="mb-2 text-sm font-medium text-muted-foreground">
{format(parseISO(label), 'MMM d, yyyy')}
</p>
{payload.map((entry) => (
<div key={entry.name} className="flex items-center gap-2 text-sm">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(entry.value)}
</span>
</div>
))}
</div>
);
}
interface RevenueAreaChartProps {
data: RevenueDataPoint[];
loading?: boolean;
}
export function RevenueAreaChart({ data, loading }: RevenueAreaChartProps) {
// CRITICAL: memoize chart data — Recharts re-renders on every reference change
const chartData = useMemo(() => data, [data]);
if (loading) {
return <div className="h-80 animate-pulse rounded-lg bg-muted" />;
}
return (
<div role="img" aria-label="Monthly revenue area chart">
<ResponsiveContainer width="100%" height={320}>
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.3} />
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorNet" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
vertical={false}
/>
<XAxis
dataKey="date"
tickFormatter={(value: string) => format(parseISO(value), 'MMM d')}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(value) => `$${(Number(value) / 1000).toFixed(0)}k`}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
axisLine={false}
tickLine={false}
width={60}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ fontSize: '14px', paddingTop: '16px' }}
formatter={(value: string) =>
<span className="text-foreground">{value}</span>
}
/>
<Area
type="monotone"
dataKey="revenue"
name="Gross Revenue"
stroke="#f59e0b"
strokeWidth={2}
fill="url(#colorRevenue)"
/>
<Area
type="monotone"
dataKey="net"
name="Net Revenue"
stroke="#22c55e"
strokeWidth={2}
fill="url(#colorNet)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
Gráfico de barras: comparación mensual
// src/components/charts/monthly-bar-chart.tsx
'use client';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer, Cell,
} from 'recharts';
interface MonthlyData {
month: string;
current: number;
previous: number;
target: number;
}
function CustomBarTooltip({ active, payload, label }: any) {
if (!active || !payload) return null;
return (
<div className="rounded-lg border bg-background p-3 shadow-md">
<p className="mb-2 font-medium">{label}</p>
{payload.map((entry: { name: string; value: number; fill: string }) => (
<div key={entry.name} className="flex items-center gap-2 text-sm">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(entry.value)}
</span>
</div>
))}
</div>
);
}
export function MonthlyBarChart({ data }: { data: MonthlyData[] }) {
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="month"
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(value) => `$${(Number(value) / 1000).toFixed(0)}k`}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<CustomBarTooltip />} />
<Legend />
<Bar dataKey="previous" name="Previous Year" fill="hsl(var(--muted))" radius={[4, 4, 0, 0]} />
<Bar dataKey="current" name="Current Year" fill="#f59e0b" radius={[4, 4, 0, 0]} />
<Bar dataKey="target" name="Target" fill="#22c55e" opacity={0.4} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
Gráfico circular/rosquilla
// src/components/charts/platform-pie-chart.tsx
'use client';
import { useState } from 'react';
import {
PieChart, Pie, Cell, Sector,
ResponsiveContainer, Legend, Tooltip,
} from 'recharts';
interface PieDataPoint {
name: string;
value: number;
color: string;
}
const PLATFORM_COLORS: Record<string, string> = {
'Odoo': '#714b67',
'Shopify': '#96bf48',
'GoHighLevel': '#f59e0b',
'Power BI': '#f2c811',
'OpenClaw': '#0ea5e9',
'Other': '#94a3b8',
};
function renderActiveShape(props: any) {
const {
cx, cy, innerRadius, outerRadius, startAngle, endAngle,
fill, payload, percent, value,
} = props;
return (
<g>
<text x={cx} y={cy - 12} textAnchor="middle" className="text-base font-bold fill-foreground">
{payload.name}
</text>
<text x={cx} y={cy + 12} textAnchor="middle" className="text-sm fill-muted-foreground">
{`${(percent * 100).toFixed(1)}%`}
</text>
<text x={cx} y={cy + 32} textAnchor="middle" className="text-sm fill-muted-foreground">
{`$${(Number(value) / 1000).toFixed(0)}k`}
</text>
<Sector
cx={cx} cy={cy}
innerRadius={innerRadius}
outerRadius={outerRadius + 8}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
<Sector
cx={cx} cy={cy}
innerRadius={outerRadius + 10}
outerRadius={outerRadius + 12}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
</g>
);
}
export function PlatformPieChart({ data }: { data: PieDataPoint[] }) {
const [activeIndex, setActiveIndex] = useState(0);
const chartData = data.map((item) => ({
...item,
color: PLATFORM_COLORS[item.name] ?? '#94a3b8',
}));
return (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
activeIndex={activeIndex}
activeShape={renderActiveShape}
data={chartData}
cx="50%"
cy="50%"
innerRadius={70}
outerRadius={100}
dataKey="value"
onMouseEnter={(_, index) => setActiveIndex(index)}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Legend
formatter={(value: string) =>
<span className="text-sm text-foreground">{value}</span>
}
/>
</PieChart>
</ResponsiveContainer>
);
}
Gráfico de líneas en tiempo real
// src/components/charts/realtime-chart.tsx
'use client';
import { useState, useEffect, useRef } from 'react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, ReferenceLine,
} from 'recharts';
interface MetricPoint {
time: string;
requests: number;
errors: number;
p95: number;
}
const MAX_DATA_POINTS = 60; // 60 seconds of data
export function RealtimeChart({ endpoint }: { endpoint: string }) {
const [data, setData] = useState<MetricPoint[]>([]);
const intervalRef = useRef<ReturnType<typeof setInterval>>();
useEffect(() => {
const fetchMetrics = async () => {
try {
const response = await fetch(endpoint);
const point: MetricPoint = await response.json();
setData((prev) => {
const updated = [
...prev,
{ ...point, time: new Date().toLocaleTimeString() },
];
// Keep only the last MAX_DATA_POINTS — sliding window
return updated.slice(-MAX_DATA_POINTS);
});
} catch {
// Metrics fetch failure is non-critical — log but do not crash
}
};
fetchMetrics();
intervalRef.current = setInterval(fetchMetrics, 1000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [endpoint]);
return (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="time"
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
axisLine={false}
/>
<Tooltip />
{/* Reference line for SLO threshold */}
<ReferenceLine
y={200}
label={{ value: 'SLO 200ms', position: 'right', fontSize: 10 }}
stroke="#ef4444"
strokeDasharray="4 4"
/>
<Line
type="monotone"
dataKey="p95"
name="P95 Latency (ms)"
stroke="#f59e0b"
strokeWidth={2}
dot={false}
isAnimationActive={false} // Disable animation for real-time data
/>
<Line
type="monotone"
dataKey="errors"
name="Errors/s"
stroke="#ef4444"
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
);
}
Diseño del panel con tarjetas de estadísticas
// src/components/dashboard/metrics-dashboard.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api';
import { RevenueAreaChart } from './revenue-area-chart';
import { MonthlyBarChart } from './monthly-bar-chart';
import { PlatformPieChart } from './platform-pie-chart';
export function MetricsDashboard() {
const { data: metrics, isLoading } = useQuery({
queryKey: ['dashboard-metrics'],
queryFn: () => apiFetch<DashboardMetrics>('/analytics/dashboard'),
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
});
return (
<div className="space-y-6">
{/* KPI Cards */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{[
{ label: 'MRR', value: metrics?.mrr, format: 'currency', trend: '+12%' },
{ label: 'ARR', value: metrics?.arr, format: 'currency', trend: '+8%' },
{ label: 'Customers', value: metrics?.customers, format: 'number', trend: '+23' },
{ label: 'Churn', value: metrics?.churnRate, format: 'percent', trend: '-0.3%' },
].map(({ label, value, format, trend }) => (
<div key={label} className="rounded-lg border bg-card p-4">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-bold">
{isLoading ? (
<span className="inline-block h-8 w-24 animate-pulse rounded bg-muted" />
) : (
formatMetric(value, format)
)}
</p>
<p className="mt-1 text-sm text-green-500">{trend} this month</p>
</div>
))}
</div>
{/* Charts */}
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 rounded-lg border bg-card p-6">
<h3 className="mb-4 text-lg font-semibold">Revenue Trend</h3>
<RevenueAreaChart data={metrics?.revenueByDay ?? []} loading={isLoading} />
</div>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 text-lg font-semibold">Revenue by Platform</h3>
<PlatformPieChart data={metrics?.revenueByPlatform ?? []} />
</div>
</div>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 text-lg font-semibold">Monthly Comparison</h3>
<MonthlyBarChart data={metrics?.monthlyComparison ?? []} />
</div>
</div>
);
}
function formatMetric(value: number | undefined, format: string): string {
if (value === undefined) return '—';
const fmt = new Intl.NumberFormat('en-US');
if (format === 'currency') return `$${fmt.format(value)}`;
if (format === 'percent') return `${value.toFixed(1)}%`;
return fmt.format(value);
}
Preguntas frecuentes
¿Por qué las devoluciones de llamada del formateador de información sobre herramientas de Recharts nunca deben escribir explícitamente sus parámetros?
Los tipos TypeScript de Recharts para la propiedad formatter están sobrecargados de manera que causan conflictos de tipos cuando se anotan los parámetros. El patrón correcto es usar Number(value) para convertir el valor de forma segura sin anotar el tipo de value. Si lo anota como (value: number), TypeScript puede generar un error debido al tipo de unión en la firma interna de Recharts. Deje que TypeScript infiera el tipo y convierta con Number(value).
¿Cómo hago para que los gráficos de Recharts funcionen en modo oscuro?
Utilice propiedades personalizadas (variables) de CSS para todos los colores en lugar de valores hexadecimales codificados. Haga referencia a ellos con hsl(var(--border)) para líneas de cuadrícula, hsl(var(--muted-foreground)) para texto de eje y hsl(var(--background)) para fondos de información sobre herramientas. Estas variables se definen en su tema Tailwind y cambian automáticamente los valores entre el modo claro y oscuro a través de la clase dark en <html>. Nunca codifique #374151; no se actualizará con el tema.
¿Cómo manejo los estados vacíos o de carga de los gráficos?
Para cargar: reemplace el gráfico con un esqueleto (animate-pulse bg-muted rounded-lg h-80). Para datos vacíos: renderice un mensaje de estado vacío centrado dentro de un ResponsiveContainer. Para datos parciales: Recharts maneja con elegancia datos dispersos: simplemente pase lo que tiene y conectará los puntos (o mostrará espacios para valores nulos con connectNulls={false}).
¿Debo usar Recharts, Chart.js o D3?
Recharts para paneles nativos de React: es declarativo, componible y se integra naturalmente con el modelo de renderizado de React. Chart.js para casos de uso más simples en los que desea un renderizador basado en Canvas (mejor rendimiento para más de 10 000 puntos de datos). D3 cuando necesite tipos de gráficos personalizados y no estándar que ninguna biblioteca cubra; espere mucho más código. Evite mezclar varias bibliotecas de gráficos en una sola aplicación.
¿Cómo exporto gráficos como imágenes para informes?
Utilice la biblioteca html2canvas para capturar el contenedor del gráfico como una imagen: const canvas = await html2canvas(chartRef.current); const url = canvas.toDataURL('image/png');. Alternativamente, use la salida SVG incorporada de Recharts; dado que Recharts procesa SVG, puede serializar el elemento SVG directamente para exportar vectores. Para informes en PDF, renderice los gráficos en el lado del servidor utilizando Puppeteer o un navegador sin cabeza.
Próximos pasos
La visualización de datos transforma métricas sin procesar en inteligencia empresarial. Los patrones de esta guía (contenedores responsivos, información sobre herramientas personalizada, transmisión en tiempo real y colores temáticos) le brindan la base para interfaces de usuario de panel que se comunican claramente en cualquier dispositivo y en cualquier combinación de colores.
ECOSIRE crea paneles de análisis con Recharts en 8 módulos de paneles para métricas financieras, operativas y de rendimiento, con datos en vivo de las API de NestJS y PostgreSQL con tecnología Drizzle. Para implementaciones de inteligencia empresarial y Power BI, explore nuestros servicios Power BI o vea todos nuestros servicios.
Escrito por
ECOSIRE Research and Development Team
Construyendo productos digitales de nivel empresarial en ECOSIRE. Compartiendo perspectivas sobre integraciones Odoo, automatización de eCommerce y soluciones empresariales impulsadas por IA.
Artículos relacionados
Building Financial Dashboards with Power BI
Step-by-step guide to building financial dashboards in Power BI covering data connections to accounting systems, DAX measures for KPIs, P&L visualisations, and best practices.
GoHighLevel Reporting and Analytics: Measuring What Matters
Master GoHighLevel reporting and analytics. Learn to build custom dashboards, track ROI across channels, measure funnel conversion, and make data-driven marketing decisions.
Next.js 16 App Router: Production Patterns and Pitfalls
Production-ready Next.js 16 App Router patterns: server components, caching strategies, metadata API, error boundaries, and performance pitfalls to avoid.