660 lines
20 KiB
Plaintext
660 lines
20 KiB
Plaintext
|
|
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;
|