dataflow/ui/src/pages/Pivot.jsx
Paul Trowbridge f7d73ad821 Pivot: clean up click inspector upper pane display
Show row path prominently, filter to non-null metric values,
use group_by › split_by as section header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:31:56 -04:00

292 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react'
import { api } from '../api'
async function fetchAllRows(source) {
const res = await api.getViewData(source, 100000, 0)
return res.rows || []
}
let perspectivePromise = null
function loadPerspective() {
if (perspectivePromise) return perspectivePromise
perspectivePromise = (async () => {
if (!document.getElementById('psp-theme')) {
const link = document.createElement('link')
link.id = 'psp-theme'
link.rel = 'stylesheet'
link.crossOrigin = 'anonymous'
link.href = 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer/dist/css/themes.css'
document.head.appendChild(link)
}
const [{ default: perspective }] = await Promise.all([
import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.4.0/dist/cdn/perspective.js'),
import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer@4.4.0/dist/cdn/perspective-viewer.js'),
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'),
])
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 || []
if (groupBy.length === 0) {
// Flat view — clicked row IS the record
return [row]
}
// __ROW_PATH__ is an array of the group_by values in order, e.g. ["Groceries", "Wal-Mart"]
const rowPath = row['__ROW_PATH__']
const pathVals = Array.isArray(rowPath)
? rowPath
: String(rowPath).split(',').map(s => s.trim())
// Zip group_by columns with __ROW_PATH__ values to build filter criteria
const criteria = groupBy
.map((col, i) => ({ col, val: pathVals[i] != null ? String(pathVals[i]).trim() : null }))
.filter(({ val }) => val != null && val !== '')
if (criteria.length === 0) return allRows
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 }
allRowsRef.current = rows
if (workerRef.current) { try { workerRef.current.terminate() } catch {} }
const worker = await perspective.worker()
if (cancelled) { worker.terminate(); return }
workerRef.current = worker
await worker.table(rows, { name: source })
if (cancelled) return
const viewer = viewerRef.current
viewer.addEventListener('perspective-click', async (e) => {
const detail = e.detail || {}
const { row, column_names } = detail
if (!row) return
// perspective-click's config only has filter — save() gives us the full config incl. group_by
const config = await viewer.save()
setClickDetail({ row, config, column_names })
const matched = filterRows(allRowsRef.current, row, config)
setInspectedRows(matched)
})
await viewer.load(worker)
const savedLayout = localStorage.getItem(LAYOUT_KEY(source))
if (savedLayout) {
await viewer.restore(JSON.parse(savedLayout))
} else {
await viewer.restore({ table: source, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
}
setStatus('ready')
} catch (err) {
if (!cancelled) { setStatus('error'); setError(err.message) }
}
}
init()
return () => { cancelled = true }
}, [source])
async function saveLayout() {
const viewer = viewerRef.current
if (!viewer) return
const layout = await viewer.save()
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout))
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
function clearLayout() {
localStorage.removeItem(LAYOUT_KEY(source))
const viewer = viewerRef.current
if (viewer) viewer.restore({ table: source, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
}
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : []
return (
<div className="relative w-full h-full flex">
<div className="relative flex-1">
{status === 'ready' && (
<div className="absolute top-2 right-3 z-20 flex gap-2">
<button onClick={saveLayout}
className="text-xs bg-white border border-gray-200 rounded px-2 py-1 text-gray-500 hover:border-gray-400 shadow-sm">
{saved ? 'Saved!' : 'Save layout'}
</button>
{localStorage.getItem(LAYOUT_KEY(source)) && (
<button onClick={clearLayout}
className="text-xs bg-white border border-gray-200 rounded px-2 py-1 text-gray-400 hover:text-red-500 shadow-sm">
Reset
</button>
)}
</div>
)}
{status === 'loading' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-gray-400">Loading</p>
</div>
)}
{status === 'error' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-red-500">Error: {error}</p>
</div>
)}
{status === 'noview' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-gray-400">No view data generate a view and transform records first.</p>
</div>
)}
<perspective-viewer
ref={viewerRef}
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}
/>
</div>
{inspectedRows && clickDetail && (
<div className="w-80 border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0">
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100">
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide">
{inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''}
</span>
<button onClick={() => { setInspectedRows(null); setClickDetail(null) }}
className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
</div>
<div className="flex-1 overflow-y-auto">
{/* Click context — row values from Perspective */}
<div className="px-3 py-2 border-b border-gray-100">
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">
{[...(clickDetail.config?.group_by || []), ...(clickDetail.config?.split_by || [])].join(' ') || clickDetail.column_names?.join(', ') || 'Cell'}
</div>
{/* Row path */}
{clickDetail.row['__ROW_PATH__'] && (
<div className="text-xs text-gray-700 font-mono font-semibold mb-1">
{Array.isArray(clickDetail.row['__ROW_PATH__'])
? clickDetail.row['__ROW_PATH__'].join(' ')
: String(clickDetail.row['__ROW_PATH__'])}
</div>
)}
{/* Non-null metric values only */}
{Object.entries(clickDetail.row)
.filter(([k, v]) => k !== '__ROW_PATH__' && v != null)
.map(([k, v]) => (
<div key={k} className="flex justify-between py-0.5 gap-2">
<span className="text-xs text-gray-400 font-mono shrink-0">{k}</span>
<span className="text-xs text-gray-700 font-mono text-right">{formatVal(v)}</span>
</div>
))}
</div>
{/* 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) && (
<div className="px-3 py-2 border-b border-gray-100">
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">Pivot context</div>
{clickDetail.config.group_by?.length > 0 && (
<div className="text-xs text-gray-500 py-0.5">group by: <span className="font-mono text-gray-700">{clickDetail.config.group_by.join(', ')}</span></div>
)}
{clickDetail.config.split_by?.length > 0 && (
<div className="text-xs text-gray-500 py-0.5">split by: <span className="font-mono text-gray-700">{clickDetail.config.split_by.join(', ')}</span></div>
)}
{clickDetail.config.filter?.map((f, i) => (
<div key={i} className="text-xs text-gray-500 py-0.5 font-mono">{f.join(' ')}</div>
))}
</div>
)}
{/* Underlying rows */}
{inspectedRows.length > 0 && (
<div className="overflow-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50 sticky top-0">
{cols.map(c => (
<th key={c} className="px-2 py-1 font-medium whitespace-nowrap">{c}</th>
))}
</tr>
</thead>
<tbody>
{inspectedRows.map((row, i) => (
<tr key={i} className="border-t border-gray-50 hover:bg-gray-50">
{cols.map(c => {
const f = formatVal(row[c])
return (
<td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-gray-700 max-w-40 truncate">
{f == null ? <span className="text-gray-300"></span> : f}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>
)
}