From b52f5c930e5cf0006ef21bc9663cf7779b191dc6 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sat, 2 May 2026 10:37:47 -0400 Subject: [PATCH] Pivot inspector: toggle, resize, sort, totals, filter fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Click cell to open inspector pane; click same cell again to close (toggle). Uses __ROW_PATH__ + column_names as key so it works on both sources and stacks. Removes event listener on view change to prevent listener accumulation. - Drag handle on left edge of inspector pane for resizing (min 240px) - Removed redundant cell-coordinates block; breadcrumb now inline in header - Sortable columns: click header to sort asc/desc with ▲/▼ indicator - Totals row: sums all-numeric columns, sticky at bottom - Derive missing split_by filters from column_names when Perspective omits them from detail.config.filter (fixes over-broad results on split_by views) Co-Authored-By: Claude Sonnet 4.6 --- ui/src/pages/Pivot.jsx | 183 ++++++++++++++++++++++++++++++----------- 1 file changed, 134 insertions(+), 49 deletions(-) diff --git a/ui/src/pages/Pivot.jsx b/ui/src/pages/Pivot.jsx index 027b064..cdd824c 100644 --- a/ui/src/pages/Pivot.jsx +++ b/ui/src/pages/Pivot.jsx @@ -86,11 +86,16 @@ export default function Pivot({ source }) { const tableRef = useRef() const allRowsRef = useRef([]) const expandDepthRef = useRef(null) + const lastClickKeyRef = useRef(null) + const perspClickHandlerRef = useRef(null) const [status, setStatus] = useState('idle') const [error, setError] = useState('') const [inspectedRows, setInspectedRows] = useState(null) const [clickDetail, setClickDetail] = useState(null) const [decimals, setDecimals] = useState(2) + const [paneWidth, setPaneWidth] = useState(384) + const [sortCol, setSortCol] = useState(null) + const [sortDir, setSortDir] = useState('asc') // View selector: source or a stack const [stacks, setStacks] = useState([]) @@ -138,6 +143,7 @@ export default function Pivot({ source }) { let cancelled = false setInspectedRows(null) setClickDetail(null) + lastClickKeyRef.current = null setActiveLayoutId(null) setShowSaveAs(false) allRowsRef.current = [] @@ -183,7 +189,7 @@ export default function Pivot({ source }) { return clean } - viewer.addEventListener('perspective-click', async (e) => { + perspClickHandlerRef.current = async (e) => { const detail = e.detail || {} const { row, column_names } = detail if (!row) return @@ -195,14 +201,39 @@ export default function Pivot({ source }) { const hasHierarchy = (config.group_by || []).length > 0 if (!hasHierarchy) return - setClickDetail({ row, config, column_names, eventFilters }) + // column_names encodes the full column path: [split_val_1, ..., split_val_N, measure] + // positionally matching config.split_by. Perspective may omit split_by coordinate + // filters from detail.config.filter, so derive any missing ones from column_names. + const splitByFields = config.split_by || [] + const coveredByEvent = new Set(eventFilters.filter(([, op]) => op === '==').map(([f]) => f)) + const derivedSplitFilters = splitByFields + .map((field, i) => { + if (coveredByEvent.has(field)) return null + const val = Array.isArray(column_names) && column_names[i] != null + ? String(column_names[i]) : null + return val != null ? [field, '==', val] : null + }) + .filter(Boolean) + const allFilters = [...eventFilters, ...derivedSplitFilters] + + // Same cell clicked again — toggle the pane closed. + // Key on row path + column names (from the raw event) rather than derived + // filters, which can vary between clicks on stack/expression views. + const clickKey = JSON.stringify({ p: row['__ROW_PATH__'], c: column_names }) + if (lastClickKeyRef.current === clickKey) { + lastClickKeyRef.current = null + setInspectedRows(null) + setClickDetail(null) + return + } + lastClickKeyRef.current = clickKey + + setClickDetail({ row, config, column_names, eventFilters: allFilters }) - // Use a Perspective view with the event filters + expressions so computed - // columns (split_by) are evaluated and filtered correctly try { const view = await tableRef.current.view({ - filter: eventFilters, - expressions: config.expressions || [], + filter: allFilters, + expressions: config.expressions || {}, }) const data = await view.to_json() await view.delete() @@ -212,10 +243,12 @@ export default function Pivot({ source }) { Object.fromEntries(Object.entries(r).filter(([k]) => !exprNames.has(k))) ) setInspectedRows(cleaned) - } catch { - setInspectedRows(filterRowsByConfig(allRowsRef.current, eventFilters)) + } catch (err) { + console.warn('Perspective inspector view failed, falling back to JS filter:', err) + setInspectedRows(filterRowsByConfig(allRowsRef.current, allFilters)) } - }) + } + viewer.addEventListener('perspective-click', perspClickHandlerRef.current) await viewer.load(worker) @@ -239,7 +272,13 @@ export default function Pivot({ source }) { } init() - return () => { cancelled = true } + return () => { + cancelled = true + if (perspClickHandlerRef.current && viewerRef.current) { + viewerRef.current.removeEventListener('perspective-click', perspClickHandlerRef.current) + perspClickHandlerRef.current = null + } + } }, [selectedView]) async function applyExpandDepth(viewer, depth) { @@ -374,6 +413,24 @@ export default function Pivot({ source }) { const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : [] + const sortedRows = sortCol == null || !inspectedRows ? inspectedRows : [...inspectedRows].sort((a, b) => { + const av = a[sortCol], bv = b[sortCol] + if (av == null && bv == null) return 0 + if (av == null) return 1 + if (bv == null) return -1 + const num = typeof av === 'number' && typeof bv === 'number' + const cmp = num ? av - bv : String(av).localeCompare(String(bv)) + return sortDir === 'asc' ? cmp : -cmp + }) + + const totals = cols.reduce((acc, c) => { + const vals = (inspectedRows || []).map(r => r[c]) + if (vals.length > 0 && vals.every(v => v == null || typeof v === 'number')) { + acc[c] = vals.reduce((s, v) => s + (v ?? 0), 0) + } + return acc + }, {}) + const groupBy = clickDetail?.config?.group_by || [] const splitBy = clickDetail?.config?.split_by || [] const coordFields = new Set([...groupBy, ...splitBy]) @@ -383,9 +440,14 @@ export default function Pivot({ source }) { .map(([f, , v]) => [f, v]) ) const cellCoords = [...groupBy, ...splitBy].map(f => coordMap[f]).filter(Boolean) - const splitVals = splitBy.map(f => coordMap[f]).filter(Boolean) - const metrics = clickDetail?.column_names || [] - const cellKey = splitVals.length > 0 && metrics.length > 0 + // column_names = [split_val_1, ..., split_val_N, measure_name] — use positional split_by length + // to separate split values from measure names; fall back to coordMap when ambiguous + const colNames = clickDetail?.column_names || [] + const splitVals = splitBy.map((f, i) => + coordMap[f] ?? (colNames[i] != null ? String(colNames[i]) : null) + ).filter(Boolean) + const metrics = splitBy.length > 0 ? colNames.slice(splitBy.length) : colNames + const cellKey = metrics.length > 0 ? [...splitVals, ...metrics].join('|') : null @@ -504,12 +566,40 @@ export default function Pivot({ source }) { {inspectedRows && clickDetail && ( -
-
- - {inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''} - -
+
+ {/* Drag-to-resize handle on left edge */} +
{ + e.preventDefault() + const startX = e.clientX + const startW = paneWidth + const onMove = (me) => setPaneWidth(Math.max(240, startW + startX - me.clientX)) + const onUp = () => { + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseup', onUp) + } + document.addEventListener('mousemove', onMove) + document.addEventListener('mouseup', onUp) + }} + /> + + {/* Header: breadcrumb + row count + controls */} +
+
+ {cellCoords.length > 0 && ( + + {cellCoords.join(' › ')} + + )} + + {inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''} + +
+
@@ -517,36 +607,13 @@ export default function Pivot({ source }) {
-
+
- - {/* Cell coordinates */} -
-
- {[...groupBy, ...splitBy].join(' › ') || clickDetail.column_names?.join(', ') || 'Cell'} -
- {cellCoords.length > 0 && ( -
- {cellCoords.join(' › ')} -
- )} - {Object.entries(clickDetail.row) - .filter(([k, v]) => k !== '__ROW_PATH__' && v != null) - .map(([k, v]) => { - const isSelected = cellKey != null && k === cellKey - return ( -
- {k} - {formatVal(v, decimals)} -
- ) - })} -
- - {/* User-set filters */} + {/* User-set filters (only shown when active) */} {(() => { const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f)) return userFilters.length > 0 ? ( @@ -565,13 +632,20 @@ export default function Pivot({ source }) { - {cols.map(c => ( - - ))} + {cols.map(c => { + const active = sortCol === c + return ( + + ) + })} - {inspectedRows.map((row, i) => ( + {sortedRows.map((row, i) => ( {cols.map(c => { const f = formatVal(row[c], decimals) @@ -584,6 +658,17 @@ export default function Pivot({ source }) { ))} + {Object.keys(totals).length > 0 && ( + + + {cols.map(c => ( + + ))} + + + )}
{c} { if (active) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortCol(c); setSortDir('asc') } }} + className="px-2 py-1 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600"> + {c}{active ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''} +
+ {totals[c] != null ? formatVal(totals[c], decimals) : ''} +
)}