Spaces:
Sleeping
Sleeping
| import React, { useState, useMemo, useCallback, useRef } from 'react'; | |
| import * as XLSX from 'xlsx'; | |
| import { | |
| ScatterChart, | |
| Scatter, | |
| XAxis, | |
| YAxis, | |
| ZAxis, | |
| CartesianGrid, | |
| Tooltip, | |
| ResponsiveContainer, | |
| ReferenceLine, | |
| LabelList, | |
| Legend, | |
| Text, | |
| Cell | |
| } from 'recharts'; | |
| import { Upload, Settings2, BarChart2, FileSpreadsheet, Info, Download, Image as ImageIcon, X, Palette, Trash2, Check, ChevronDown, ChevronUp, Copy } from 'lucide-react'; | |
| import { clsx, type ClassValue } from 'clsx'; | |
| import { twMerge } from 'tailwind-merge'; | |
| import { toPng, toBlob } from 'html-to-image'; | |
| import { ChartConfig, DataPoint, PointOverride } from './types'; | |
| // Utility for tailwind classes | |
| function cn(...inputs: ClassValue[]) { | |
| return twMerge(clsx(inputs)); | |
| } | |
| export default function App() { | |
| const [data, setData] = useState<DataPoint[]>([]); | |
| const [columns, setColumns] = useState<string[]>([]); | |
| const [config, setConfig] = useState<ChartConfig>({ | |
| xAxisKey: '', | |
| yAxisKey: '', | |
| labelKey: '', | |
| categoryKey: '', | |
| groupByKeys: [], | |
| sizeKey: '', | |
| title: 'Campaign Performance Quadrant', | |
| }); | |
| const [pointOverrides, setPointOverrides] = useState<Record<string, PointOverride>>({}); | |
| const [selectedPoint, setSelectedPoint] = useState<{ id: string, label: string } | null>(null); | |
| const [isDragging, setIsDragging] = useState(false); | |
| const chartRef = useRef<HTMLDivElement>(null); | |
| const parseNumeric = (val: any): number => { | |
| if (val === null || val === undefined || val === '') return NaN; | |
| if (typeof val === 'number') return val; | |
| const str = String(val).trim(); | |
| if (str.endsWith('%')) { | |
| return parseFloat(str.replace('%', '')) / 100; | |
| } | |
| const parsed = parseFloat(str); | |
| return isNaN(parsed) ? NaN : parsed; | |
| }; | |
| const handleFileUpload = (file: File) => { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| const bstr = e.target?.result; | |
| const wb = XLSX.read(bstr, { type: 'binary' }); | |
| const wsname = wb.SheetNames[0]; | |
| const ws = wb.Sheets[wsname]; | |
| // Use defval: "" to ensure all keys are present even if empty in some rows | |
| const jsonData = XLSX.utils.sheet_to_json(ws, { defval: "" }) as DataPoint[]; | |
| if (jsonData.length > 0) { | |
| // Add unique ID to each row | |
| const dataWithIds = jsonData.map((row, idx) => ({ | |
| ...row, | |
| __id: `row-${idx}-${Date.now()}` | |
| })); | |
| // Get all keys from the sheet range if possible, or from the first row | |
| const cols = Object.keys(jsonData[0]); | |
| setColumns(cols); | |
| setData(dataWithIds); | |
| const numericCols = cols.filter(c => !isNaN(parseNumeric(jsonData[0][c]))); | |
| setConfig(prev => ({ | |
| ...prev, | |
| xAxisKey: numericCols.find(c => c.toUpperCase().includes('CPM')) || numericCols[0] || cols[0], | |
| yAxisKey: numericCols.find(c => c.toUpperCase().includes('CTR')) || numericCols[1] || cols[1], | |
| labelKey: cols.find(c => /name|campaign|product/i.test(c)) || cols[0], | |
| categoryKey: cols.find(c => /type|category/i.test(c)) || '', | |
| groupByKeys: [], | |
| sizeKey: numericCols.find(c => /spend|cost|budget|impression/i.test(c)) || '', | |
| })); | |
| setPointOverrides({}); | |
| } | |
| }; | |
| reader.readAsBinaryString(file); | |
| }; | |
| const onDrop = useCallback((e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setIsDragging(false); | |
| const file = e.dataTransfer.files[0]; | |
| if (file && (file.name.endsWith('.xlsx') || file.name.endsWith('.xls') || file.name.endsWith('.csv'))) { | |
| handleFileUpload(file); | |
| } | |
| }, []); | |
| // Removed old stats useMemo as it is now calculated per chart | |
| const chartData = useMemo(() => { | |
| if (!data.length || !config.xAxisKey || !config.yAxisKey) return []; | |
| // Keep the filtering logic if needed, but the user didn't ask to change it | |
| const campaignsToRemove = ['Gender holiday promotion', 'AIOT March promotion']; | |
| return data | |
| .filter(d => { | |
| const label = String(d[config.labelKey] || '').trim().toLowerCase(); | |
| const isHidden = pointOverrides[d.__id]?.hidden; | |
| // Filter out abnormal points (CPC, CPV, ER = empty) | |
| const cpc = String(d['CPC'] || '').trim(); | |
| const cpv = String(d['CPV'] || '').trim(); | |
| const er = String(d['ER'] || '').trim(); | |
| const isAbnormal = (d.hasOwnProperty('CPC') && cpc === '') || | |
| (d.hasOwnProperty('CPV') && cpv === '') || | |
| (d.hasOwnProperty('ER') && er === ''); | |
| return !campaignsToRemove.some(c => c.toLowerCase() === label) && !isHidden && !isAbnormal; | |
| }) | |
| .map(d => ({ | |
| ...d, | |
| x: parseNumeric(d[config.xAxisKey]), | |
| y: parseNumeric(d[config.yAxisKey]), | |
| z: config.sizeKey ? parseNumeric(d[config.sizeKey]) : 1, | |
| color: pointOverrides[d.__id]?.color || '#6366f1' | |
| })) | |
| .filter(d => !isNaN(d.x) && !isNaN(d.y)); | |
| }, [data, config.xAxisKey, config.yAxisKey, config.labelKey, pointOverrides]); | |
| const multiChartData = useMemo(() => { | |
| if (!chartData.length) return []; | |
| if (config.groupByKeys.length === 0) { | |
| return [{ name: 'All Data', points: chartData }]; | |
| } | |
| const groups: Record<string, DataPoint[]> = {}; | |
| chartData.forEach(d => { | |
| const groupName = config.groupByKeys | |
| .map(key => `${key}: ${d[key] || 'Other'}`) | |
| .join(' | '); | |
| if (!groups[groupName]) groups[groupName] = []; | |
| groups[groupName].push(d); | |
| }); | |
| return Object.entries(groups).map(([name, points]) => ({ name, points })); | |
| }, [chartData, config.groupByKeys]); | |
| const calculateStats = (points: DataPoint[]) => { | |
| if (!points.length) return null; | |
| const xValues = points.map(d => d.x).filter(v => !isNaN(v)); | |
| const yValues = points.map(d => d.y).filter(v => !isNaN(v)); | |
| if (xValues.length === 0 || yValues.length === 0) return null; | |
| const xMean = xValues.reduce((a, b) => a + b, 0) / xValues.length; | |
| const yMean = yValues.reduce((a, b) => a + b, 0) / yValues.length; | |
| const xMin = Math.min(...xValues); | |
| const xMax = Math.max(...xValues); | |
| const yMin = Math.min(...yValues); | |
| const yMax = Math.max(...yValues); | |
| return { xMean, yMean, xMin, xMax, yMin, yMax }; | |
| }; | |
| const colors = ['#3b82f6', '#f97316', '#10b981', '#8b5cf6', '#ec4899', '#f59e0b']; | |
| const shapes = ['circle', 'cross', 'diamond', 'square', 'star', 'triangle']; | |
| const formatValue = (val: any) => { | |
| const num = Number(val); | |
| if (isNaN(num)) return val; | |
| return num <= 1 && num >= 0 ? `${(num * 100).toFixed(2)}%` : num.toFixed(2); | |
| }; | |
| const [copyingId, setCopyingId] = useState<string | null>(null); | |
| const copyChart = useCallback((elementId: string) => { | |
| const el = document.getElementById(elementId); | |
| if (!el) return; | |
| setCopyingId(elementId); | |
| toBlob(el, { backgroundColor: '#ffffff' }) | |
| .then((blob) => { | |
| if (blob) { | |
| const item = new ClipboardItem({ 'image/png': blob }); | |
| navigator.clipboard.write([item]); | |
| setTimeout(() => setCopyingId(null), 2000); | |
| } | |
| }) | |
| .catch((err) => { | |
| console.error('Failed to copy chart', err); | |
| setCopyingId(null); | |
| }); | |
| }, []); | |
| const downloadPng = useCallback(() => { | |
| if (chartRef.current === null) return; | |
| toPng(chartRef.current, { cacheBust: true, backgroundColor: '#ffffff' }) | |
| .then((dataUrl) => { | |
| const link = document.createElement('a'); | |
| link.download = `${config.title || 'quadrant-chart'}.png`; | |
| link.href = dataUrl; | |
| link.click(); | |
| }) | |
| .catch((err) => { | |
| console.error('oops, something went wrong!', err); | |
| }); | |
| }, [config.title]); | |
| const renderCustomShape = (props: any) => { | |
| const { cx, cy, fill, shapeIndex } = props; | |
| const shape = shapes[shapeIndex % shapes.length]; | |
| switch (shape) { | |
| case 'cross': | |
| return ( | |
| <g transform={`translate(${cx - 6}, ${cy - 6})`}> | |
| <path d="M0 0 L12 12 M12 0 L0 12" stroke={fill} strokeWidth={3} fill="none" /> | |
| </g> | |
| ); | |
| case 'diamond': | |
| return <path d={`M${cx},${cy - 8} L${cx + 8},${cy} L${cx},${cy + 8} L${cx - 8},${cy} Z`} fill={fill} />; | |
| case 'square': | |
| return <rect x={cx - 6} y={cy - 6} width={12} height={12} fill={fill} />; | |
| default: | |
| return <circle cx={cx} cy={cy} r={6} fill={fill} />; | |
| } | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-[#f8fafc] text-slate-900 font-sans pb-20"> | |
| <header className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between sticky top-0 z-10"> | |
| <div className="flex items-center gap-3"> | |
| <div className="bg-indigo-600 p-2 rounded-lg"> | |
| <BarChart2 className="text-white w-6 h-6" /> | |
| </div> | |
| <h1 className="text-xl font-bold tracking-tight">Quadrant Chart Pro</h1> | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| <button | |
| onClick={() => { | |
| const sample = [ | |
| { Name: 'Redmi note 14 launch', CPM: 0.71, CTR: 0.80, Type: 'Launch' }, | |
| { Name: 'Xiaomi 15 launch', CPM: 0.67, CTR: 0.41, Type: 'Launch' }, | |
| { Name: 'Redmi 15/15C launch', CPM: 0.54, CTR: 0.43, Type: 'Launch' }, | |
| { Name: 'Redmi note 14XFF', CPM: 0.45, CTR: 0.48, Type: 'Promotion' }, | |
| { Name: 'Xiaomi Pad 7 Pro', CPM: 0.34, CTR: 0.66, Type: 'Launch' }, | |
| { Name: 'REDMI Pad 2 launch', CPM: 0.30, CTR: 0.56, Type: 'Launch' }, | |
| { Name: 'Smartphone NY Promo', CPM: 0.26, CTR: 0.37, Type: 'Promotion' }, | |
| { Name: 'TV March promotion', CPM: 0.27, CTR: 0.09, Type: 'Promotion' }, | |
| { Name: 'Xiaomi 15T series launch', CPM: 0.19, CTR: 0.31, Type: 'Launch' }, | |
| { Name: 'Redmi Pad 2 Pro launch', CPM: 0.15, CTR: 0.38, Type: 'Launch' }, | |
| { Name: 'Redmi A5 launch', CPM: 0.19, CTR: 0.27, Type: 'Launch' }, | |
| { Name: 'Xiaomi Robot Vacuum 5 launch', CPM: 0.11, CTR: 0.15, Type: 'Launch' }, | |
| { Name: 'Band10 launch', CPM: 0.12, CTR: 0.05, Type: 'Launch' }, | |
| ]; | |
| setColumns(Object.keys(sample[0])); | |
| setData(sample); | |
| setConfig({ | |
| xAxisKey: 'CPM', | |
| yAxisKey: 'CTR', | |
| labelKey: 'Name', | |
| categoryKey: 'Type', | |
| groupByKeys: [], | |
| sizeKey: 'CPM', // Just for example | |
| title: 'Campaign Performance Quadrant (CPM vs CTR)', | |
| }); | |
| }} | |
| className="text-sm font-medium text-indigo-600 hover:text-indigo-700" | |
| > | |
| Load Sample Data | |
| </button> | |
| <button | |
| onClick={downloadPng} | |
| disabled={multiChartData.length === 0} | |
| className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-indigo-600 text-white hover:bg-indigo-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| <ImageIcon size={18} /> | |
| Save as PNG | |
| </button> | |
| </div> | |
| </header> | |
| <main className="max-w-[1600px] mx-auto p-6 grid grid-cols-1 lg:grid-cols-4 gap-6"> | |
| <div className="lg:col-span-1 space-y-6"> | |
| <section className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm"> | |
| <h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4 flex items-center gap-2"> | |
| <FileSpreadsheet size={16} /> | |
| Data Source | |
| </h2> | |
| <div | |
| onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} | |
| onDragLeave={() => setIsDragging(false)} | |
| onDrop={onDrop} | |
| className={cn( | |
| "border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer", | |
| isDragging ? "border-indigo-500 bg-indigo-50" : "border-slate-200 hover:border-slate-300", | |
| data.length > 0 ? "py-4" : "py-12" | |
| )} | |
| onClick={() => document.getElementById('file-upload')?.click()} | |
| > | |
| <input id="file-upload" type="file" className="hidden" accept=".xlsx,.xls,.csv" onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0])} /> | |
| <Upload className={cn("mx-auto mb-3 text-slate-400", data.length > 0 ? "w-6 h-6" : "w-10 h-10")} /> | |
| <p className="text-sm font-medium text-slate-600">{data.length > 0 ? "Change File" : "Drop Excel file here"}</p> | |
| </div> | |
| </section> | |
| <section className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm"> | |
| <h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4 flex items-center gap-2"> | |
| <Settings2 size={16} /> | |
| Configuration | |
| </h2> | |
| <div className="space-y-4"> | |
| <div> | |
| <label className="block text-xs font-bold text-slate-700 mb-1.5">Chart Title</label> | |
| <input type="text" value={config.title} onChange={(e) => setConfig(prev => ({ ...prev, title: e.target.value }))} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 outline-none" /> | |
| </div> | |
| <div> | |
| <label className="block text-xs font-bold text-slate-700 mb-1.5">X-Axis</label> | |
| <select value={config.xAxisKey} onChange={(e) => setConfig(prev => ({ ...prev, xAxisKey: e.target.value }))} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm outline-none"> | |
| {columns.map(col => <option key={col} value={col}>{col}</option>)} | |
| </select> | |
| </div> | |
| <div> | |
| <label className="block text-xs font-bold text-slate-700 mb-1.5">Y-Axis</label> | |
| <select value={config.yAxisKey} onChange={(e) => setConfig(prev => ({ ...prev, yAxisKey: e.target.value }))} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm outline-none"> | |
| {columns.map(col => <option key={col} value={col}>{col}</option>)} | |
| </select> | |
| </div> | |
| <div> | |
| <label className="block text-xs font-bold text-slate-700 mb-1.5">Label</label> | |
| <select value={config.labelKey} onChange={(e) => setConfig(prev => ({ ...prev, labelKey: e.target.value }))} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm outline-none"> | |
| {columns.map(col => <option key={col} value={col}>{col}</option>)} | |
| </select> | |
| </div> | |
| <div> | |
| <label className="block text-xs font-bold text-slate-700 mb-1.5">Point Size (Weight)</label> | |
| <select value={config.sizeKey} onChange={(e) => setConfig(prev => ({ ...prev, sizeKey: e.target.value }))} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm outline-none"> | |
| <option value="">Uniform Size</option> | |
| {columns.map(col => <option key={col} value={col}>{col}</option>)} | |
| </select> | |
| </div> | |
| <div> | |
| <label className="block text-xs font-bold text-slate-700 mb-1.5">Group By (Separate Charts)</label> | |
| <div className="space-y-1 max-h-40 overflow-y-auto p-2 bg-slate-50 border border-slate-200 rounded-lg"> | |
| {columns.map(col => ( | |
| <label key={col} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-slate-100 p-1 rounded"> | |
| <input | |
| type="checkbox" | |
| checked={config.groupByKeys.includes(col)} | |
| onChange={(e) => { | |
| const newKeys = e.target.checked | |
| ? [...config.groupByKeys, col] | |
| : config.groupByKeys.filter(k => k !== col); | |
| setConfig(prev => ({ ...prev, groupByKeys: newKeys })); | |
| }} | |
| className="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" | |
| /> | |
| <span className="truncate">{col}</span> | |
| </label> | |
| ))} | |
| {columns.length === 0 && <p className="text-xs text-slate-400 italic">Upload data first</p>} | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| <div ref={chartRef} className="lg:col-span-3 space-y-8"> | |
| {multiChartData.length > 0 ? ( | |
| multiChartData.map((group, groupIndex) => { | |
| const groupStats = calculateStats(group.points); | |
| if (!groupStats) return null; | |
| return ( | |
| <div key={group.name} id={`chart-container-${groupIndex}`} className="bg-white p-8 rounded-3xl border border-slate-200 shadow-sm h-[700px] flex flex-col overflow-hidden relative group"> | |
| <div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2 z-20"> | |
| <button | |
| onClick={() => copyChart(`chart-container-${groupIndex}`)} | |
| className="p-2 bg-white border border-slate-200 rounded-lg shadow-sm hover:bg-slate-50 transition-colors flex items-center gap-2 text-xs font-medium text-slate-600" | |
| title="Copy to Clipboard" | |
| > | |
| {copyingId === `chart-container-${groupIndex}` ? <Check size={14} className="text-green-500" /> : <Copy size={14} />} | |
| {copyingId === `chart-container-${groupIndex}` ? 'Copied!' : 'Copy'} | |
| </button> | |
| </div> | |
| <div className="mb-6 text-center"> | |
| <h2 className="text-2xl font-bold text-slate-800 tracking-tight">{config.title}</h2> | |
| <div className="flex flex-wrap justify-center gap-x-6 gap-y-1 mt-2 text-sm border-t border-slate-100 pt-2"> | |
| <span className="text-slate-500"> | |
| X-Axis: <span className="text-slate-900 font-semibold">{config.xAxisKey}</span> | |
| </span> | |
| <span className="text-slate-500"> | |
| Y-Axis: <span className="text-slate-900 font-semibold">{config.yAxisKey}</span> | |
| </span> | |
| {config.sizeKey && ( | |
| <span className="text-slate-500"> | |
| Size: <span className="text-slate-900 font-semibold">{config.sizeKey}</span> | |
| </span> | |
| )} | |
| {config.groupByKeys.length > 0 && ( | |
| <span className="text-slate-500"> | |
| Group: <span className="text-indigo-600 font-bold">{group.name}</span> | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex-1 w-full relative"> | |
| <ResponsiveContainer width="100%" height="100%"> | |
| <ScatterChart margin={{ top: 40, right: 40, bottom: 60, left: 60 }}> | |
| <CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" /> | |
| <XAxis | |
| type="number" | |
| dataKey="x" | |
| name={config.xAxisKey} | |
| stroke="#94a3b8" | |
| fontSize={12} | |
| domain={['auto', 'auto']} | |
| tickFormatter={formatValue} | |
| label={{ value: `${config.xAxisKey} | Mean = ${formatValue(groupStats.xMean)}`, position: 'bottom', offset: 40, style: { fill: '#475569', fontWeight: 600, fontSize: 14 } }} | |
| /> | |
| <YAxis | |
| type="number" | |
| dataKey="y" | |
| name={config.yAxisKey} | |
| stroke="#94a3b8" | |
| fontSize={12} | |
| domain={['auto', 'auto']} | |
| tickFormatter={formatValue} | |
| label={{ value: `${config.yAxisKey} | Mean = ${formatValue(groupStats.yMean)}`, angle: -90, position: 'left', offset: 40, style: { fill: '#475569', fontWeight: 600, fontSize: 14 } }} | |
| /> | |
| <ZAxis type="number" dataKey="z" range={config.sizeKey ? [50, 800] : [100, 100]} /> | |
| <Tooltip | |
| content={({ active, payload }) => { | |
| if (active && payload && payload.length) { | |
| const d = payload[0].payload; | |
| return ( | |
| <div className="bg-white p-4 border border-slate-200 shadow-2xl rounded-xl text-sm min-w-[220px]"> | |
| <p className="font-bold text-slate-900 mb-3 border-b border-slate-100 pb-2 text-base">{d[config.labelKey]}</p> | |
| <div className="space-y-2 mb-3"> | |
| <div className="flex flex-col bg-slate-50 p-2 rounded-lg border border-slate-100"> | |
| <span className="text-[10px] uppercase tracking-wider text-slate-400 font-bold">{config.xAxisKey} (X)</span> | |
| <span className="font-mono font-bold text-indigo-600 text-lg leading-none">{formatValue(d.x)}</span> | |
| </div> | |
| <div className="flex flex-col bg-slate-50 p-2 rounded-lg border border-slate-100"> | |
| <span className="text-[10px] uppercase tracking-wider text-slate-400 font-bold">{config.yAxisKey} (Y)</span> | |
| <span className="font-mono font-bold text-indigo-600 text-lg leading-none">{formatValue(d.y)}</span> | |
| </div> | |
| {config.sizeKey && ( | |
| <div className="flex flex-col bg-slate-50 p-2 rounded-lg border border-slate-100"> | |
| <span className="text-[10px] uppercase tracking-wider text-slate-400 font-bold">{config.sizeKey} (Size)</span> | |
| <span className="font-mono font-bold text-indigo-600 text-lg leading-none">{formatValue(d.z)}</span> | |
| </div> | |
| )} | |
| </div> | |
| <div className="space-y-1 pt-2 border-t border-slate-100"> | |
| {columns.filter(c => c !== config.xAxisKey && c !== config.yAxisKey && c !== config.labelKey && c !== config.sizeKey && !c.startsWith('__')).slice(0, 5).map(col => ( | |
| <p key={col} className="flex justify-between gap-4 text-[11px]"> | |
| <span className="text-slate-400 truncate max-w-[120px]">{col}:</span> | |
| <span className="text-slate-600 font-medium">{String(d[col] || '')}</span> | |
| </p> | |
| ))} | |
| </div> | |
| <p className="mt-3 text-[10px] text-center text-indigo-400 italic font-medium animate-pulse">Click point to edit or remove</p> | |
| </div> | |
| ); | |
| } | |
| return null; | |
| }} | |
| /> | |
| <ReferenceLine x={groupStats.xMean} stroke="#6366f1" strokeDasharray="5 5" strokeWidth={2} /> | |
| <ReferenceLine y={groupStats.yMean} stroke="#6366f1" strokeDasharray="5 5" strokeWidth={2} /> | |
| {/* Quadrant Labels */} | |
| <Text x="25%" y="15%" textAnchor="middle" fill="#cbd5e1" fontSize={14} fontWeight="bold">LOW {config.xAxisKey} / HIGH {config.yAxisKey}</Text> | |
| <Text x="75%" y="15%" textAnchor="middle" fill="#cbd5e1" fontSize={14} fontWeight="bold">HIGH {config.xAxisKey} / HIGH {config.yAxisKey}</Text> | |
| <Text x="25%" y="85%" textAnchor="middle" fill="#cbd5e1" fontSize={14} fontWeight="bold">LOW {config.xAxisKey} / LOW {config.yAxisKey}</Text> | |
| <Text x="75%" y="85%" textAnchor="middle" fill="#cbd5e1" fontSize={14} fontWeight="bold">HIGH {config.xAxisKey} / LOW {config.yAxisKey}</Text> | |
| <Scatter | |
| name={group.name} | |
| data={group.points} | |
| onClick={(data) => { | |
| if (data && data.payload) { | |
| setSelectedPoint({ | |
| id: data.payload.__id, | |
| label: data.payload[config.labelKey] | |
| }); | |
| } | |
| }} | |
| cursor="pointer" | |
| > | |
| {group.points.map((entry, index) => ( | |
| <Cell key={`cell-${index}`} fill={entry.color} /> | |
| ))} | |
| <LabelList dataKey={config.labelKey} position="top" offset={10} style={{ fontSize: '10px', fill: '#64748b', fontWeight: 500 }} /> | |
| </Scatter> | |
| </ScatterChart> | |
| </ResponsiveContainer> | |
| </div> | |
| </div> | |
| ); | |
| }) | |
| ) : ( | |
| <div className="bg-white p-8 rounded-3xl border border-slate-200 shadow-sm h-[700px] flex flex-col items-center justify-center text-slate-300"> | |
| <BarChart2 size={80} className="mb-4 opacity-20" /> | |
| <p>Upload data and configure axes to see the chart</p> | |
| </div> | |
| )} | |
| </div> | |
| </main> | |
| {/* Point Interaction Modal */} | |
| {selectedPoint && ( | |
| <div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"> | |
| <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200"> | |
| <div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50/50"> | |
| <h3 className="font-bold text-slate-800 truncate pr-4">Edit Point: {selectedPoint.label}</h3> | |
| <button onClick={() => setSelectedPoint(null)} className="p-1 hover:bg-slate-200 rounded-full transition-colors"> | |
| <X size={20} className="text-slate-500" /> | |
| </button> | |
| </div> | |
| <div className="p-6 space-y-6"> | |
| <div> | |
| <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-3 flex items-center gap-2"> | |
| <Palette size={14} /> | |
| Change Color | |
| </label> | |
| <div className="grid grid-cols-6 gap-3"> | |
| {['#6366f1', '#ef4444', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6', '#06b6d4', '#475569', '#000000', '#f97316', '#84cc16', '#a855f7'].map(color => ( | |
| <button | |
| key={color} | |
| onClick={() => { | |
| setPointOverrides(prev => ({ | |
| ...prev, | |
| [selectedPoint.id]: { ...prev[selectedPoint.id], color } | |
| })); | |
| }} | |
| className={cn( | |
| "w-10 h-10 rounded-full border-2 transition-all hover:scale-110", | |
| pointOverrides[selectedPoint.id]?.color === color || (!pointOverrides[selectedPoint.id]?.color && color === '#6366f1') | |
| ? "border-slate-900 scale-110 shadow-lg" | |
| : "border-transparent" | |
| )} | |
| style={{ backgroundColor: color }} | |
| > | |
| {(pointOverrides[selectedPoint.id]?.color === color || (!pointOverrides[selectedPoint.id]?.color && color === '#6366f1')) && ( | |
| <Check size={16} className="mx-auto text-white drop-shadow-md" /> | |
| )} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="pt-4 border-t border-slate-100"> | |
| <button | |
| onClick={() => { | |
| setPointOverrides(prev => ({ | |
| ...prev, | |
| [selectedPoint.id]: { ...prev[selectedPoint.id], hidden: true } | |
| })); | |
| setSelectedPoint(null); | |
| }} | |
| className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-red-50 text-red-600 hover:bg-red-100 rounded-xl font-semibold transition-colors" | |
| > | |
| <Trash2 size={18} /> | |
| Remove Point from Chart | |
| </button> | |
| </div> | |
| </div> | |
| <div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end"> | |
| <button | |
| onClick={() => setSelectedPoint(null)} | |
| className="px-6 py-2 bg-slate-900 text-white rounded-xl font-semibold hover:bg-slate-800 transition-colors" | |
| > | |
| Done | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {data.length > 0 && ( | |
| <section className="max-w-[1600px] mx-auto px-6"> | |
| <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden"> | |
| <div className="px-6 py-4 border-b border-slate-100 bg-slate-50/50"> | |
| <h3 className="text-sm font-bold text-slate-700">Data Preview (First 10 rows)</h3> | |
| </div> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-left text-sm border-collapse"> | |
| <thead> | |
| <tr className="bg-slate-50"> | |
| {columns.map(col => <th key={col} className="px-6 py-3 font-semibold text-slate-600 border-b border-slate-100">{col}</th>)} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {data.slice(0, 10).map((row, i) => ( | |
| <tr key={i} className="hover:bg-slate-50 transition-colors"> | |
| {columns.map(col => <td key={col} className="px-6 py-3 text-slate-500 border-b border-slate-100">{String(row[col] || '')}</td>)} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </section> | |
| )} | |
| </div> | |
| ); | |
| } | |