Spaces:
Sleeping
Sleeping
| import React from 'react'; | |
| import { ChartData } from '../types'; | |
| import { AlertTriangle } from 'lucide-react'; | |
| interface Props { | |
| data: ChartData; | |
| theme: 'light' | 'dark'; | |
| } | |
| // Simple SVG-based chart implementation that works without external dependencies | |
| const InteractiveChart: React.FC<Props> = ({ data, theme }) => { | |
| const isDark = theme === 'dark'; | |
| // Validate data | |
| if (!data || !data.data || data.data.length === 0) { | |
| return ( | |
| <div className="w-full p-6 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800"> | |
| <div className="flex items-start gap-3"> | |
| <AlertTriangle size={20} className="text-amber-500 flex-shrink-0 mt-0.5" /> | |
| <div> | |
| <p className="font-semibold text-amber-700 dark:text-amber-300 text-sm"> | |
| No chart data available | |
| </p> | |
| <p className="mt-1 text-xs text-amber-600 dark:text-amber-400"> | |
| The chart could not be rendered because no data was provided. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Default colors palette | |
| const defaultColors = [ | |
| '#0ea5e9', // brand blue | |
| '#8b5cf6', // purple | |
| '#10b981', // emerald | |
| '#f59e0b', // amber | |
| '#ef4444', // red | |
| '#ec4899', // pink | |
| '#06b6d4', // cyan | |
| '#84cc16', // lime | |
| ]; | |
| const colors = data.colors || defaultColors; | |
| const maxValue = Math.max(...data.data.map(d => d.value)); | |
| const chartHeight = 200; | |
| const chartWidth = 400; | |
| const barWidth = Math.min(60, (chartWidth - 40) / data.data.length - 10); | |
| const renderBarChart = () => ( | |
| <svg viewBox={`0 0 ${chartWidth} ${chartHeight + 60}`} className="w-full h-auto"> | |
| {/* Y-axis labels */} | |
| {[0, 0.25, 0.5, 0.75, 1].map((ratio, i) => ( | |
| <g key={i}> | |
| <text | |
| x="30" | |
| y={chartHeight - (ratio * chartHeight) + 5} | |
| className={`text-[10px] ${isDark ? 'fill-gray-400' : 'fill-gray-500'}`} | |
| textAnchor="end" | |
| > | |
| {Math.round(maxValue * ratio)} | |
| </text> | |
| <line | |
| x1="40" | |
| y1={chartHeight - (ratio * chartHeight)} | |
| x2={chartWidth - 10} | |
| y2={chartHeight - (ratio * chartHeight)} | |
| className={isDark ? 'stroke-gray-700' : 'stroke-gray-200'} | |
| strokeDasharray="4,4" | |
| /> | |
| </g> | |
| ))} | |
| {/* Bars */} | |
| {data.data.map((item, index) => { | |
| const barHeight = (item.value / maxValue) * chartHeight; | |
| const x = 50 + index * ((chartWidth - 60) / data.data.length); | |
| const y = chartHeight - barHeight; | |
| return ( | |
| <g key={index} className="group cursor-pointer"> | |
| {/* Bar */} | |
| <rect | |
| x={x} | |
| y={y} | |
| width={barWidth} | |
| height={barHeight} | |
| fill={colors[index % colors.length]} | |
| rx="4" | |
| className="transition-all duration-300 hover:opacity-80" | |
| /> | |
| {/* Value on hover */} | |
| <text | |
| x={x + barWidth / 2} | |
| y={y - 8} | |
| textAnchor="middle" | |
| className={`text-[11px] font-bold opacity-0 group-hover:opacity-100 transition-opacity ${isDark ? 'fill-gray-200' : 'fill-gray-700'}`} | |
| > | |
| {item.value} | |
| </text> | |
| {/* Label */} | |
| <text | |
| x={x + barWidth / 2} | |
| y={chartHeight + 20} | |
| textAnchor="middle" | |
| className={`text-[10px] ${isDark ? 'fill-gray-400' : 'fill-gray-600'}`} | |
| > | |
| {item.label.length > 10 ? item.label.substring(0, 10) + '...' : item.label} | |
| </text> | |
| </g> | |
| ); | |
| })} | |
| </svg> | |
| ); | |
| const renderLineChart = () => { | |
| const points = data.data.map((item, index) => { | |
| const x = 50 + index * ((chartWidth - 80) / (data.data.length - 1 || 1)); | |
| const y = chartHeight - (item.value / maxValue) * chartHeight; | |
| return { x, y, ...item }; | |
| }); | |
| const pathD = points | |
| .map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`) | |
| .join(' '); | |
| // Area fill path | |
| const areaD = `${pathD} L ${points[points.length - 1]?.x || 0} ${chartHeight} L ${points[0]?.x || 0} ${chartHeight} Z`; | |
| return ( | |
| <svg viewBox={`0 0 ${chartWidth} ${chartHeight + 60}`} className="w-full h-auto"> | |
| {/* Grid lines */} | |
| {[0, 0.25, 0.5, 0.75, 1].map((ratio, i) => ( | |
| <g key={i}> | |
| <text | |
| x="30" | |
| y={chartHeight - (ratio * chartHeight) + 5} | |
| className={`text-[10px] ${isDark ? 'fill-gray-400' : 'fill-gray-500'}`} | |
| textAnchor="end" | |
| > | |
| {Math.round(maxValue * ratio)} | |
| </text> | |
| <line | |
| x1="40" | |
| y1={chartHeight - (ratio * chartHeight)} | |
| x2={chartWidth - 10} | |
| y2={chartHeight - (ratio * chartHeight)} | |
| className={isDark ? 'stroke-gray-700' : 'stroke-gray-200'} | |
| strokeDasharray="4,4" | |
| /> | |
| </g> | |
| ))} | |
| {/* Area fill */} | |
| <path | |
| d={areaD} | |
| fill={colors[0]} | |
| fillOpacity="0.1" | |
| /> | |
| {/* Line */} | |
| <path | |
| d={pathD} | |
| fill="none" | |
| stroke={colors[0]} | |
| strokeWidth="3" | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| /> | |
| {/* Points and labels */} | |
| {points.map((point, index) => ( | |
| <g key={index} className="group cursor-pointer"> | |
| <circle | |
| cx={point.x} | |
| cy={point.y} | |
| r="6" | |
| fill={colors[0]} | |
| className="transition-all duration-200 hover:r-8" | |
| /> | |
| <circle | |
| cx={point.x} | |
| cy={point.y} | |
| r="3" | |
| fill={isDark ? '#1e293b' : 'white'} | |
| /> | |
| {/* Tooltip on hover */} | |
| <text | |
| x={point.x} | |
| y={point.y - 14} | |
| textAnchor="middle" | |
| className={`text-[11px] font-bold opacity-0 group-hover:opacity-100 transition-opacity ${isDark ? 'fill-gray-200' : 'fill-gray-700'}`} | |
| > | |
| {point.value} | |
| </text> | |
| {/* X-axis label */} | |
| <text | |
| x={point.x} | |
| y={chartHeight + 20} | |
| textAnchor="middle" | |
| className={`text-[10px] ${isDark ? 'fill-gray-400' : 'fill-gray-600'}`} | |
| > | |
| {point.label.length > 8 ? point.label.substring(0, 8) + '..' : point.label} | |
| </text> | |
| </g> | |
| ))} | |
| </svg> | |
| ); | |
| }; | |
| const renderPieChart = () => { | |
| const total = data.data.reduce((sum, item) => sum + item.value, 0); | |
| const centerX = 150; | |
| const centerY = 120; | |
| const radius = 80; | |
| let startAngle = -90; | |
| return ( | |
| <svg viewBox="0 0 300 280" className="w-full h-auto max-w-[300px] mx-auto"> | |
| {data.data.map((item, index) => { | |
| const angle = (item.value / total) * 360; | |
| const endAngle = startAngle + angle; | |
| const startRad = (startAngle * Math.PI) / 180; | |
| const endRad = (endAngle * Math.PI) / 180; | |
| const x1 = centerX + radius * Math.cos(startRad); | |
| const y1 = centerY + radius * Math.sin(startRad); | |
| const x2 = centerX + radius * Math.cos(endRad); | |
| const y2 = centerY + radius * Math.sin(endRad); | |
| const largeArc = angle > 180 ? 1 : 0; | |
| const pathD = `M ${centerX} ${centerY} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`; | |
| const midAngle = startAngle + angle / 2; | |
| const midRad = (midAngle * Math.PI) / 180; | |
| const labelRadius = radius + 25; | |
| const labelX = centerX + labelRadius * Math.cos(midRad); | |
| const labelY = centerY + labelRadius * Math.sin(midRad); | |
| startAngle = endAngle; | |
| return ( | |
| <g key={index} className="group cursor-pointer"> | |
| <path | |
| d={pathD} | |
| fill={colors[index % colors.length]} | |
| className="transition-all duration-200 hover:opacity-80" | |
| style={{ transformOrigin: `${centerX}px ${centerY}px` }} | |
| /> | |
| </g> | |
| ); | |
| })} | |
| {/* Center circle for donut effect */} | |
| <circle | |
| cx={centerX} | |
| cy={centerY} | |
| r={radius * 0.5} | |
| className={isDark ? 'fill-gray-900' : 'fill-white'} | |
| /> | |
| {/* Legend */} | |
| <g transform={`translate(10, ${centerY * 2 + 20})`}> | |
| {data.data.map((item, index) => ( | |
| <g key={index} transform={`translate(${index * 90}, 0)`}> | |
| <rect | |
| x="0" | |
| y="0" | |
| width="12" | |
| height="12" | |
| rx="2" | |
| fill={colors[index % colors.length]} | |
| /> | |
| <text | |
| x="18" | |
| y="10" | |
| className={`text-[10px] ${isDark ? 'fill-gray-300' : 'fill-gray-600'}`} | |
| > | |
| {item.label.length > 8 ? item.label.substring(0, 8) + '..' : item.label} | |
| </text> | |
| </g> | |
| ))} | |
| </g> | |
| </svg> | |
| ); | |
| }; | |
| const renderChart = () => { | |
| switch (data.type) { | |
| case 'bar': | |
| return renderBarChart(); | |
| case 'line': | |
| case 'area': | |
| return renderLineChart(); | |
| case 'pie': | |
| return renderPieChart(); | |
| default: | |
| return renderBarChart(); | |
| } | |
| }; | |
| return ( | |
| <div className="w-full"> | |
| {/* Chart Title */} | |
| {data.title && ( | |
| <h4 className="text-center text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4"> | |
| {data.title} | |
| </h4> | |
| )} | |
| {/* Chart */} | |
| <div className="relative"> | |
| {renderChart()} | |
| </div> | |
| {/* Axis Labels */} | |
| {(data.xAxis || data.yAxis) && ( | |
| <div className="flex justify-between mt-2 text-xs text-gray-500 dark:text-gray-400"> | |
| {data.yAxis && <span className="italic">{data.yAxis}</span>} | |
| {data.xAxis && <span className="italic">{data.xAxis}</span>} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default InteractiveChart; | |