UNPKG

datajunction-ui

Version:
812 lines (761 loc) 25.4 kB
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;