Files
bakery-ia/frontend/src/components/domain/analytics/ChartWidget.tsx.backup

660 lines
20 KiB
Plaintext
Raw Normal View History

2025-08-28 10:41:04 +02:00
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-gray-600">{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-red-600 font-medium">Error al cargar el gráfico</p>
<p className="text-sm text-gray-500 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-gray-900">
{widget.title}
</h3>
)}
{showSubtitle && widget.subtitle && (
<p className="text-sm text-gray-600 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-gray-500">No hay datos disponibles</p>
<p className="text-sm text-gray-400 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-gray-400">
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-gray-700 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-gray-700 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;