PaperStack / components /InteractiveChart.tsx
Akhil-Theerthala's picture
Upload 32 files
46a757e verified
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;