import React, { useState, useEffect, useMemo } from "react"; // Helper checking if >80% of values are numbers export function isNumeric(values) { if (!values || values.length === 0) return false; const numCount = values.filter(v => typeof v === "number" && !isNaN(v)).length; return (numCount / values.length) > 0.8; } // Helper checking if >80% of values match YYYY-MM-DD format export function looksLikeDate(values) { if (!values || values.length === 0) return false; const dateRegex = /^\d{4}-\d{2}-\d{2}/; const dateCount = values.filter(v => typeof v === "string" && dateRegex.test(v)).length; return (dateCount / values.length) > 0.8; } export default function ChartView({ result }) { const [mounted, setMounted] = useState(false); const [hoveredIdx, setHoveredIdx] = useState(null); useEffect(() => { const timer = setTimeout(() => setMounted(true), 50); return () => clearTimeout(timer); }, [result]); const chartMeta = useMemo(() => { if (!result || !result.results || !result.results.columns || !result.results.rows || result.results.rows.length === 0) { return null; } const columns = result.results.columns; const rows = result.results.rows; let chartType = null; let xIdx = 0; let yIdx = 1; // Detect column roles if (columns.length >= 2) { const col1Values = rows.map(r => r[0]); const col2Values = rows.map(r => r[1]); const col1IsNumeric = isNumeric(col1Values); const col2IsNumeric = isNumeric(col2Values); const col1IsDate = looksLikeDate(col1Values); if (!col1IsNumeric && col2IsNumeric) { xIdx = 0; yIdx = 1; if (col1IsDate) { chartType = "LINE"; } else if (rows.length <= 6) { chartType = "PIE"; } else { chartType = "BAR"; } } else if (col1IsNumeric && !col2IsNumeric) { xIdx = 1; yIdx = 0; const col2IsDate = looksLikeDate(col2Values); if (col2IsDate) { chartType = "LINE"; } else if (rows.length <= 6) { chartType = "PIE"; } else { chartType = "BAR"; } } else if (columns.length >= 3) { // 3+ columns. Look for first text/date and first numeric let foundX = -1; let foundY = -1; for (let i = 0; i < columns.length; i++) { const vals = rows.map(r => r[i]); if (isNumeric(vals)) { if (foundY === -1) foundY = i; } else { if (foundX === -1) foundX = i; } } if (foundX !== -1 && foundY !== -1) { xIdx = foundX; yIdx = foundY; chartType = "BAR"; } } } if (!chartType) return null; // Format data points (limit to first 20 rows for layout readability) const points = rows.slice(0, 20).map((row, idx) => { const rawX = row[xIdx]; const rawY = row[yIdx]; return { xVal: rawX === null || rawX === undefined ? "NULL" : String(rawX), yVal: Number(rawY || 0), originalRowIdx: idx }; }); return { chartType, points, xLabel: columns[xIdx], yLabel: columns[yIdx], totalCount: rows.length }; }, [result]); if (!chartMeta) { return (
Chart not available for this query type. View data in the Table tab.
); } const { chartType, points, xLabel, yLabel, totalCount } = chartMeta; // Format large numbers cleanly const formatNumber = (num) => { if (Math.abs(num) >= 1_000_000) return (num / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"; if (Math.abs(num) >= 1_000) return (num / 1_000).toFixed(1).replace(/\.0$/, "") + "K"; if (num % 1 !== 0) return num.toFixed(2); return num.toString(); }; // --- 1. BAR CHART RENDERER --- if (chartType === "BAR") { const svgWidth = 600; const svgHeight = 350; const padding = { top: 40, right: 30, bottom: 60, left: 65 }; const graphWidth = svgWidth - padding.left - padding.right; const graphHeight = svgHeight - padding.top - padding.bottom; const yValues = points.map(p => p.yVal); const maxY = Math.max(...yValues, 0); const minY = Math.min(...yValues, 0); const upperLimit = maxY === 0 ? 10 : maxY * 1.15; // 15% top padding // Grid ticks (5 marks) const tickCount = 4; const ticks = Array.from({ length: tickCount + 1 }).map((_, i) => { const val = (upperLimit / tickCount) * i; const y = padding.top + graphHeight - (val / upperLimit) * graphHeight; return { val, y }; }); const colWidth = graphWidth / points.length; const barPadding = 0.35; const barWidth = colWidth * (1 - barPadding); const barOffset = colWidth * (barPadding / 2); return (
{/* Grid lines */} {ticks.map((t, idx) => ( {formatNumber(t.val)} ))} {/* Bars */} {points.map((pt, idx) => { const x = padding.left + idx * colWidth + barOffset; const barHeightVal = (pt.yVal / upperLimit) * graphHeight; // Mount animation height const animatedHeight = mounted ? barHeightVal : 0; const y = padding.top + graphHeight - animatedHeight; // Shorten label const displayLabel = pt.xVal.length > 10 ? pt.xVal.substring(0, 10) + "..." : pt.xVal; const isHovered = hoveredIdx === idx; return ( setHoveredIdx(idx)} onMouseLeave={() => setHoveredIdx(null)} > {/* Bar */} {/* Label on top of bar */} {mounted && isHovered && ( {pt.yVal.toLocaleString()} )} {/* X axis labels (slanted for readability) */} {displayLabel} ); })} {/* Axis lines */} {/* Title labels */} {yLabel} by {xLabel} {totalCount > 20 && (first 20 rows)}
); } // --- 2. LINE CHART RENDERER --- if (chartType === "LINE") { const svgWidth = 600; const svgHeight = 350; const padding = { top: 40, right: 40, bottom: 50, left: 65 }; const graphWidth = svgWidth - padding.left - padding.right; const graphHeight = svgHeight - padding.top - padding.bottom; const yValues = points.map(p => p.yVal); const maxY = Math.max(...yValues, 0); const upperLimit = maxY === 0 ? 10 : maxY * 1.15; // Grid ticks (5 marks) const tickCount = 4; const ticks = Array.from({ length: tickCount + 1 }).map((_, i) => { const val = (upperLimit / tickCount) * i; const y = padding.top + graphHeight - (val / upperLimit) * graphHeight; return { val, y }; }); // Compute coordinates const segmentWidth = graphWidth / Math.max(points.length - 1, 1); const coords = points.map((pt, idx) => { const x = padding.left + idx * segmentWidth; const y = padding.top + graphHeight - (pt.yVal / upperLimit) * graphHeight; return { x, y, pt, idx }; }); // Generate polyline string points const polylinePoints = coords.map(c => `${c.x},${c.y}`).join(" "); // Generate gradient fill area path points const areaPath = coords.length > 0 ? `M ${coords[0].x},${padding.top + graphHeight} ` + coords.map(c => `L ${c.x},${c.y}`).join(" ") + ` L ${coords[coords.length - 1].x},${padding.top + graphHeight} Z` : ""; return (
{/* Grid lines */} {ticks.map((t, idx) => ( {formatNumber(t.val)} ))} {/* Area under the line */} {mounted && areaPath && ( )} {/* Line string */} {mounted && polylinePoints && ( )} {/* Vertical crosshair on hover */} {hoveredIdx !== null && coords[hoveredIdx] && ( {/* Tooltip */} {coords[hoveredIdx].pt.xVal} {coords[hoveredIdx].pt.yVal.toLocaleString()} )} {/* Data point dots and hover trigger zones */} {coords.map((c, idx) => ( {/* Invisible large target for easier hover */} setHoveredIdx(idx)} onMouseLeave={() => setHoveredIdx(null)} /> {/* X axis labels (limited spacing) */} {(points.length < 8 || idx % 2 === 0) && ( {c.pt.xVal} )} ))} {/* Axis lines */} {/* Title labels */} {yLabel} trend over {xLabel} {totalCount > 20 && (first 20 rows)}
); } // --- 3. PIE CHART RENDERER --- if (chartType === "PIE") { const svgWidth = 400; const svgHeight = 300; const cx = 130; const cy = 150; const r = 95; const colors = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"]; const yValues = points.map(p => p.yVal); const sum = yValues.reduce((a, b) => a + b, 0); let currentAngle = -Math.PI / 2; // Start at 12 o'clock const slices = points.map((pt, idx) => { const val = pt.yVal; const pct = sum === 0 ? 0 : val / sum; const angle = pct * 2 * Math.PI; const startAngle = currentAngle; const endAngle = currentAngle + angle; currentAngle = endAngle; const midAngle = (startAngle + endAngle) / 2; return { pt, pct, startAngle, endAngle, midAngle, color: colors[idx % colors.length], idx }; }); return (
{/* Slices */} {sum > 0 ? ( slices.map((slice, idx) => { const x1 = cx + r * Math.cos(slice.startAngle); const y1 = cy + r * Math.sin(slice.startAngle); const x2 = cx + r * Math.cos(slice.endAngle); const y2 = cy + r * Math.sin(slice.endAngle); const largeArcFlag = slice.pct > 0.5 ? 1 : 0; const isHovered = hoveredIdx === idx; // Arc path: Move to center, Line to start boundary, Arc to end boundary, close path let d = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArcFlag} 1 ${x2} ${y2} Z`; if (slice.pct >= 0.999) { // Draw a circle if single slice dominates d = `M ${cx} ${cy - r} A ${r} ${r} 0 1 1 ${cx - 0.01} ${cy - r} Z`; } // Displace slice on hover const dx = isHovered ? 8 * Math.cos(slice.midAngle) : 0; const dy = isHovered ? 8 * Math.sin(slice.midAngle) : 0; return ( setHoveredIdx(idx)} onMouseLeave={() => setHoveredIdx(null)} style={{ transform: `translate(${dx}px, ${dy}px)`, transition: "transform 0.2s ease-out", cursor: "pointer" }} /> ); }) ) : ( )} {/* Legend keys on the right */} {slices.map((slice, idx) => { const displayLabel = slice.pt.xVal.length > 12 ? slice.pt.xVal.substring(0, 12) + "..." : slice.pt.xVal; const pctText = (slice.pct * 100).toFixed(1) + "%"; const isHovered = hoveredIdx === idx; return ( setHoveredIdx(idx)} onMouseLeave={() => setHoveredIdx(null)} > {displayLabel} ({pctText}) ); })} {/* Title labels */} {yLabel} share by {xLabel}
); } return null; }