feat: auto-chart detection in SQL Explorer
Adds an '✨ Auto' button that analyzes query results and picks the
best chart type and column mapping:
- Date/time column + numeric → line chart (time series)
- Categorical + numeric → bar chart (categories)
- Two numeric columns → scatter plot
- Shows detected type and column names as a label
Click Auto, run any query, and it figures out the rest.
This commit is contained in:
@@ -29,7 +29,7 @@ interface SchemaInfo {
|
|||||||
tables: Array<{ name: string; columns: Array<{ name: string; type: string; nullable?: boolean }> }>;
|
tables: Array<{ name: string; columns: Array<{ name: string; type: string; nullable?: boolean }> }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChartType = 'none' | 'bar' | 'line' | 'scatter';
|
type ChartType = 'none' | 'auto' | 'bar' | 'line' | 'scatter';
|
||||||
|
|
||||||
export function SqlExplorerPage() {
|
export function SqlExplorerPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -85,14 +85,74 @@ export function SqlExplorerPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Build chart data from result
|
// Build chart data from result
|
||||||
|
// Auto-detect best chart type and columns when chartType === 'auto'
|
||||||
|
const autoChart = (() => {
|
||||||
|
if (!result || result.columns.length < 2 || result.row_count === 0) {
|
||||||
|
return { type: 'bar' as const, x: 0, y: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cols = result.columns;
|
||||||
|
const rows = result.rows;
|
||||||
|
|
||||||
|
// Classify each column as numeric or categorical
|
||||||
|
const isNumeric = cols.map((_, ci) => {
|
||||||
|
const sample = rows.slice(0, 10);
|
||||||
|
const numCount = sample.filter((r) => {
|
||||||
|
const v = r[ci];
|
||||||
|
return v != null && !isNaN(parseFloat(String(v))) && String(v).trim() !== '';
|
||||||
|
}).length;
|
||||||
|
return numCount > sample.length * 0.7;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if a column looks like a date/time
|
||||||
|
const isDateLike = cols.map((c, ci) => {
|
||||||
|
const name = c.name.toLowerCase();
|
||||||
|
if (name.includes('date') || name.includes('time') || name.includes('_at') || name.includes('created') || name.includes('generated')) return true;
|
||||||
|
const sample = String(rows[0]?.[ci] ?? '');
|
||||||
|
return /^\d{4}-\d{2}/.test(sample);
|
||||||
|
});
|
||||||
|
|
||||||
|
const numericCols = cols.map((_, i) => i).filter((i) => isNumeric[i]);
|
||||||
|
const categoricalCols = cols.map((_, i) => i).filter((i) => !isNumeric[i]);
|
||||||
|
const dateCols = cols.map((_, i) => i).filter((i) => isDateLike[i]);
|
||||||
|
|
||||||
|
// Heuristics:
|
||||||
|
// 1. Date column + numeric → line chart (time series)
|
||||||
|
// 2. Categorical + numeric → bar chart (categories)
|
||||||
|
// 3. Two numeric columns → scatter plot
|
||||||
|
// 4. Fallback → bar chart with first categorical as X, first numeric as Y
|
||||||
|
|
||||||
|
if (dateCols.length > 0 && numericCols.length > 0) {
|
||||||
|
const xIdx = dateCols[0];
|
||||||
|
const yIdx = numericCols.find((i) => i !== xIdx) ?? numericCols[0];
|
||||||
|
return { type: 'line' as const, x: xIdx, y: yIdx };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoricalCols.length > 0 && numericCols.length > 0) {
|
||||||
|
return { type: 'bar' as const, x: categoricalCols[0], y: numericCols[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numericCols.length >= 2) {
|
||||||
|
return { type: 'scatter' as const, x: numericCols[0], y: numericCols[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: first col as X, second as Y, bar chart
|
||||||
|
return { type: 'bar' as const, x: 0, y: Math.min(1, cols.length - 1) };
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Resolve effective chart settings (auto overrides manual selections)
|
||||||
|
const effectiveChartType = chartType === 'auto' ? autoChart.type : chartType;
|
||||||
|
const effectiveXCol = chartType === 'auto' ? autoChart.x : xCol;
|
||||||
|
const effectiveYCol = chartType === 'auto' ? autoChart.y : yCol;
|
||||||
|
|
||||||
const chartData = result && result.columns.length >= 2
|
const chartData = result && result.columns.length >= 2
|
||||||
? result.rows.map((row) => {
|
? result.rows.map((row) => {
|
||||||
const yRaw = row[yCol];
|
const yRaw = row[effectiveYCol];
|
||||||
const yNum = yRaw != null ? parseFloat(String(yRaw)) : NaN;
|
const yNum = yRaw != null ? parseFloat(String(yRaw)) : NaN;
|
||||||
return {
|
return {
|
||||||
x: row[xCol],
|
x: row[effectiveXCol],
|
||||||
y: isNaN(yNum) ? 0 : yNum,
|
y: isNaN(yNum) ? 0 : yNum,
|
||||||
label: String(row[xCol] ?? ''),
|
label: String(row[effectiveXCol] ?? ''),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
@@ -249,16 +309,16 @@ export function SqlExplorerPage() {
|
|||||||
<Card className="shrink-0">
|
<Card className="shrink-0">
|
||||||
<div className="mb-2 flex items-center gap-3">
|
<div className="mb-2 flex items-center gap-3">
|
||||||
<span className="text-xs text-gray-500">Chart:</span>
|
<span className="text-xs text-gray-500">Chart:</span>
|
||||||
{(['none', 'bar', 'line', 'scatter'] as ChartType[]).map((ct) => (
|
{(['none', 'auto', 'bar', 'line', 'scatter'] as ChartType[]).map((ct) => (
|
||||||
<button
|
<button
|
||||||
key={ct}
|
key={ct}
|
||||||
onClick={() => setChartType(ct)}
|
onClick={() => setChartType(ct)}
|
||||||
className={`rounded px-2 py-0.5 text-xs ${chartType === ct ? 'bg-brand-600 text-white' : 'text-gray-400 hover:bg-surface-800'}`}
|
className={`rounded px-2 py-0.5 text-xs ${chartType === ct ? 'bg-brand-600 text-white' : 'text-gray-400 hover:bg-surface-800'}`}
|
||||||
>
|
>
|
||||||
{ct === 'none' ? 'Off' : ct}
|
{ct === 'none' ? 'Off' : ct === 'auto' ? '✨ Auto' : ct}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{chartType !== 'none' && (
|
{chartType !== 'none' && chartType !== 'auto' && (
|
||||||
<>
|
<>
|
||||||
<select value={xCol} onChange={(e) => setXCol(Number(e.target.value))} className="rounded border border-surface-700 bg-surface-900 px-1 py-0.5 text-xs text-gray-300" aria-label="X axis column">
|
<select value={xCol} onChange={(e) => setXCol(Number(e.target.value))} className="rounded border border-surface-700 bg-surface-900 px-1 py-0.5 text-xs text-gray-300" aria-label="X axis column">
|
||||||
{result.columns.map((c, i) => <option key={i} value={i}>{c.name}</option>)}
|
{result.columns.map((c, i) => <option key={i} value={i}>{c.name}</option>)}
|
||||||
@@ -269,10 +329,15 @@ export function SqlExplorerPage() {
|
|||||||
</select>
|
</select>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{chartType === 'auto' && result && (
|
||||||
|
<span className="text-[10px] text-gray-500">
|
||||||
|
{autoChart.type} · X: {result.columns[autoChart.x]?.name} · Y: {result.columns[autoChart.y]?.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{chartType !== 'none' && chartData.length > 0 && (
|
{chartType !== 'none' && chartData.length > 0 && (
|
||||||
<ResponsiveContainer width="100%" height={250}>
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
{chartType === 'bar' ? (
|
{effectiveChartType === 'bar' ? (
|
||||||
<BarChart data={chartData}>
|
<BarChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
<XAxis dataKey="label" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
<XAxis dataKey="label" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||||
@@ -280,7 +345,7 @@ export function SqlExplorerPage() {
|
|||||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||||
<Bar dataKey="y" fill="#3b82f6" />
|
<Bar dataKey="y" fill="#3b82f6" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
) : chartType === 'line' ? (
|
) : effectiveChartType === 'line' ? (
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
<XAxis dataKey="label" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
<XAxis dataKey="label" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||||
@@ -291,8 +356,8 @@ export function SqlExplorerPage() {
|
|||||||
) : (
|
) : (
|
||||||
<ScatterChart>
|
<ScatterChart>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
<XAxis dataKey="x" tick={{ fill: '#6b7280', fontSize: 10 }} name={result.columns[xCol]?.name} />
|
<XAxis dataKey="x" tick={{ fill: '#6b7280', fontSize: 10 }} name={result.columns[effectiveXCol]?.name} />
|
||||||
<YAxis dataKey="y" tick={{ fill: '#6b7280', fontSize: 10 }} name={result.columns[yCol]?.name} />
|
<YAxis dataKey="y" tick={{ fill: '#6b7280', fontSize: 10 }} name={result.columns[effectiveYCol]?.name} />
|
||||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||||
<Scatter data={chartData} fill="#3b82f6" />
|
<Scatter data={chartData} fill="#3b82f6" />
|
||||||
</ScatterChart>
|
</ScatterChart>
|
||||||
|
|||||||
Reference in New Issue
Block a user