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([]); const [columns, setColumns] = useState([]); const [config, setConfig] = useState({ xAxisKey: '', yAxisKey: '', labelKey: '', categoryKey: '', groupByKeys: [], sizeKey: '', title: 'Campaign Performance Quadrant', }); const [pointOverrides, setPointOverrides] = useState>({}); const [selectedPoint, setSelectedPoint] = useState<{ id: string, label: string } | null>(null); const [isDragging, setIsDragging] = useState(false); const chartRef = useRef(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 = {}; 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(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 ( ); case 'diamond': return ; case 'square': return ; default: return ; } }; return (

Quadrant Chart Pro

Data Source

{ 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()} > e.target.files?.[0] && handleFileUpload(e.target.files[0])} /> 0 ? "w-6 h-6" : "w-10 h-10")} />

{data.length > 0 ? "Change File" : "Drop Excel file here"}

Configuration

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" />
{columns.map(col => ( ))} {columns.length === 0 &&

Upload data first

}
{multiChartData.length > 0 ? ( multiChartData.map((group, groupIndex) => { const groupStats = calculateStats(group.points); if (!groupStats) return null; return (

{config.title}

X-Axis: {config.xAxisKey} Y-Axis: {config.yAxisKey} {config.sizeKey && ( Size: {config.sizeKey} )} {config.groupByKeys.length > 0 && ( Group: {group.name} )}
{ if (active && payload && payload.length) { const d = payload[0].payload; return (

{d[config.labelKey]}

{config.xAxisKey} (X) {formatValue(d.x)}
{config.yAxisKey} (Y) {formatValue(d.y)}
{config.sizeKey && (
{config.sizeKey} (Size) {formatValue(d.z)}
)}
{columns.filter(c => c !== config.xAxisKey && c !== config.yAxisKey && c !== config.labelKey && c !== config.sizeKey && !c.startsWith('__')).slice(0, 5).map(col => (

{col}: {String(d[col] || '')}

))}

Click point to edit or remove

); } return null; }} /> {/* Quadrant Labels */} LOW {config.xAxisKey} / HIGH {config.yAxisKey} HIGH {config.xAxisKey} / HIGH {config.yAxisKey} LOW {config.xAxisKey} / LOW {config.yAxisKey} HIGH {config.xAxisKey} / LOW {config.yAxisKey} { if (data && data.payload) { setSelectedPoint({ id: data.payload.__id, label: data.payload[config.labelKey] }); } }} cursor="pointer" > {group.points.map((entry, index) => ( ))}
); }) ) : (

Upload data and configure axes to see the chart

)}
{/* Point Interaction Modal */} {selectedPoint && (

Edit Point: {selectedPoint.label}

{['#6366f1', '#ef4444', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6', '#06b6d4', '#475569', '#000000', '#f97316', '#84cc16', '#a855f7'].map(color => ( ))}
)} {data.length > 0 && (

Data Preview (First 10 rows)

{columns.map(col => )} {data.slice(0, 10).map((row, i) => ( {columns.map(col => )} ))}
{col}
{String(row[col] || '')}
)}
); }