From e3ceb70fc60ce45cdac7e85e80ded4ec8ddcb427 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Tue, 14 Apr 2026 22:03:58 -0400 Subject: [PATCH] Pivot: row select default, click inspector with underlying rows Co-Authored-By: Claude Sonnet 4.6 --- ui/src/pages/Pivot.jsx | 222 +++++++++++++++++++++++++++++++---------- 1 file changed, 170 insertions(+), 52 deletions(-) diff --git a/ui/src/pages/Pivot.jsx b/ui/src/pages/Pivot.jsx index 02fe9f2..2edb8c2 100644 --- a/ui/src/pages/Pivot.jsx +++ b/ui/src/pages/Pivot.jsx @@ -1,19 +1,16 @@ import { useEffect, useRef, useState } from 'react' import { api } from '../api' -// Fetch all rows for a source (no pagination limit) async function fetchAllRows(source) { const res = await api.getViewData(source, 100000, 0) return res.rows || [] } -let perspectiveLoaded = false let perspectivePromise = null function loadPerspective() { if (perspectivePromise) return perspectivePromise perspectivePromise = (async () => { - // Inject theme CSS once if (!document.getElementById('psp-theme')) { const link = document.createElement('link') link.id = 'psp-theme' @@ -28,45 +25,84 @@ function loadPerspective() { import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-datagrid@4.4.0/dist/cdn/perspective-viewer-datagrid.js'), import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-d3fc@4.4.0/dist/cdn/perspective-viewer-d3fc.js'), ]) - perspectiveLoaded = true return perspective })() return perspectivePromise } +function formatVal(v) { + if (v == null) return null + // Perspective returns dates as ms timestamps + if (typeof v === 'number' && v > 1e11 && v < 2e12) { + const d = new Date(v) + if (!isNaN(d)) return d.toISOString().slice(0, 10) + } + return String(v) +} + +function normalize(v) { + if (v == null) return null + // Perspective returns dates as ms timestamps — convert to ISO date string + if (typeof v === 'number' && v > 1e11 && v < 2e12) return new Date(v).toISOString().slice(0, 10) + return String(v).trim() +} + +function filterRows(allRows, row, config) { + const groupBy = config.group_by || [] + const splitBy = config.split_by || [] + const pivotCols = [...groupBy, ...splitBy] + + if (pivotCols.length === 0) { + // Flat view — the clicked row IS one underlying row; return it directly + // (no need to re-filter; Perspective already shows one row per record) + return [row] + } + + // Pivoted — filter allRows by each group/split column value + const criteria = pivotCols + .map(col => ({ col, val: normalize(row[col]) })) + .filter(({ val }) => val != null) + + return allRows.filter(r => + criteria.every(({ col, val }) => normalize(r[col]) === val) + ) +} + const LAYOUT_KEY = (source) => `psp_layout_${source}` +const DEFAULT_PLUGIN_CONFIG = { edit_mode: 'SELECT_ROW' } export default function Pivot({ source }) { const viewerRef = useRef() const workerRef = useRef() + const allRowsRef = useRef([]) const [status, setStatus] = useState('idle') const [error, setError] = useState('') const [saved, setSaved] = useState(false) + const [inspectedRows, setInspectedRows] = useState(null) + const [clickDetail, setClickDetail] = useState(null) useEffect(() => { if (!source) return let cancelled = false setSaved(false) + setInspectedRows(null) + setClickDetail(null) + allRowsRef.current = [] async function init() { setStatus('loading') setError('') - try { const [perspective, rows] = await Promise.all([ loadPerspective(), fetchAllRows(source), ]) if (cancelled) return + if (!rows.length) { setStatus('noview'); return } - if (!rows.length) { - setStatus('noview') - return - } + allRowsRef.current = rows - if (workerRef.current) { - try { workerRef.current.terminate() } catch {} - } + if (workerRef.current) { try { workerRef.current.terminate() } catch {} } const worker = await perspective.worker() if (cancelled) { worker.terminate(); return } @@ -76,13 +112,23 @@ export default function Pivot({ source }) { if (cancelled) return const viewer = viewerRef.current + + viewer.addEventListener('perspective-click', (e) => { + const detail = e.detail || {} + const { row, config, column_names } = detail + if (!row) return + setClickDetail({ row, config, column_names }) + const matched = filterRows(allRowsRef.current, row, config || {}) + setInspectedRows(matched.length > 0 ? matched : [row]) + }) + await viewer.load(worker) - const saved = localStorage.getItem(LAYOUT_KEY(source)) - if (saved) { - await viewer.restore(JSON.parse(saved)) + const savedLayout = localStorage.getItem(LAYOUT_KEY(source)) + if (savedLayout) { + await viewer.restore(JSON.parse(savedLayout)) } else { - await viewer.restore({ table: source, settings: true }) + await viewer.restore({ table: source, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG }) } setStatus('ready') } catch (err) { @@ -106,50 +152,122 @@ export default function Pivot({ source }) { function clearLayout() { localStorage.removeItem(LAYOUT_KEY(source)) const viewer = viewerRef.current - if (viewer) viewer.restore({ table: source, settings: true }) + if (viewer) viewer.restore({ table: source, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG }) } if (!source) return
Select a source first.
+ const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : [] + return ( -
- {status === 'ready' && ( -
- - {localStorage.getItem(LAYOUT_KEY(source)) && ( - - )} + {localStorage.getItem(LAYOUT_KEY(source)) && ( + + )} +
+ )} + {status === 'loading' && ( +
+

Loading…

+
+ )} + {status === 'error' && ( +
+

Error: {error}

+
+ )} + {status === 'noview' && ( +
+

No view data — generate a view and transform records first.

+
+ )} + +
+ + {inspectedRows && clickDetail && ( +
+
+ + {inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''} + + +
+
+ + {/* Click context — row values from Perspective */} +
+
+ {clickDetail.column_names?.join(', ') || 'Cell'} +
+ {Object.entries(clickDetail.row).map(([k, v]) => ( +
+ {k} + {formatVal(v) ?? '—'} +
+ ))} +
+ + {/* Config context — group_by / split_by / filters if any */} + {(clickDetail.config?.group_by?.length > 0 || clickDetail.config?.split_by?.length > 0 || clickDetail.config?.filter?.length > 0) && ( +
+
Pivot context
+ {clickDetail.config.group_by?.length > 0 && ( +
group by: {clickDetail.config.group_by.join(', ')}
+ )} + {clickDetail.config.split_by?.length > 0 && ( +
split by: {clickDetail.config.split_by.join(', ')}
+ )} + {clickDetail.config.filter?.map((f, i) => ( +
{f.join(' ')}
+ ))} +
+ )} + + {/* Underlying rows */} + {inspectedRows.length > 0 && ( +
+ + + + {cols.map(c => ( + + ))} + + + + {inspectedRows.map((row, i) => ( + + {cols.map(c => { + const f = formatVal(row[c]) + return ( + + ) + })} + + ))} + +
{c}
+ {f == null ? : f} +
+
+ )} +
)} - {status === 'loading' && ( -
-

Loading…

-
- )} - {status === 'error' && ( -
-

Error: {error}

-
- )} - {status === 'noview' && ( -
-

No view data — generate a view and transform records first.

-
- )} - ) }