Widgets de Dashboard con Gráficos y Visualización de Datos
Widgets interactivos de dashboard con varios tipos de gráficos, actualizaciones de datos en tiempo real y diseño responsivo para aplicaciones web modernas
Diseño Responsivo
Sí
Soporte para Modo Oscuro
No
líneas
193
Compatibilidad del Navegador
No
Vista Previa en Vivo
Interactúa con el componente sin salir de la página.
Widgets de Dashboard con Gráficos y Visualización de Datos
Una colección completa de widgets interactivos de dashboard con varios tipos de gráficos, actualizaciones de datos en tiempo real y patrones de diseño modernos para visualización de datos.
Características
- Múltiples Tipos de Gráficos: Gráficos de línea, barras, circular, dona, área y medidor
- Actualizaciones en Tiempo Real: Transmisión de datos en vivo y actualización automática
- Elementos Interactivos: Efectos hover, tooltips y leyendas clicables
- Diseño Responsivo: Se adapta a diferentes tamaños de pantalla y orientaciones
- Temas Personalizables: Esquemas de colores claro, oscuro y personalizados
- Exportación de Datos: Exportar gráficos como imágenes o datos como CSV/JSON
- Efectos de Animación: Transiciones suaves y animaciones de carga
- Accesibilidad: Soporte para lectores de pantalla y navegación por teclado
<div class="dashboard-container">
<!-- Encabezado del Dashboard -->
<div class="dashboard-header">
<h1 class="dashboard-title">Dashboard de Analíticas</h1>
<div class="dashboard-controls">
<button class="refresh-btn" data-action="refresh">
<svg class="icon" viewBox="0 0 24 24">
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-8 3.58-8 8s3.58 8 8 8c3.74 0 6.95-2.57 7.9-6h-2.02c-.85 2.37-3.13 4-5.88 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
</svg>
Actualizar
</button>
<select class="time-range-select">
<option value="1h">Última Hora</option>
<option value="24h" selected>Últimas 24 Horas</option>
<option value="7d">Últimos 7 Días</option>
<option value="30d">Últimos 30 Días</option>
</select>
</div>
</div>
<!-- Tarjetas KPI -->
<div class="kpi-grid">
<div class="kpi-card" data-kpi="revenue">
<div class="kpi-icon">
<svg viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1.41 16.09V20h-2.67v-1.93c-1.71-.36-3.16-1.46-3.27-3.4h1.96c.1 1.05.82 1.87 2.65 1.87 1.96 0 2.4-.98 2.4-1.59 0-.83-.44-1.61-2.67-2.14-2.48-.6-4.18-1.62-4.18-3.67 0-1.72 1.39-2.84 3.11-3.21V4h2.67v1.95c1.86.45 2.79 1.86 2.85 3.39H14.3c-.05-1.11-.64-1.87-2.22-1.87-1.5 0-2.4.68-2.4 1.64 0 .84.65 1.39 2.67 1.91s4.18 1.39 4.18 3.91c-.01 1.83-1.38 2.83-3.12 3.16z"/>
</svg>
</div>
<div class="kpi-content">
<div class="kpi-value">$24,567</div>
<div class="kpi-label">Ingresos Totales</div>
<div class="kpi-change positive">+12.5%</div>
</div>
</div>
<div class="kpi-card" data-kpi="users">
<div class="kpi-icon">
<svg viewBox="0 0 24 24">
<path d="M16 4c0-1.11.89-2 2-2s2 .89 2 2-.89 2-2 2-2-.89-2-2zm4 18v-6h2.5l-2.54-7.63A1.5 1.5 0 0 0 18.54 8H17c-.8 0-1.54.37-2.01.99l-2.98 3.67a.5.5 0 0 0 .39.84h2.6v6h5zm-7.5-10.5c.83 0 1.5-.67 1.5-1.5s-.67-1.5-1.5-1.5S11 9.17 11 10.5s.67 1.5 1.5 1.5zM5.5 6c1.11 0 2-.89 2-2s-.89-2-2-2-2 .89-2 2 .89 2 2 2zm2.5 16v-7H9.5l.68-3.19c.08-.37.34-.68.71-.8L14 9.5v3.5h-1.5v7h-4.5z"/>
</svg>
</div>
<div class="kpi-content">
<div class="kpi-value">1,234</div>
<div class="kpi-label">Usuarios Activos</div>
<div class="kpi-change positive">+8.2%</div>
</div>
</div>
<div class="kpi-card" data-kpi="orders">
<div class="kpi-icon">
<svg viewBox="0 0 24 24">
<path d="M7 4V2C7 1.45 7.45 1 8 1h8c.55 0 1 .45 1 1v2h4c.55 0 1 .45 1 1s-.45 1-1 1h-1v11c0 1.1-.9 2-2 2H6c-1.1 0-2-.9-2-2V6H3c-.55 0-1-.45-1-1s.45-1 1-1h4zM9 3v1h6V3H9zm7 15V8H8v10h8z"/>
</svg>
</div>
<div class="kpi-content">
<div class="kpi-value">456</div>
<div class="kpi-label">Pedidos</div>
<div class="kpi-change negative">-2.1%</div>
</div>
</div>
<div class="kpi-card" data-kpi="conversion">
<div class="kpi-icon">
<svg viewBox="0 0 24 24">
<path d="M16 6l2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6z"/>
</svg>
</div>
<div class="kpi-content">
<div class="kpi-value">3.2%</div>
<div class="kpi-label">Tasa de Conversión</div>
<div class="kpi-change positive">+0.5%</div>
</div>
</div>
</div>
<!-- Cuadrícula de Gráficos -->
<div class="charts-grid">
<!-- Widget de Gráfico de Línea -->
<div class="chart-widget" data-chart="line">
<div class="widget-header">
<h3 class="widget-title">Tendencia de Ingresos</h3>
<div class="widget-controls">
<button class="widget-menu-btn">
<svg viewBox="0 0 24 24">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
</button>
</div>
</div>
<div class="chart-container">
<canvas id="lineChart" width="400" height="200"></canvas>
</div>
</div>
<!-- Widget de Gráfico de Barras -->
<div class="chart-widget" data-chart="bar">
<div class="widget-header">
<h3 class="widget-title">Ventas por Categoría</h3>
<div class="widget-controls">
<button class="widget-menu-btn">
<svg viewBox="0 0 24 24">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
</button>
</div>
</div>
<div class="chart-container">
<canvas id="barChart" width="400" height="200"></canvas>
</div>
</div>
<!-- Widget de Gráfico Circular -->
<div class="chart-widget" data-chart="pie">
<div class="widget-header">
<h3 class="widget-title">Fuentes de Tráfico</h3>
<div class="widget-controls">
<button class="widget-menu-btn">
<svg viewBox="0 0 24 24">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
</button>
</div>
</div>
<div class="chart-container">
<canvas id="pieChart" width="400" height="200"></canvas>
</div>
</div>
<!-- Widget de Gráfico de Medidor -->
<div class="chart-widget" data-chart="gauge">
<div class="widget-header">
<h3 class="widget-title">Puntuación de Rendimiento</h3>
<div class="widget-controls">
<button class="widget-menu-btn">
<svg viewBox="0 0 24 24">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
</button>
</div>
</div>
<div class="chart-container">
<canvas id="gaugeChart" width="400" height="200"></canvas>
</div>
</div>
<!-- Widget de Tabla de Datos -->
<div class="chart-widget table-widget" data-widget="table">
<div class="widget-header">
<h3 class="widget-title">Transacciones Recientes</h3>
<div class="widget-controls">
<button class="export-btn" data-export="csv">
<svg viewBox="0 0 24 24">
<path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
</svg>
Exportar
</button>
</div>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Cliente</th>
<th>Cantidad</th>
<th>Estado</th>
<th>Fecha</th>
</tr>
</thead>
<tbody>
<tr>
<td>#1234</td>
<td>Juan Pérez</td>
<td>$299.99</td>
<td><span class="status success">Completado</span></td>
<td>2024-01-15</td>
</tr>
<tr>
<td>#1235</td>
<td>María García</td>
<td>$149.50</td>
<td><span class="status pending">Pendiente</span></td>
<td>2024-01-15</td>
</tr>
<tr>
<td>#1236</td>
<td>Carlos López</td>
<td>$89.99</td>
<td><span class="status failed">Fallido</span></td>
<td>2024-01-14</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Widget de Actividad en Tiempo Real -->
<div class="chart-widget activity-widget" data-widget="activity">
<div class="widget-header">
<h3 class="widget-title">Actividad en Vivo</h3>
<div class="widget-controls">
<div class="live-indicator">
<span class="live-dot"></span>
En Vivo
</div>
</div>
</div>
<div class="activity-container">
<div class="activity-item">
<div class="activity-icon">
<svg viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
</div>
<div class="activity-content">
<div class="activity-text">Nuevo pedido recibido de Juan Pérez</div>
<div class="activity-time">hace 2 minutos</div>
</div>
</div>
<div class="activity-item">
<div class="activity-icon">
<svg viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</div>
<div class="activity-content">
<div class="activity-text">Reseña de producto enviada</div>
<div class="activity-time">hace 5 minutos</div>
</div>
</div>
<div class="activity-item">
<div class="activity-icon">
<svg viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm3.5 6L12 10.5 8.5 8 12 5.5 15.5 8zM12 13.5l3.5 2.5L12 18.5 8.5 16l3.5-2.5z"/>
</svg>
</div>
<div class="activity-content">
<div class="activity-text">Usuario registrado</div>
<div class="activity-time">hace 8 minutos</div>
</div>
</div>
</div>
</div>
</div>
</div>
/* Contenedor del Dashboard */
.dashboard-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
}
/* Encabezado del Dashboard */
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding: 1.5rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.dashboard-title {
font-size: 2rem;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.dashboard-controls {
display: flex;
gap: 1rem;
align-items: center;
}
.refresh-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.refresh-btn:hover {
background: #2563eb;
transform: translateY(-1px);
}
.refresh-btn .icon {
width: 16px;
height: 16px;
fill: currentColor;
}
.time-range-select {
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
background: white;
font-size: 0.875rem;
cursor: pointer;
}
/* Cuadrícula KPI */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.kpi-card {
background: white;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 1rem;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.kpi-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
}
.kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1);
}
.kpi-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.kpi-icon svg {
width: 24px;
height: 24px;
fill: white;
}
.kpi-content {
flex: 1;
}
.kpi-value {
font-size: 1.875rem;
font-weight: 700;
color: #1f2937;
line-height: 1;
}
.kpi-label {
font-size: 0.875rem;
color: #6b7280;
margin: 0.25rem 0;
}
.kpi-change {
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: 4px;
display: inline-block;
}
.kpi-change.positive {
background: #dcfce7;
color: #166534;
}
.kpi-change.negative {
background: #fef2f2;
color: #dc2626;
}
/* Cuadrícula de Gráficos */
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 1.5rem;
}
.chart-widget {
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.chart-widget:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1);
}
.widget-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #f3f4f6;
}
.widget-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.widget-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.widget-menu-btn {
width: 32px;
height: 32px;
border: none;
background: #f9fafb;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.widget-menu-btn:hover {
background: #f3f4f6;
}
.widget-menu-btn svg {
width: 16px;
height: 16px;
fill: #6b7280;
}
.chart-container {
padding: 1.5rem;
position: relative;
}
.chart-container canvas {
max-width: 100%;
height: auto;
}
/* Widget de Tabla */
.table-widget {
grid-column: 1 / -1;
}
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #f3f4f6;
}
.data-table th {
background: #f9fafb;
font-weight: 600;
color: #374151;
font-size: 0.875rem;
}
.data-table td {
color: #6b7280;
}
.status {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.status.success {
background: #dcfce7;
color: #166534;
}
.status.pending {
background: #fef3c7;
color: #92400e;
}
.status.failed {
background: #fef2f2;
color: #dc2626;
}
.export-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #f9fafb;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
.export-btn:hover {
background: #f3f4f6;
}
.export-btn svg {
width: 14px;
height: 14px;
fill: #6b7280;
}
/* Widget de Actividad */
.activity-widget {
grid-column: span 1;
}
.live-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #059669;
font-weight: 500;
}
.live-dot {
width: 8px;
height: 8px;
background: #059669;
border-radius: 50%;
animation: pulse 2s infinite;
}
.activity-container {
padding: 1.5rem;
}
.activity-item {
display: flex;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid #f3f4f6;
}
.activity-item:last-child {
border-bottom: none;
}
.activity-icon {
width: 40px;
height: 40px;
background: #f3f4f6;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.activity-icon svg {
width: 20px;
height: 20px;
fill: #6b7280;
}
.activity-content {
flex: 1;
}
.activity-text {
font-size: 0.875rem;
color: #374151;
margin-bottom: 0.25rem;
}
.activity-time {
font-size: 0.75rem;
color: #9ca3af;
}
/* Diseño Responsivo */
@media (max-width: 768px) {
.dashboard-container {
padding: 1rem;
}
.dashboard-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.dashboard-controls {
justify-content: center;
}
.kpi-grid {
grid-template-columns: 1fr;
}
.charts-grid {
grid-template-columns: 1fr;
}
.table-widget {
grid-column: 1;
}
}
/* Animaciones */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.chart-widget {
animation: fadeInUp 0.6s ease-out;
}
.kpi-card {
animation: fadeInUp 0.6s ease-out;
}
/* Soporte para Tema Oscuro */
@media (prefers-color-scheme: dark) {
.dashboard-container {
background: linear-gradient(135deg, #1f2937 0%, #111827 100%);
}
.dashboard-header,
.kpi-card,
.chart-widget {
background: #374151;
color: #f9fafb;
}
.dashboard-title,
.kpi-value,
.widget-title {
color: #f9fafb;
}
.kpi-label,
.data-table td {
color: #d1d5db;
}
.data-table th {
background: #4b5563;
color: #f9fafb;
}
.widget-menu-btn {
background: #4b5563;
}
.widget-menu-btn:hover {
background: #6b7280;
}
}
// Clase Principal del Dashboard
class WidgetsDashboard {
constructor(container, options = {}) {
this.container = typeof container === 'string' ? document.querySelector(container) : container;
this.options = {
autoRefresh: true,
refreshInterval: 30000, // 30 segundos
enableAnimations: true,
theme: 'auto', // 'light', 'dark', 'auto'
locale: 'es-ES',
...options
};
this.charts = new Map();
this.refreshTimer = null;
this.isVisible = true;
this.init();
}
init() {
this.setupEventListeners();
this.initializeCharts();
this.startAutoRefresh();
this.setupIntersectionObserver();
this.setupAccessibility();
// Disparar evento de inicialización
this.dispatchEvent('dashboard:initialized', { dashboard: this });
}
setupEventListeners() {
// Botón de actualización
const refreshBtn = this.container.querySelector('.refresh-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => this.refreshData());
}
// Selector de rango de tiempo
const timeRangeSelect = this.container.querySelector('.time-range-select');
if (timeRangeSelect) {
timeRangeSelect.addEventListener('change', (e) => {
this.updateTimeRange(e.target.value);
});
}
// Botones de exportación
const exportBtns = this.container.querySelectorAll('.export-btn');
exportBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
const format = e.currentTarget.dataset.export || 'csv';
this.exportData(format);
});
});
// Menús de widgets
const menuBtns = this.container.querySelectorAll('.widget-menu-btn');
menuBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
this.toggleWidgetMenu(e.currentTarget);
});
});
// Eventos de visibilidad
document.addEventListener('visibilitychange', () => {
this.isVisible = !document.hidden;
if (this.isVisible) {
this.refreshData();
}
});
// Eventos de redimensionamiento
window.addEventListener('resize', this.debounce(() => {
this.resizeCharts();
}, 250));
}
initializeCharts() {
// Inicializar gráfico de línea
this.initLineChart();
// Inicializar gráfico de barras
this.initBarChart();
// Inicializar gráfico circular
this.initPieChart();
// Inicializar gráfico de medidor
this.initGaugeChart();
}
initLineChart() {
const canvas = this.container.querySelector('#lineChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun'],
datasets: [{
label: 'Ingresos',
data: [12000, 19000, 15000, 25000, 22000, 30000],
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.1)'
}
},
x: {
grid: {
display: false
}
}
},
animation: {
duration: this.options.enableAnimations ? 1000 : 0
}
}
});
this.charts.set('line', chart);
}
initBarChart() {
const canvas = this.container.querySelector('#barChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const chart = new Chart(ctx, {
type: 'bar',
data: {
labels: ['Electrónicos', 'Ropa', 'Hogar', 'Deportes', 'Libros'],
datasets: [{
label: 'Ventas',
data: [65, 59, 80, 81, 56],
backgroundColor: [
'#3b82f6',
'#8b5cf6',
'#10b981',
'#f59e0b',
'#ef4444'
],
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.1)'
}
},
x: {
grid: {
display: false
}
}
},
animation: {
duration: this.options.enableAnimations ? 1000 : 0
}
}
});
this.charts.set('bar', chart);
}
initPieChart() {
const canvas = this.container.querySelector('#pieChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const chart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Orgánico', 'Directo', 'Social', 'Email', 'Referidos'],
datasets: [{
data: [35, 25, 20, 15, 5],
backgroundColor: [
'#3b82f6',
'#8b5cf6',
'#10b981',
'#f59e0b',
'#ef4444'
],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true
}
}
},
animation: {
duration: this.options.enableAnimations ? 1000 : 0
}
}
});
this.charts.set('pie', chart);
}
initGaugeChart() {
const canvas = this.container.querySelector('#gaugeChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const chart = new Chart(ctx, {
type: 'doughnut',
data: {
datasets: [{
data: [85, 15],
backgroundColor: ['#10b981', '#f3f4f6'],
borderWidth: 0,
circumference: 180,
rotation: 270
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
},
animation: {
duration: this.options.enableAnimations ? 1500 : 0
}
},
plugins: [{
afterDraw: (chart) => {
const { ctx, chartArea } = chart;
const centerX = (chartArea.left + chartArea.right) / 2;
const centerY = (chartArea.top + chartArea.bottom) / 2;
ctx.save();
ctx.font = 'bold 24px Inter';
ctx.fillStyle = '#1f2937';
ctx.textAlign = 'center';
ctx.fillText('85%', centerX, centerY + 10);
ctx.font = '14px Inter';
ctx.fillStyle = '#6b7280';
ctx.fillText('Puntuación', centerX, centerY + 30);
ctx.restore();
}
}]
});
this.charts.set('gauge', chart);
}
refreshData() {
const refreshBtn = this.container.querySelector('.refresh-btn');
if (refreshBtn) {
refreshBtn.classList.add('loading');
refreshBtn.disabled = true;
}
// Simular carga de datos
setTimeout(() => {
this.updateKPIs();
this.updateCharts();
this.updateActivityFeed();
if (refreshBtn) {
refreshBtn.classList.remove('loading');
refreshBtn.disabled = false;
}
this.dispatchEvent('dashboard:refreshed', { timestamp: Date.now() });
}, 1000);
}
updateKPIs() {
const kpiCards = this.container.querySelectorAll('.kpi-card');
kpiCards.forEach(card => {
const valueEl = card.querySelector('.kpi-value');
const changeEl = card.querySelector('.kpi-change');
if (valueEl && changeEl) {
// Simular nuevos valores
const currentValue = parseFloat(valueEl.textContent.replace(/[^0-9.]/g, ''));
const change = (Math.random() - 0.5) * 0.2; // ±10%
const newValue = currentValue * (1 + change);
// Animar el cambio de valor
this.animateValue(valueEl, currentValue, newValue, 1000);
// Actualizar indicador de cambio
const changePercent = (change * 100).toFixed(1);
changeEl.textContent = `${change >= 0 ? '+' : ''}${changePercent}%`;
changeEl.className = `kpi-change ${change >= 0 ? 'positive' : 'negative'}`;
}
});
}
updateCharts() {
this.charts.forEach((chart, type) => {
if (type === 'line') {
// Actualizar datos del gráfico de línea
const newData = chart.data.datasets[0].data.map(value =>
value + (Math.random() - 0.5) * 5000
);
chart.data.datasets[0].data = newData;
} else if (type === 'bar') {
// Actualizar datos del gráfico de barras
const newData = chart.data.datasets[0].data.map(value =>
Math.max(0, value + (Math.random() - 0.5) * 20)
);
chart.data.datasets[0].data = newData;
}
chart.update('active');
});
}
updateActivityFeed() {
const activityContainer = this.container.querySelector('.activity-container');
if (!activityContainer) return;
const activities = [
'Nuevo pedido recibido de Ana Martín',
'Producto agregado al inventario',
'Reseña de cliente enviada',
'Usuario premium registrado',
'Pago procesado exitosamente'
];
const randomActivity = activities[Math.floor(Math.random() * activities.length)];
const newItem = this.createActivityItem(randomActivity, 'hace 1 minuto');
activityContainer.insertBefore(newItem, activityContainer.firstChild);
// Mantener solo los últimos 5 elementos
const items = activityContainer.querySelectorAll('.activity-item');
if (items.length > 5) {
items[items.length - 1].remove();
}
}
createActivityItem(text, time) {
const item = document.createElement('div');
item.className = 'activity-item';
item.innerHTML = `
<div class="activity-icon">
<svg viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
</div>
<div class="activity-content">
<div class="activity-text">${text}</div>
<div class="activity-time">${time}</div>
</div>
`;
return item;
}
updateTimeRange(range) {
// Actualizar datos basados en el rango de tiempo seleccionado
this.dispatchEvent('dashboard:timeRangeChanged', { range });
this.refreshData();
}
exportData(format = 'csv') {
const data = this.collectDashboardData();
if (format === 'csv') {
this.exportToCSV(data);
} else if (format === 'json') {
this.exportToJSON(data);
}
this.dispatchEvent('dashboard:dataExported', { format, data });
}
collectDashboardData() {
const kpiData = [];
const kpiCards = this.container.querySelectorAll('.kpi-card');
kpiCards.forEach(card => {
const label = card.querySelector('.kpi-label')?.textContent;
const value = card.querySelector('.kpi-value')?.textContent;
const change = card.querySelector('.kpi-change')?.textContent;
if (label && value) {
kpiData.push({ label, value, change });
}
});
return {
kpis: kpiData,
timestamp: new Date().toISOString()
};
}
exportToCSV(data) {
const csvContent = [
['Métrica', 'Valor', 'Cambio'],
...data.kpis.map(kpi => [kpi.label, kpi.value, kpi.change])
].map(row => row.join(',')).join('\n');
this.downloadFile(csvContent, 'dashboard-data.csv', 'text/csv');
}
exportToJSON(data) {
const jsonContent = JSON.stringify(data, null, 2);
this.downloadFile(jsonContent, 'dashboard-data.json', 'application/json');
}
downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
toggleWidgetMenu(button) {
// Implementar menú contextual del widget
console.log('Menú del widget activado', button);
}
resizeCharts() {
this.charts.forEach(chart => {
chart.resize();
});
}
startAutoRefresh() {
if (!this.options.autoRefresh) return;
this.refreshTimer = setInterval(() => {
if (this.isVisible) {
this.refreshData();
}
}, this.options.refreshInterval);
}
stopAutoRefresh() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
}
setupIntersectionObserver() {
if (!this.options.enableAnimations) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in');
}
});
}, {
threshold: 0.1,
rootMargin: '50px'
});
const widgets = this.container.querySelectorAll('.chart-widget, .kpi-card');
widgets.forEach(widget => observer.observe(widget));
}
setupAccessibility() {
// Configurar atributos ARIA
const widgets = this.container.querySelectorAll('.chart-widget');
widgets.forEach((widget, index) => {
widget.setAttribute('role', 'region');
widget.setAttribute('aria-label', `Widget ${index + 1}`);
});
// Configurar navegación por teclado
const focusableElements = this.container.querySelectorAll(
'button, select, [tabindex]:not([tabindex="-1"])'
);
focusableElements.forEach(element => {
element.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
element.click();
}
});
});
}
// Métodos de utilidad
animateValue(element, start, end, duration) {
const startTime = performance.now();
const isNumber = !isNaN(start) && !isNaN(end);
const animate = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
if (isNumber) {
const current = start + (end - start) * this.easeOutCubic(progress);
const prefix = element.textContent.match(/^[^0-9]*/)[0];
element.textContent = prefix + Math.round(current).toLocaleString(this.options.locale);
}
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
dispatchEvent(eventName, detail = {}) {
const event = new CustomEvent(eventName, {
detail,
bubbles: true,
cancelable: true
});
this.container.dispatchEvent(event);
}
// API Pública
updateWidget(widgetId, data) {
const widget = this.container.querySelector(`[data-widget="${widgetId}"]`);
if (widget) {
// Implementar actualización específica del widget
this.dispatchEvent('widget:updated', { widgetId, data });
}
}
addWidget(config) {
// Implementar adición dinámica de widgets
this.dispatchEvent('widget:added', { config });
}
removeWidget(widgetId) {
const widget = this.container.querySelector(`[data-widget="${widgetId}"]`);
if (widget) {
widget.remove();
this.dispatchEvent('widget:removed', { widgetId });
}
}
getWidgetData(widgetId) {
// Retornar datos específicos del widget
return this.collectDashboardData();
}
setTheme(theme) {
this.options.theme = theme;
document.documentElement.setAttribute('data-theme', theme);
this.dispatchEvent('dashboard:themeChanged', { theme });
}
destroy() {
this.stopAutoRefresh();
// Destruir gráficos
this.charts.forEach(chart => chart.destroy());
this.charts.clear();
// Remover event listeners
// (En una implementación real, se almacenarían las referencias)
this.dispatchEvent('dashboard:destroyed');
}
}
// Funciones de utilidad globales
function throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
function debounce(func, wait, immediate) {
let timeout;
return function() {
const context = this;
const args = arguments;
const later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}
// Auto-inicialización
document.addEventListener('DOMContentLoaded', () => {
const dashboardContainers = document.querySelectorAll('.dashboard-container');
dashboardContainers.forEach(container => {
if (!container.dataset.dashboardInitialized) {
new WidgetsDashboard(container);
container.dataset.dashboardInitialized = 'true';
}
});
});
// Exportar para uso como módulo
if (typeof module !== 'undefined' && module.exports) {
module.exports = WidgetsDashboard;
}
// Soporte para AMD
if (typeof define === 'function' && define.amd) {
define([], () => WidgetsDashboard);
}
// Exportar globalmente
if (typeof window !== 'undefined') {
window.WidgetsDashboard = WidgetsDashboard;
}
Ejemplos de Uso
Dashboard Básico
// Inicialización básica
const dashboard = new WidgetsDashboard('.dashboard-container');
// Escuchar eventos del dashboard
dashboard.container.addEventListener('dashboard:initialized', (e) => {
console.log('Dashboard inicializado:', e.detail.dashboard);
});
dashboard.container.addEventListener('dashboard:refreshed', (e) => {
console.log('Datos actualizados:', e.detail.timestamp);
});
Configuración Personalizada
const dashboard = new WidgetsDashboard('.dashboard-container', {
autoRefresh: true,
refreshInterval: 60000, // 1 minuto
enableAnimations: true,
theme: 'dark',
locale: 'es-ES'
});
// Cambiar tema dinámicamente
dashboard.setTheme('light');
// Actualizar datos manualmente
dashboard.refreshData();
Gestión Dinámica de Widgets
// Agregar nuevo widget
dashboard.addWidget({
type: 'chart',
chartType: 'line',
title: 'Nuevas Métricas',
data: {
labels: ['Q1', 'Q2', 'Q3', 'Q4'],
datasets: [{
label: 'Ventas',
data: [100, 150, 200, 180]
}]
}
});
// Actualizar widget existente
dashboard.updateWidget('revenue-chart', {
data: newChartData,
options: updatedOptions
});
// Remover widget
dashboard.removeWidget('old-widget');
Exportación de Datos
// Exportar como CSV
dashboard.exportData('csv');
// Exportar como JSON
dashboard.exportData('json');
// Obtener datos programáticamente
const dashboardData = dashboard.getWidgetData();
console.log('Datos del dashboard:', dashboardData);
Control de Actualización
// Detener actualización automática
dashboard.stopAutoRefresh();
// Reiniciar actualización automática
dashboard.startAutoRefresh();
// Actualizar rango de tiempo
dashboard.updateTimeRange('7d');
Referencia de API
Constructor
new WidgetsDashboard(container, options)
Opciones del Constructor
| Opción | Tipo | Predeterminado | Descripción |
|---|---|---|---|
autoRefresh | Boolean | true | Habilitar actualización automática |
refreshInterval | Number | 30000 | Intervalo de actualización en ms |
enableAnimations | Boolean | true | Habilitar animaciones |
theme | String | 'auto' | Tema: ‘light’, ‘dark’, ‘auto’ |
locale | String | 'es-ES' | Configuración regional |
Métodos
refreshData()
Actualiza todos los datos del dashboard manualmente.
dashboard.refreshData();
updateWidget(widgetId, data)
Actualiza un widget específico con nuevos datos.
dashboard.updateWidget('chart-1', {
data: newData,
options: newOptions
});
addWidget(config)
Agrega un nuevo widget al dashboard.
dashboard.addWidget({
type: 'kpi',
title: 'Nueva Métrica',
value: 1234,
change: '+5.2%'
});
removeWidget(widgetId)
Remueve un widget del dashboard.
dashboard.removeWidget('widget-id');
exportData(format)
Exporta los datos del dashboard en el formato especificado.
dashboard.exportData('csv'); // o 'json'
setTheme(theme)
Cambia el tema del dashboard.
dashboard.setTheme('dark'); // 'light', 'dark', 'auto'
updateTimeRange(range)
Actualiza el rango de tiempo para los datos.
dashboard.updateTimeRange('7d'); // '1h', '24h', '7d', '30d'
getWidgetData(widgetId?)
Obtiene los datos de un widget específico o de todo el dashboard.
const data = dashboard.getWidgetData('chart-1');
const allData = dashboard.getWidgetData();
destroy()
Destruye la instancia del dashboard y limpia los recursos.
dashboard.destroy();
Eventos
dashboard:initialized
Se dispara cuando el dashboard se inicializa completamente.
dashboard.container.addEventListener('dashboard:initialized', (e) => {
console.log('Dashboard listo:', e.detail.dashboard);
});
dashboard:refreshed
Se dispara cuando los datos se actualizan.
dashboard.container.addEventListener('dashboard:refreshed', (e) => {
console.log('Actualizado en:', e.detail.timestamp);
});
dashboard:timeRangeChanged
Se dispara cuando cambia el rango de tiempo.
dashboard.container.addEventListener('dashboard:timeRangeChanged', (e) => {
console.log('Nuevo rango:', e.detail.range);
});
dashboard:dataExported
Se dispara cuando se exportan datos.
dashboard.container.addEventListener('dashboard:dataExported', (e) => {
console.log('Datos exportados:', e.detail.format, e.detail.data);
});
widget:updated
Se dispara cuando se actualiza un widget.
dashboard.container.addEventListener('widget:updated', (e) => {
console.log('Widget actualizado:', e.detail.widgetId);
});
Clases CSS
Contenedores
.dashboard-container- Contenedor principal del dashboard.dashboard-header- Encabezado con título y controles.kpi-grid- Cuadrícula de tarjetas KPI.charts-grid- Cuadrícula de widgets de gráficos
Widgets
.kpi-card- Tarjeta de indicador clave de rendimiento.chart-widget- Widget de gráfico.table-widget- Widget de tabla de datos.activity-widget- Widget de actividad en tiempo real
Estados
.loading- Estado de carga.error- Estado de error.success- Estado de éxito.animate-in- Animación de entrada
Controles
.refresh-btn- Botón de actualización.export-btn- Botón de exportación.widget-menu-btn- Botón de menú del widget.time-range-select- Selector de rango de tiempo
Personalización
Variables CSS
:root {
/* Colores principales */
--dashboard-primary: #3b82f6;
--dashboard-secondary: #8b5cf6;
--dashboard-success: #10b981;
--dashboard-warning: #f59e0b;
--dashboard-error: #ef4444;
/* Colores de fondo */
--dashboard-bg: #f5f7fa;
--dashboard-card-bg: #ffffff;
--dashboard-header-bg: #ffffff;
/* Colores de texto */
--dashboard-text-primary: #1f2937;
--dashboard-text-secondary: #6b7280;
--dashboard-text-muted: #9ca3af;
/* Espaciado */
--dashboard-spacing-xs: 0.5rem;
--dashboard-spacing-sm: 1rem;
--dashboard-spacing-md: 1.5rem;
--dashboard-spacing-lg: 2rem;
--dashboard-spacing-xl: 3rem;
/* Bordes */
--dashboard-border-radius: 12px;
--dashboard-border-color: #e5e7eb;
/* Sombras */
--dashboard-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--dashboard-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--dashboard-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
Tema Personalizado
/* Tema personalizado */
.dashboard-container[data-theme="custom"] {
--dashboard-primary: #6366f1;
--dashboard-secondary: #8b5cf6;
--dashboard-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* Animaciones personalizadas */
@keyframes slideInFromLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.custom-animation {
animation: slideInFromLeft 0.6s ease-out;
}
Integración con Frameworks
React
import React, { useEffect, useRef } from 'react';
import { WidgetsDashboard } from './widgets-dashboard';
function DashboardComponent({ config }) {
const dashboardRef = useRef(null);
const instanceRef = useRef(null);
useEffect(() => {
if (dashboardRef.current && !instanceRef.current) {
instanceRef.current = new WidgetsDashboard(dashboardRef.current, config);
}
return () => {
if (instanceRef.current) {
instanceRef.current.destroy();
instanceRef.current = null;
}
};
}, [config]);
return (
<div ref={dashboardRef} className="dashboard-container">
{/* Contenido del dashboard */}
</div>
);
}
Vue
<template>
<div ref="dashboardContainer" class="dashboard-container">
<!-- Contenido del dashboard -->
</div>
</template>
<script>
import { WidgetsDashboard } from './widgets-dashboard';
export default {
name: 'DashboardComponent',
props: {
config: {
type: Object,
default: () => ({})
}
},
mounted() {
this.dashboard = new WidgetsDashboard(this.$refs.dashboardContainer, this.config);
},
beforeUnmount() {
if (this.dashboard) {
this.dashboard.destroy();
}
}
};
</script>
Angular
import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { WidgetsDashboard } from './widgets-dashboard';
@Component({
selector: 'app-dashboard',
template: `
<div #dashboardContainer class="dashboard-container">
<!-- Contenido del dashboard -->
</div>
`
})
export class DashboardComponent implements OnInit, OnDestroy {
@ViewChild('dashboardContainer', { static: true }) dashboardContainer!: ElementRef;
@Input() config: any = {};
private dashboard?: WidgetsDashboard;
ngOnInit() {
this.dashboard = new WidgetsDashboard(
this.dashboardContainer.nativeElement,
this.config
);
}
ngOnDestroy() {
if (this.dashboard) {
this.dashboard.destroy();
}
}
}
Accesibilidad
Soporte ARIA
- Todos los widgets tienen roles ARIA apropiados
- Etiquetas descriptivas para lectores de pantalla
- Estados de carga y error comunicados a tecnologías asistivas
- Navegación por teclado completa
Navegación por Teclado
Tab/Shift+Tab- Navegar entre elementos interactivosEnter/Space- Activar botones y controlesEscape- Cerrar menús y modalesArrow Keys- Navegar dentro de widgets complejos
Soporte para Lectores de Pantalla
<!-- Ejemplo de estructura accesible -->
<div class="chart-widget" role="region" aria-label="Gráfico de ingresos">
<div class="widget-header">
<h3 id="chart-title">Tendencia de Ingresos</h3>
</div>
<div class="chart-container" aria-labelledby="chart-title">
<canvas aria-label="Gráfico que muestra la tendencia de ingresos de los últimos 6 meses"></canvas>
</div>
</div>
Reducción de Movimiento
@media (prefers-reduced-motion: reduce) {
.dashboard-container * {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Soporte de Navegadores
- Chrome: 60+
- Firefox: 55+
- Safari: 12+
- Edge: 79+
- IE: No soportado
Polyfills para Navegadores Antiguos
<!-- Para soporte de IE 11 -->
<script src="https://polyfill.io/v3/polyfill.min.js?features=es6,IntersectionObserver,CustomEvent"></script>
Consideraciones de Rendimiento
Optimización
- Uso de
requestAnimationFramepara animaciones suaves - Debouncing y throttling para eventos de redimensionamiento
- Lazy loading de gráficos fuera del viewport
- Virtualización para grandes conjuntos de datos
- Limpieza automática de recursos al destruir
Mejores Prácticas
// Configurar intervalos de actualización apropiados
const dashboard = new WidgetsDashboard('.dashboard-container', {
refreshInterval: 60000, // No menos de 30 segundos para datos en tiempo real
enableAnimations: !window.matchMedia('(prefers-reduced-motion: reduce)').matches
});
// Pausar actualizaciones cuando la pestaña no está visible
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
dashboard.stopAutoRefresh();
} else {
dashboard.startAutoRefresh();
}
});
Gestión de Memoria
- Destrucción automática de gráficos Chart.js
- Limpieza de event listeners
- Cancelación de timers y observadores
- Liberación de referencias DOM
HTML
60
líneas
CSS
133
líneas
<div class="dashboard-container">
<div class="dashboard-grid">
<!-- Widget de Gráfico de Línea -->
<div class="widget line-chart-widget">
<div class="widget-header">
<h3>Ventas Mensuales</h3>
<div class="widget-controls">
<button class="refresh-btn">↻</button>
</div>
</div>
<div class="chart-container">
<canvas id="lineChart"></canvas>
</div>
</div>
<!-- Widget de Gráfico de Barras -->
<div class="widget bar-chart-widget">
<div class="widget-header">
<h3>Productos Populares</h3>
</div>
<div class="chart-container">
<canvas id="barChart"></canvas>
</div>
</div>
<!-- Widget de Métricas -->
<div class="widget metrics-widget">
<div class="widget-header">
<h3>Métricas Clave</h3>
</div>
<div class="metrics-grid">
<div class="metric-item">
<div class="metric-value">\$24,580</div>
<div class="metric-label">Ingresos</div>
<div class="metric-change positive">+12.5%</div>
</div>
<div class="metric-item">
<div class="metric-value">1,247</div>
<div class="metric-label">Usuarios</div>
<div class="metric-change positive">+8.2%</div>
</div>
<div class="metric-item">
<div class="metric-value">89.3%</div>
<div class="metric-label">Conversión</div>
<div class="metric-change negative">-2.1%</div>
</div>
</div>
</div>
<!-- Widget de Gráfico Circular -->
<div class="widget pie-chart-widget">
<div class="widget-header">
<h3>Distribución de Tráfico</h3>
</div>
<div class="chart-container">
<canvas id="pieChart"></canvas>
</div>
</div>
</div>
</div>