Pivot inspector: toggle, resize, sort, totals, filter fix
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
f373c85c16
commit
b52f5c930e
@ -86,11 +86,16 @@ export default function Pivot({ source }) {
|
|||||||
const tableRef = useRef()
|
const tableRef = useRef()
|
||||||
const allRowsRef = useRef([])
|
const allRowsRef = useRef([])
|
||||||
const expandDepthRef = useRef(null)
|
const expandDepthRef = useRef(null)
|
||||||
|
const lastClickKeyRef = useRef(null)
|
||||||
|
const perspClickHandlerRef = useRef(null)
|
||||||
const [status, setStatus] = useState('idle')
|
const [status, setStatus] = useState('idle')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [inspectedRows, setInspectedRows] = useState(null)
|
const [inspectedRows, setInspectedRows] = useState(null)
|
||||||
const [clickDetail, setClickDetail] = useState(null)
|
const [clickDetail, setClickDetail] = useState(null)
|
||||||
const [decimals, setDecimals] = useState(2)
|
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
|
// View selector: source or a stack
|
||||||
const [stacks, setStacks] = useState([])
|
const [stacks, setStacks] = useState([])
|
||||||
@ -138,6 +143,7 @@ export default function Pivot({ source }) {
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
setInspectedRows(null)
|
setInspectedRows(null)
|
||||||
setClickDetail(null)
|
setClickDetail(null)
|
||||||
|
lastClickKeyRef.current = null
|
||||||
setActiveLayoutId(null)
|
setActiveLayoutId(null)
|
||||||
setShowSaveAs(false)
|
setShowSaveAs(false)
|
||||||
allRowsRef.current = []
|
allRowsRef.current = []
|
||||||
@ -183,7 +189,7 @@ export default function Pivot({ source }) {
|
|||||||
return clean
|
return clean
|
||||||
}
|
}
|
||||||
|
|
||||||
viewer.addEventListener('perspective-click', async (e) => {
|
perspClickHandlerRef.current = async (e) => {
|
||||||
const detail = e.detail || {}
|
const detail = e.detail || {}
|
||||||
const { row, column_names } = detail
|
const { row, column_names } = detail
|
||||||
if (!row) return
|
if (!row) return
|
||||||
@ -195,14 +201,39 @@ export default function Pivot({ source }) {
|
|||||||
const hasHierarchy = (config.group_by || []).length > 0
|
const hasHierarchy = (config.group_by || []).length > 0
|
||||||
if (!hasHierarchy) return
|
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 {
|
try {
|
||||||
const view = await tableRef.current.view({
|
const view = await tableRef.current.view({
|
||||||
filter: eventFilters,
|
filter: allFilters,
|
||||||
expressions: config.expressions || [],
|
expressions: config.expressions || {},
|
||||||
})
|
})
|
||||||
const data = await view.to_json()
|
const data = await view.to_json()
|
||||||
await view.delete()
|
await view.delete()
|
||||||
@ -212,10 +243,12 @@ export default function Pivot({ source }) {
|
|||||||
Object.fromEntries(Object.entries(r).filter(([k]) => !exprNames.has(k)))
|
Object.fromEntries(Object.entries(r).filter(([k]) => !exprNames.has(k)))
|
||||||
)
|
)
|
||||||
setInspectedRows(cleaned)
|
setInspectedRows(cleaned)
|
||||||
} catch {
|
} catch (err) {
|
||||||
setInspectedRows(filterRowsByConfig(allRowsRef.current, eventFilters))
|
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)
|
await viewer.load(worker)
|
||||||
|
|
||||||
@ -239,7 +272,13 @@ export default function Pivot({ source }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init()
|
init()
|
||||||
return () => { cancelled = true }
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (perspClickHandlerRef.current && viewerRef.current) {
|
||||||
|
viewerRef.current.removeEventListener('perspective-click', perspClickHandlerRef.current)
|
||||||
|
perspClickHandlerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}, [selectedView])
|
}, [selectedView])
|
||||||
|
|
||||||
async function applyExpandDepth(viewer, depth) {
|
async function applyExpandDepth(viewer, depth) {
|
||||||
@ -374,6 +413,24 @@ export default function Pivot({ source }) {
|
|||||||
|
|
||||||
const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : []
|
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 groupBy = clickDetail?.config?.group_by || []
|
||||||
const splitBy = clickDetail?.config?.split_by || []
|
const splitBy = clickDetail?.config?.split_by || []
|
||||||
const coordFields = new Set([...groupBy, ...splitBy])
|
const coordFields = new Set([...groupBy, ...splitBy])
|
||||||
@ -383,9 +440,14 @@ export default function Pivot({ source }) {
|
|||||||
.map(([f, , v]) => [f, v])
|
.map(([f, , v]) => [f, v])
|
||||||
)
|
)
|
||||||
const cellCoords = [...groupBy, ...splitBy].map(f => coordMap[f]).filter(Boolean)
|
const cellCoords = [...groupBy, ...splitBy].map(f => coordMap[f]).filter(Boolean)
|
||||||
const splitVals = splitBy.map(f => coordMap[f]).filter(Boolean)
|
// column_names = [split_val_1, ..., split_val_N, measure_name] — use positional split_by length
|
||||||
const metrics = clickDetail?.column_names || []
|
// to separate split values from measure names; fall back to coordMap when ambiguous
|
||||||
const cellKey = splitVals.length > 0 && metrics.length > 0
|
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('|')
|
? [...splitVals, ...metrics].join('|')
|
||||||
: null
|
: null
|
||||||
|
|
||||||
@ -504,12 +566,40 @@ export default function Pivot({ source }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{inspectedRows && clickDetail && (
|
{inspectedRows && clickDetail && (
|
||||||
<div className="w-96 border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0">
|
<div
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100">
|
style={{ width: paneWidth }}
|
||||||
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide">
|
className="relative border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0"
|
||||||
{inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''}
|
>
|
||||||
</span>
|
{/* Drag-to-resize handle on left edge */}
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-300 z-10"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
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 */}
|
||||||
|
<div className="flex items-center justify-between pl-3 pr-2 py-2 border-b border-gray-100 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{cellCoords.length > 0 && (
|
||||||
|
<span className="text-xs text-gray-700 font-mono font-semibold truncate">
|
||||||
|
{cellCoords.join(' › ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||||
|
{inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<button onClick={() => setDecimals(d => Math.max(0, d - 1))}
|
<button onClick={() => setDecimals(d => Math.max(0, d - 1))}
|
||||||
className="text-xs text-gray-400 hover:text-gray-600 w-4 text-center">−</button>
|
className="text-xs text-gray-400 hover:text-gray-600 w-4 text-center">−</button>
|
||||||
@ -517,36 +607,13 @@ export default function Pivot({ source }) {
|
|||||||
<button onClick={() => setDecimals(d => Math.min(8, d + 1))}
|
<button onClick={() => setDecimals(d => Math.min(8, d + 1))}
|
||||||
className="text-xs text-gray-400 hover:text-gray-600 w-4 text-center">+</button>
|
className="text-xs text-gray-400 hover:text-gray-600 w-4 text-center">+</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => { setInspectedRows(null); setClickDetail(null) }}
|
<button onClick={() => { setInspectedRows(null); setClickDetail(null); lastClickKeyRef.current = null }}
|
||||||
className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
|
className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{/* User-set filters (only shown when active) */}
|
||||||
{/* Cell coordinates */}
|
|
||||||
<div className="px-3 py-2 border-b border-gray-100">
|
|
||||||
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">
|
|
||||||
{[...groupBy, ...splitBy].join(' › ') || clickDetail.column_names?.join(', ') || 'Cell'}
|
|
||||||
</div>
|
|
||||||
{cellCoords.length > 0 && (
|
|
||||||
<div className="text-xs text-gray-700 font-mono font-semibold">
|
|
||||||
{cellCoords.join(' › ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{Object.entries(clickDetail.row)
|
|
||||||
.filter(([k, v]) => k !== '__ROW_PATH__' && v != null)
|
|
||||||
.map(([k, v]) => {
|
|
||||||
const isSelected = cellKey != null && k === cellKey
|
|
||||||
return (
|
|
||||||
<div key={k} className={`flex justify-between py-0.5 gap-2 ${isSelected ? 'font-semibold' : ''}`}>
|
|
||||||
<span className={`text-xs font-mono shrink-0 ${isSelected ? 'text-gray-700' : 'text-gray-400'}`}>{k}</span>
|
|
||||||
<span className={`text-xs font-mono text-right ${isSelected ? 'text-blue-600' : 'text-gray-700'}`}>{formatVal(v, decimals)}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User-set filters */}
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f))
|
const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f))
|
||||||
return userFilters.length > 0 ? (
|
return userFilters.length > 0 ? (
|
||||||
@ -565,13 +632,20 @@ export default function Pivot({ source }) {
|
|||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50 sticky top-0">
|
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50 sticky top-0">
|
||||||
{cols.map(c => (
|
{cols.map(c => {
|
||||||
<th key={c} className="px-2 py-1 font-medium whitespace-nowrap">{c}</th>
|
const active = sortCol === c
|
||||||
))}
|
return (
|
||||||
|
<th key={c}
|
||||||
|
onClick={() => { 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' ? ' ▲' : ' ▼') : ''}
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{inspectedRows.map((row, i) => (
|
{sortedRows.map((row, i) => (
|
||||||
<tr key={i} className="border-t border-gray-50 hover:bg-gray-50">
|
<tr key={i} className="border-t border-gray-50 hover:bg-gray-50">
|
||||||
{cols.map(c => {
|
{cols.map(c => {
|
||||||
const f = formatVal(row[c], decimals)
|
const f = formatVal(row[c], decimals)
|
||||||
@ -584,6 +658,17 @@ export default function Pivot({ source }) {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
{Object.keys(totals).length > 0 && (
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t-2 border-gray-200 bg-gray-50 font-semibold text-gray-700 sticky bottom-0">
|
||||||
|
{cols.map(c => (
|
||||||
|
<td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-right">
|
||||||
|
{totals[c] != null ? formatVal(totals[c], decimals) : ''}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user