Files
bakery-ia/frontend/src/components/domain/analytics/ChartWidget.tsx
2025-08-28 10:41:04 +02:00

660 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Card, Button, Badge, Select, Modal } from '../../ui';
import type {
ChartWidget as ChartWidgetType,
ChartSeries,
ChartConfig,
ChartFilter,
ExportFormat,
ChartType
} from './types';
interface ChartWidgetProps {
widget: ChartWidgetType;
data?: ChartSeries[];
loading?: boolean;
error?: string;
onConfigChange?: (config: Partial<ChartConfig>) => void;
onFiltersChange?: (filters: ChartFilter[]) => void;
onRefresh?: () => void;
onExport?: (format: ExportFormat) => void;
onFullscreen?: () => void;
interactive?: boolean;
showControls?: boolean;
showTitle?: boolean;
showSubtitle?: boolean;
className?: string;
}
interface ChartPoint {
x: number;
y: number;
label: string;
}
const CHART_TYPE_LABELS: Record<ChartType, string> = {
line: 'Líneas',
bar: 'Barras',
pie: 'Circular',
area: 'Área',
scatter: 'Dispersión',
doughnut: 'Dona',
radar: 'Radar',
mixed: 'Mixto',
};
const EXPORT_FORMATS: ExportFormat[] = ['png', 'svg', 'pdf', 'csv', 'excel'];
export const ChartWidget: React.FC<ChartWidgetProps> = ({
widget,
data = [],
loading = false,
error,
onConfigChange,
onFiltersChange,
onRefresh,
onExport,
onFullscreen,
interactive = true,
showControls = true,
showTitle = true,
showSubtitle = true,
className = '',
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [hoveredPoint, setHoveredPoint] = useState<{ seriesIndex: number; pointIndex: number } | null>(null);
const [selectedExportFormat, setSelectedExportFormat] = useState<ExportFormat>('png');
const chartDimensions = useMemo(() => ({
width: widget.dimensions?.width || '100%',
height: widget.dimensions?.height || 400,
aspectRatio: widget.dimensions?.aspect_ratio || '16:9',
}), [widget.dimensions]);
// Simple chart rendering logic
useEffect(() => {
if (!canvasRef.current || !data.length || loading) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw based on chart type
switch (widget.type) {
case 'bar':
drawBarChart(ctx, canvas, data);
break;
case 'line':
drawLineChart(ctx, canvas, data);
break;
case 'pie':
drawPieChart(ctx, canvas, data);
break;
case 'area':
drawAreaChart(ctx, canvas, data);
break;
default:
drawBarChart(ctx, canvas, data);
}
}, [data, widget.type, loading]);
const drawBarChart = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, chartData: ChartSeries[]) => {
if (!chartData.length) return;
const padding = 60;
const chartWidth = canvas.width - 2 * padding;
const chartHeight = canvas.height - 2 * padding;
// Get all data points
const allPoints = chartData.flatMap(series => series.data);
const maxY = Math.max(...allPoints.map(point => point.y));
const minY = Math.min(...allPoints.map(point => point.y));
const firstSeries = chartData[0];
const barWidth = chartWidth / firstSeries.data.length / chartData.length;
const barSpacing = barWidth * 0.1;
// Draw grid
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const y = padding + (chartHeight * i) / 5;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(padding + chartWidth, y);
ctx.stroke();
}
// Draw bars
chartData.forEach((series, seriesIndex) => {
const color = series.color || widget.config.color_scheme[seriesIndex % widget.config.color_scheme.length];
ctx.fillStyle = color;
series.data.forEach((point, pointIndex) => {
const x = padding + (pointIndex * chartWidth) / firstSeries.data.length + (seriesIndex * barWidth);
const barHeight = ((point.y - minY) / (maxY - minY)) * chartHeight;
const y = padding + chartHeight - barHeight;
ctx.fillRect(x + barSpacing, y, barWidth - barSpacing, barHeight);
// Add value labels if enabled
if (widget.config.show_tooltips) {
ctx.fillStyle = '#374151';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(point.y.toString(), x + barWidth / 2, y - 5);
ctx.fillStyle = color;
}
});
});
// Draw axes
ctx.strokeStyle = '#374151';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, padding + chartHeight);
ctx.lineTo(padding + chartWidth, padding + chartHeight);
ctx.stroke();
// Draw labels
ctx.fillStyle = '#6b7280';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
// X-axis labels
firstSeries.data.forEach((point, index) => {
const x = padding + (index + 0.5) * (chartWidth / firstSeries.data.length);
ctx.fillText(point.x.toString(), x, padding + chartHeight + 20);
});
// Y-axis labels
ctx.textAlign = 'right';
for (let i = 0; i <= 5; i++) {
const y = padding + (chartHeight * i) / 5;
const value = maxY - ((maxY - minY) * i) / 5;
ctx.fillText(value.toFixed(0), padding - 10, y + 4);
}
};
const drawLineChart = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, chartData: ChartSeries[]) => {
if (!chartData.length) return;
const padding = 60;
const chartWidth = canvas.width - 2 * padding;
const chartHeight = canvas.height - 2 * padding;
const allPoints = chartData.flatMap(series => series.data);
const maxY = Math.max(...allPoints.map(point => point.y));
const minY = Math.min(...allPoints.map(point => point.y));
// Draw grid
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const y = padding + (chartHeight * i) / 5;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(padding + chartWidth, y);
ctx.stroke();
}
// Draw lines
chartData.forEach((series, seriesIndex) => {
if (!series.visible) return;
const color = series.color || widget.config.color_scheme[seriesIndex % widget.config.color_scheme.length];
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
series.data.forEach((point, pointIndex) => {
const x = padding + (pointIndex * chartWidth) / (series.data.length - 1);
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
if (pointIndex === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// Draw points
ctx.fillStyle = color;
series.data.forEach((point, pointIndex) => {
const x = padding + (pointIndex * chartWidth) / (series.data.length - 1);
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
ctx.beginPath();
ctx.arc(x, y, 4, 0, 2 * Math.PI);
ctx.fill();
});
});
// Draw axes
ctx.strokeStyle = '#374151';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, padding + chartHeight);
ctx.lineTo(padding + chartWidth, padding + chartHeight);
ctx.stroke();
};
const drawPieChart = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, chartData: ChartSeries[]) => {
if (!chartData.length) return;
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const radius = Math.min(canvas.width, canvas.height) / 3;
const firstSeries = chartData[0];
const total = firstSeries.data.reduce((sum, point) => sum + point.y, 0);
let startAngle = -Math.PI / 2;
firstSeries.data.forEach((point, index) => {
const sliceAngle = (point.y / total) * 2 * Math.PI;
const color = widget.config.color_scheme[index % widget.config.color_scheme.length];
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fill();
// Draw slice border
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.stroke();
// Draw labels
const labelAngle = startAngle + sliceAngle / 2;
const labelX = centerX + Math.cos(labelAngle) * (radius + 30);
const labelY = centerY + Math.sin(labelAngle) * (radius + 30);
ctx.fillStyle = '#374151';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(point.x.toString(), labelX, labelY);
const percentage = ((point.y / total) * 100).toFixed(1);
ctx.fillText(`${percentage}%`, labelX, labelY + 15);
startAngle += sliceAngle;
});
};
const drawAreaChart = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, chartData: ChartSeries[]) => {
if (!chartData.length) return;
const padding = 60;
const chartWidth = canvas.width - 2 * padding;
const chartHeight = canvas.height - 2 * padding;
const allPoints = chartData.flatMap(series => series.data);
const maxY = Math.max(...allPoints.map(point => point.y));
const minY = Math.min(...allPoints.map(point => point.y));
chartData.forEach((series, seriesIndex) => {
if (!series.visible) return;
const color = series.color || widget.config.color_scheme[seriesIndex % widget.config.color_scheme.length];
// Create gradient
const gradient = ctx.createLinearGradient(0, padding, 0, padding + chartHeight);
gradient.addColorStop(0, color + '80'); // Semi-transparent
gradient.addColorStop(1, color + '10'); // Very transparent
ctx.fillStyle = gradient;
ctx.beginPath();
// Start from bottom-left
const firstX = padding;
const firstY = padding + chartHeight;
ctx.moveTo(firstX, firstY);
// Draw the area path
series.data.forEach((point, pointIndex) => {
const x = padding + (pointIndex * chartWidth) / (series.data.length - 1);
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
ctx.lineTo(x, y);
});
// Close the path at bottom-right
const lastX = padding + chartWidth;
const lastY = padding + chartHeight;
ctx.lineTo(lastX, lastY);
ctx.closePath();
ctx.fill();
// Draw the line on top
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
series.data.forEach((point, pointIndex) => {
const x = padding + (pointIndex * chartWidth) / (series.data.length - 1);
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
if (pointIndex === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
});
};
const handleExport = () => {
if (onExport) {
onExport(selectedExportFormat);
}
};
const handleFullscreen = () => {
setIsFullscreen(!isFullscreen);
if (onFullscreen) {
onFullscreen();
}
};
const renderLegend = () => {
if (!widget.config.show_legend || !data.length) return null;
return (
<div className="flex flex-wrap gap-4 mt-4">
{data.map((series, index) => (
<div key={series.id} className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{
backgroundColor: series.color || widget.config.color_scheme[index % widget.config.color_scheme.length]
}}
/>
<span className="text-sm text-[var(--text-secondary)]">{series.name}</span>
</div>
))}
</div>
);
};
const renderControls = () => {
if (!showControls || !interactive) return null;
return (
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={() => setIsConfigModalOpen(true)}
title="Configurar gráfico"
>
</Button>
<Select
value={selectedExportFormat}
onChange={(e) => setSelectedExportFormat(e.target.value as ExportFormat)}
className="w-20 text-xs"
>
{EXPORT_FORMATS.map(format => (
<option key={format} value={format}>{format.toUpperCase()}</option>
))}
</Select>
<Button
variant="ghost"
size="sm"
onClick={handleExport}
title="Exportar gráfico"
>
📥
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleFullscreen}
title="Pantalla completa"
>
{isFullscreen ? '📋' : '🔍'}
</Button>
{onRefresh && (
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={loading}
title="Actualizar datos"
>
🔄
</Button>
)}
</div>
);
};
if (error) {
return (
<Card className={`p-6 ${className}`}>
<div className="text-center">
<p className="text-[var(--color-error)] font-medium">Error al cargar el gráfico</p>
<p className="text-sm text-[var(--text-tertiary)] mt-1">{error}</p>
{onRefresh && (
<Button variant="outline" size="sm" onClick={onRefresh} className="mt-3">
Reintentar
</Button>
)}
</div>
</Card>
);
}
return (
<>
<Card className={`group relative ${className}`} style={{
width: chartDimensions.width,
height: isFullscreen ? '90vh' : chartDimensions.height
}}>
{/* Header */}
<div className="flex items-start justify-between p-4 pb-2">
<div className="flex-1">
{showTitle && (
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{widget.title}
</h3>
)}
{showSubtitle && widget.subtitle && (
<p className="text-sm text-[var(--text-secondary)] mt-1">
{widget.subtitle}
</p>
)}
</div>
{renderControls()}
</div>
{/* Chart Area */}
<div className="px-4 pb-4" style={{ height: `calc(100% - 80px)` }}>
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : data.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-[var(--text-tertiary)]">No hay datos disponibles</p>
<p className="text-sm text-[var(--text-tertiary)] mt-1">
Ajusta los filtros o verifica la conexión de datos
</p>
</div>
</div>
) : (
<div className="h-full">
<canvas
ref={canvasRef}
className="w-full h-full"
style={{ maxHeight: '100%' }}
/>
{renderLegend()}
</div>
)}
</div>
{/* Last Updated */}
{widget.last_updated && (
<div className="absolute bottom-2 right-2 text-xs text-[var(--text-tertiary)]">
Actualizado: {new Date(widget.last_updated).toLocaleTimeString()}
</div>
)}
</Card>
{/* Configuration Modal */}
<Modal
isOpen={isConfigModalOpen}
onClose={() => setIsConfigModalOpen(false)}
title="Configuración del Gráfico"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Tipo de gráfico
</label>
<Select
value={widget.type}
onChange={(e) => {
if (onConfigChange) {
onConfigChange({ visualization_type: e.target.value as ChartType });
}
}}
>
{Object.entries(CHART_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={widget.config.show_legend}
onChange={(e) => {
if (onConfigChange) {
onConfigChange({ show_legend: e.target.checked });
}
}}
className="mr-2"
/>
Mostrar leyenda
</label>
</div>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={widget.config.show_grid}
onChange={(e) => {
if (onConfigChange) {
onConfigChange({ show_grid: e.target.checked });
}
}}
className="mr-2"
/>
Mostrar cuadrícula
</label>
</div>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={widget.config.show_tooltips}
onChange={(e) => {
if (onConfigChange) {
onConfigChange({ show_tooltips: e.target.checked });
}
}}
className="mr-2"
/>
Mostrar tooltips
</label>
</div>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={widget.config.animation_enabled}
onChange={(e) => {
if (onConfigChange) {
onConfigChange({ animation_enabled: e.target.checked });
}
}}
className="mr-2"
/>
Animaciones
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Posición de la leyenda
</label>
<Select
value={widget.config.legend_position}
onChange={(e) => {
if (onConfigChange) {
onConfigChange({ legend_position: e.target.value as any });
}
}}
>
<option value="top">Arriba</option>
<option value="bottom">Abajo</option>
<option value="left">Izquierda</option>
<option value="right">Derecha</option>
</Select>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button
variant="outline"
onClick={() => setIsConfigModalOpen(false)}
>
Cancelar
</Button>
<Button
variant="primary"
onClick={() => {
setIsConfigModalOpen(false);
if (onRefresh) onRefresh();
}}
>
Aplicar cambios
</Button>
</div>
</div>
</Modal>
</>
);
};
export default ChartWidget;