datajunction-ui
Version:
DataJunction UI
812 lines (761 loc) • 25.4 kB
JSX
import { useState, useCallback, useMemo, useEffect, useRef, memo } from 'react';
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
import foundation from 'react-syntax-highlighter/dist/esm/styles/hljs/foundation';
import sql from 'react-syntax-highlighter/dist/esm/languages/hljs/sql';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
SyntaxHighlighter.registerLanguage('sql', sql);
const SERIES_COLORS = [
'#60a5fa',
'#34d399',
'#fbbf24',
'#f87171',
'#a78bfa',
'#22d3ee',
'#fb923c',
];
// Threshold for switching from multi-series to small multiples
const SMALL_MULTIPLES_THRESHOLD = 2;
function isTimeColumn(col) {
const name = col.name.toLowerCase();
const type = (col.type || '').toLowerCase();
return (
type.includes('date') ||
type.includes('timestamp') ||
type.includes('time') ||
name === 'date' ||
name === 'day' ||
name === 'week' ||
name === 'month' ||
name === 'year' ||
name === 'quarter' ||
name.endsWith('_date') ||
name.endsWith('_day') ||
name.endsWith('_week') ||
name.endsWith('_month') ||
name.endsWith('_year') ||
name.endsWith('_at') ||
name.startsWith('date') ||
name.startsWith('ds')
);
}
function isNumericColumn(col) {
const type = (col.type || '').toLowerCase();
return (
type.includes('int') ||
type.includes('float') ||
type.includes('double') ||
type.includes('decimal') ||
type.includes('numeric') ||
type.includes('real') ||
type.includes('number')
);
}
function detectChartConfig(columns, rows) {
if (!columns.length || !rows.length) return null;
const tagged = columns.map((c, i) => ({ ...c, idx: i }));
const timeCols = tagged.filter(c => isTimeColumn(c));
const numericCols = tagged.filter(c => isNumericColumn(c));
const nonNumericCols = tagged.filter(c => !isNumericColumn(c));
const nonTimeCatCols = nonNumericCols.filter(c => !isTimeColumn(c));
// Time dimension present → line chart
if (timeCols.length > 0) {
const xCol = timeCols[0];
const metricCols = numericCols.filter(c => c.idx !== xCol.idx);
if (metricCols.length > 0) {
// Exactly one categorical dim + one metric → pivot as series
if (nonTimeCatCols.length === 1) {
return {
type: 'line',
xCol,
groupByCol: nonTimeCatCols[0],
metricCols,
};
}
return { type: 'line', xCol, metricCols };
}
}
// Categorical dimension(s) → bar chart
if (nonTimeCatCols.length > 0 && numericCols.length > 0) {
if (nonTimeCatCols.length === 1) {
return { type: 'bar', xCol: nonTimeCatCols[0], metricCols: numericCols };
}
if (nonTimeCatCols.length === 2) {
return {
type: 'bar',
xCol: nonTimeCatCols[0],
groupByCol: nonTimeCatCols[1],
metricCols: numericCols,
};
}
// 3+ cats → fall back to first cat as x-axis
return { type: 'bar', xCol: nonTimeCatCols[0], metricCols: numericCols };
}
// Multiple numeric columns, no string/time dim → treat first as x-axis (line)
if (numericCols.length > 1) {
const xCol = numericCols[0];
const metricCols = numericCols.slice(1);
return { type: 'line', xCol, metricCols };
}
// Scalar result → KPI cards
if (numericCols.length > 0) {
return { type: 'kpi', metricCols: numericCols };
}
return null;
}
const MAX_GROUP_VALUES = 50;
function buildPivotedData(rows, columns, xCol, groupByCol, metricCols) {
const xIdx = xCol.idx;
const gIdx = groupByCol.idx;
const metricIdxs = metricCols.map(c => c.idx);
// Pass 1: group totals only (cheap — just numbers)
const groupTotals = {};
for (const row of rows) {
const gVal = String(row[gIdx] ?? '(null)');
groupTotals[gVal] =
(groupTotals[gVal] || 0) + (Number(row[metricIdxs[0]]) || 0);
}
const groupValues = Object.entries(groupTotals)
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_GROUP_VALUES)
.map(([k]) => k);
const groupSet = new Set(groupValues);
// Pass 2: build ALL metric pivot maps in one sweep
const pivotMaps = metricCols.map(() => ({}));
for (const row of rows) {
const gVal = String(row[gIdx] ?? '(null)');
if (!groupSet.has(gVal)) continue;
const xVal = row[xIdx];
const mapKey = String(xVal ?? '(null)');
for (let m = 0; m < metricCols.length; m++) {
const pm = pivotMaps[m];
if (!pm[mapKey]) pm[mapKey] = { [xCol.name]: xVal };
pm[mapKey][gVal] = row[metricIdxs[m]];
}
}
const sortFn = (a, b) => {
const av = a[xCol.name];
const bv = b[xCol.name];
if (av === null && bv === null) return 0;
if (av === null) return 1;
if (bv === null) return -1;
if (typeof av === 'number' && typeof bv === 'number') return av - bv;
return String(av).localeCompare(String(bv));
};
const pivotedByMetric = metricCols.map((metricCol, m) => {
const pivoted = Object.values(pivotMaps[m]);
pivoted.sort(sortFn);
return { col: metricCol, data: pivoted };
});
return { pivotedByMetric, groupValues };
}
function buildChartData(columns, rows, xCol) {
const data = rows.map(row => {
const obj = {};
columns.forEach((col, i) => {
obj[col.name] = row[i];
});
return obj;
});
const key = xCol.name;
data.sort((a, b) => {
const av = a[key];
const bv = b[key];
if (av === null && bv === null) return 0;
if (av === null) return 1;
if (bv === null) return -1;
if (typeof av === 'number' && typeof bv === 'number') return av - bv;
return String(av).localeCompare(String(bv));
});
return data;
}
function formatYAxis(value) {
if (Math.abs(value) >= 1_000_000_000)
return (value / 1_000_000_000).toFixed(1) + 'B';
if (Math.abs(value) >= 1_000_000) return (value / 1_000_000).toFixed(1) + 'M';
if (Math.abs(value) >= 1_000) return (value / 1_000).toFixed(1) + 'K';
return value;
}
function KpiCards({ rows, metricCols }) {
const row = rows[0] || [];
return (
<div className="kpi-cards">
{metricCols.map(col => {
const val = row[col.idx];
const formatted =
val == null
? '—'
: typeof val === 'number'
? val.toLocaleString(undefined, { maximumFractionDigits: 4 })
: String(val);
return (
<div key={col.idx} className="kpi-card">
<div className="kpi-label">{col.name}</div>
<div className="kpi-value">{formatted}</div>
{col.type && <div className="kpi-type">{col.type}</div>}
</div>
);
})}
</div>
);
}
const CHART_MARGIN = { top: 8, right: 24, left: 8, bottom: 40 };
const AXIS_TICK = { fontSize: 11, fill: '#64748b' };
const TOOLTIP_STYLE = { fontSize: 12, border: '1px solid #e2e8f0' };
const TOOLTIP_WRAPPER_STYLE = { zIndex: 9999 };
const Chart = memo(function Chart({
type,
xCol,
metricCols,
seriesKeys,
chartData,
seriesColors = SERIES_COLORS,
}) {
const showDots = chartData.length <= 60;
const keys = seriesKeys || metricCols.map(c => c.name);
const xInterval =
type === 'line'
? 'preserveStartEnd'
: Math.max(0, Math.ceil(chartData.length / 20) - 1);
if (type === 'line') {
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={CHART_MARGIN}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
dataKey={xCol.name}
tick={AXIS_TICK}
angle={-35}
textAnchor="end"
interval={xInterval}
/>
<YAxis tickFormatter={formatYAxis} tick={AXIS_TICK} width={60} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
wrapperStyle={TOOLTIP_WRAPPER_STYLE}
/>
{keys.map((key, i) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={seriesColors[i % seriesColors.length]}
dot={showDots}
strokeWidth={2}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={CHART_MARGIN}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
dataKey={xCol.name}
tick={AXIS_TICK}
angle={-35}
textAnchor="end"
interval={xInterval}
/>
<YAxis tickFormatter={formatYAxis} tick={AXIS_TICK} width={60} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
{keys.map((key, i) => (
<Bar
key={key}
dataKey={key}
fill={seriesColors[i % seriesColors.length]}
isAnimationActive={false}
/>
))}
</BarChart>
</ResponsiveContainer>
);
});
const ChartView = memo(function ChartView({
chartConfig,
chartData,
pivotedByMetric,
groupValues,
rows,
columns,
}) {
if (!chartConfig) {
return <div className="chart-no-data">No chartable data detected</div>;
}
if (chartConfig.type === 'kpi') {
return <KpiCards rows={rows} metricCols={chartConfig.metricCols} />;
}
const { type, xCol, metricCols } = chartConfig;
// Pivoted multi-metric: small multiples, one per metric, each with groupBy series
if (pivotedByMetric && pivotedByMetric.length > 1) {
return (
<div className="small-multiples">
{pivotedByMetric.map(({ col, data }) => (
<div key={col.idx} className="small-multiple">
<div className="small-multiple-label">{col.name}</div>
<div className="small-multiple-chart">
<Chart
type={type}
xCol={xCol}
metricCols={[col]}
seriesKeys={groupValues}
chartData={data}
/>
</div>
</div>
))}
</div>
);
}
// Pivoted single-metric: one chart with groupBy as series
if (groupValues) {
return (
<Chart
type={type}
xCol={xCol}
metricCols={metricCols}
seriesKeys={groupValues}
chartData={chartData}
/>
);
}
// No groupBy: standard small multiples or single chart
if (metricCols.length > SMALL_MULTIPLES_THRESHOLD) {
return (
<div className="small-multiples">
{metricCols.map((col, i) => (
<div key={col.idx} className="small-multiple">
<div className="small-multiple-label">{col.name}</div>
<div className="small-multiple-chart">
<Chart
type={type}
xCol={xCol}
metricCols={[col]}
chartData={chartData}
seriesColors={[SERIES_COLORS[i % SERIES_COLORS.length]]}
/>
</div>
</div>
))}
</div>
);
}
return (
<Chart
type={type}
xCol={xCol}
metricCols={metricCols}
chartData={chartData}
/>
);
});
/**
* ResultsView - Displays query results with SQL and data table
* Layout: SQL in top ~25%, results in bottom ~75% with Table/Chart tabs
*/
export function ResultsView({
sql: sqlQuery,
results,
loading,
error,
elapsedTime,
onBackToPlan,
selectedMetrics,
selectedDimensions,
filters,
dialect,
cubeName,
availability,
links,
}) {
const [copied, setCopied] = useState(false);
const [sortColumn, setSortColumn] = useState(null);
const [sortDirection, setSortDirection] = useState('asc');
const [activeTab, setActiveTab] = useState('table');
// Resizable SQL pane height (px); null = use CSS default
const [sqlPaneHeight, setSqlPaneHeight] = useState(null);
const resultsPanesRef = useRef(null);
const handleSqlResizerMouseDown = useCallback(
e => {
e.preventDefault();
const container = resultsPanesRef.current;
if (!container) return;
const startY = e.clientY;
const startHeight = sqlPaneHeight ?? container.offsetHeight * 0.333;
const onMouseMove = moveEvent => {
const delta = moveEvent.clientY - startY;
const newHeight = Math.max(
80,
Math.min(container.offsetHeight * 0.8, startHeight + delta),
);
setSqlPaneHeight(newHeight);
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
},
[sqlPaneHeight],
);
const handleCopySql = useCallback(() => {
if (sqlQuery) {
navigator.clipboard.writeText(sqlQuery);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [sqlQuery]);
const columns = useMemo(
() => results?.results?.[0]?.columns || [],
[results],
);
const rows = useMemo(() => results?.results?.[0]?.rows || [], [results]);
const rowCount = rows.length;
const handleSort = useCallback(
columnIndex => {
if (sortColumn === columnIndex) {
setSortDirection(d => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortColumn(columnIndex);
setSortDirection('asc');
}
},
[sortColumn],
);
const sortedRows = useMemo(() => {
if (sortColumn === null) return rows;
return [...rows].sort((a, b) => {
const aVal = a[sortColumn];
const bVal = b[sortColumn];
if (aVal === null && bVal === null) return 0;
if (aVal === null) return 1;
if (bVal === null) return -1;
let cmp;
if (typeof aVal === 'number' && typeof bVal === 'number') {
cmp = aVal - bVal;
} else {
cmp = String(aVal).localeCompare(String(bVal));
}
return sortDirection === 'asc' ? cmp : -cmp;
});
}, [rows, sortColumn, sortDirection]);
const chartConfig = useMemo(
() => detectChartConfig(columns, rows),
[columns, rows],
);
const { chartData, pivotedByMetric, groupValues } = useMemo(() => {
if (!chartConfig || !chartConfig.xCol)
return { chartData: [], pivotedByMetric: null, groupValues: null };
if (chartConfig.groupByCol) {
const { pivotedByMetric, groupValues } = buildPivotedData(
rows,
columns,
chartConfig.xCol,
chartConfig.groupByCol,
chartConfig.metricCols,
);
return {
chartData: pivotedByMetric[0].data,
pivotedByMetric,
groupValues,
};
}
return {
chartData: buildChartData(columns, rows, chartConfig.xCol),
pivotedByMetric: null,
groupValues: null,
};
}, [columns, rows, chartConfig]);
const canChart = rowCount > 0;
// Reset to table view if new results can't be charted
useEffect(() => {
if (!canChart && activeTab === 'chart') setActiveTab('table');
}, [canChart, activeTab]);
return (
<div className="results-view">
{/* Header */}
<div className="results-header">
<button className="back-to-plan-btn" onClick={onBackToPlan}>
<span className="back-arrow">←</span>
<span>Back to Plan</span>
</button>
<div className="results-summary">
{loading ? (
<span className="results-loading-text">Running query...</span>
) : error ? (
<span className="results-error-text">Query failed</span>
) : (
<>
<span className="results-count">
{rowCount.toLocaleString()} rows
</span>
{elapsedTime != null && (
<span className="results-time">{elapsedTime.toFixed(2)}s</span>
)}
</>
)}
</div>
</div>
{/* Two-pane layout: SQL (top) + Results (bottom) */}
<div className="results-panes" ref={resultsPanesRef}>
{/* SQL Pane */}
<div
className="sql-pane"
style={
sqlPaneHeight != null
? { flex: `0 0 ${sqlPaneHeight}px`, maxHeight: sqlPaneHeight }
: undefined
}
>
<div className="sql-pane-header">
<span className="sql-pane-title">SQL Query</span>
{cubeName && (
<span
className="sql-pane-info"
title={
availability
? `Querying materialized dataset ${[
availability.catalog,
availability.schema_,
availability.table,
]
.filter(Boolean)
.join('.')}, last refreshed for data through ${new Date(
availability.validThroughTs,
).toLocaleDateString()}`
: undefined
}
>
<span className="info-materialized">
<span style={{ fontFamily: 'sans-serif' }}>⚡</span> Using
materialized cube
</span>
{availability?.validThroughTs && (
<>
{' · Valid thru '}
{new Date(availability.validThroughTs).toLocaleDateString()}
</>
)}
</span>
)}
<button
className={`copy-btn ${copied ? 'copied' : ''}`}
onClick={handleCopySql}
disabled={!sqlQuery}
>
{copied ? '✓ Copied' : 'Copy'}
</button>
</div>
<div className="sql-pane-content">
{sqlQuery ? (
<SyntaxHighlighter
language="sql"
style={foundation}
wrapLongLines={true}
customStyle={{
margin: 0,
padding: '12px 16px',
background: '#f8fafc',
fontSize: '12px',
height: '100%',
overflow: 'auto',
}}
>
{sqlQuery}
</SyntaxHighlighter>
) : (
<div className="sql-pane-empty">Generating SQL...</div>
)}
</div>
</div>
{/* Horizontal resizer between SQL and results panes */}
<div
className="horizontal-resizer"
onMouseDown={handleSqlResizerMouseDown}
title="Drag to resize"
/>
{/* Results Pane */}
<div className="results-pane">
{loading ? (
<div className="results-loading">
<div className="loading-spinner large" />
<span>Executing query...</span>
<span className="loading-hint">
Querying {selectedMetrics.length} metric(s) with{' '}
{selectedDimensions.length} dimension(s)
</span>
{links && links.length > 0 && (
<span className="results-links">
{links.map((link, idx) => (
<a
key={idx}
href={link}
target="_blank"
rel="noopener noreferrer"
className="results-link"
>
View query ↗
</a>
))}
</span>
)}
</div>
) : error ? (
<div className="results-error">
<div className="error-icon">⚠</div>
<h3>Query Failed</h3>
<p className="error-message">{error}</p>
<button
className="action-btn action-btn-primary"
onClick={onBackToPlan}
>
Back to Plan
</button>
</div>
) : (
<div className="results-table-section">
{/* Tab bar */}
<div className="results-tabs-bar">
<div className="results-tabs">
<button
className={`results-tab ${
activeTab === 'table' ? 'active' : ''
}`}
onClick={() => setActiveTab('table')}
>
Table
</button>
<button
className={`results-tab ${
activeTab === 'chart' ? 'active' : ''
} ${!canChart ? 'disabled' : ''}`}
onClick={() => canChart && setActiveTab('chart')}
title={
!canChart
? 'No chartable data (need at least one numeric column)'
: undefined
}
>
Chart
</button>
</div>
<div className="results-tabs-meta">
<span className="table-count">
{rowCount.toLocaleString()} rows
</span>
{filters && filters.length > 0 && (
<div className="table-filters">
{filters.map((filter, idx) => (
<span key={idx} className="filter-chip small">
{filter}
</span>
))}
</div>
)}
</div>
</div>
{/* Content */}
{activeTab === 'table' ? (
<div className="results-table-wrapper">
{rowCount === 0 ? (
<div className="table-empty">
<p>No results returned</p>
</div>
) : (
<table className="results-table">
<thead>
<tr>
{columns.map((col, idx) => (
<th
key={idx}
title={col.semantic_name || col.name}
onClick={() => handleSort(idx)}
className={sortColumn === idx ? 'sorted' : ''}
>
<span className="col-header-content">
{col.name}
<span className="sort-arrows">
<span
className={`sort-arrow up ${
sortColumn === idx &&
sortDirection === 'asc'
? 'active'
: ''
}`}
>
▲
</span>
<span
className={`sort-arrow down ${
sortColumn === idx &&
sortDirection === 'desc'
? 'active'
: ''
}`}
>
▼
</span>
</span>
</span>
<span className="col-type">{col.type}</span>
</th>
))}
</tr>
</thead>
<tbody>
{sortedRows.map((row, rowIdx) => (
<tr key={rowIdx}>
{row.map((cell, cellIdx) => (
<td key={cellIdx}>
{cell === null ? (
<span className="null-value">NULL</span>
) : (
String(cell)
)}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
) : (
<div className="results-chart-wrapper">
{canChart ? (
<ChartView
chartConfig={chartConfig}
chartData={chartData}
pivotedByMetric={pivotedByMetric}
groupValues={groupValues}
rows={rows}
columns={columns}
/>
) : (
<div className="chart-no-data">
No chartable data detected
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
export default ResultsView;