dataflow/ui/src/pages/Pivot.jsx
Paul Trowbridge 0c3cee4945 Migrate Perspective from CDN to npm; upgrade to 4.5.1
Replace runtime CDN imports with static ESM imports from npm packages.
Uses @perspective-dev/client and viewer inline builds (WASM embedded).
Bumps all packages to 4.5.1; d3fc stays at 4.4.1 (no 4.5.x release yet).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 23:00:23 -04:00

623 lines
25 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, useCallback } from 'react'
import { api } from '../api'
import useTheme from '../theme.jsx'
import perspective from '@perspective-dev/client/inline'
import '@perspective-dev/viewer/inline'
import '@perspective-dev/viewer-datagrid'
import '@perspective-dev/viewer-d3fc'
import '@perspective-dev/viewer/themes'
async function fetchAllRows(source) {
const res = await api.getViewData(source, 100000, 0)
return res.rows || []
}
function loadPerspective() {
return Promise.resolve(perspective)
}
function formatVal(v, decimals = 2) {
if (v == null) return null
if (typeof v === 'number') {
if (v > 1e11 && v < 2e12) {
const d = new Date(v)
if (!isNaN(d)) return d.toISOString().slice(0, 10)
}
return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
}
return String(v)
}
function normalize(v) {
if (v == null) return null
if (typeof v === 'number' && v > 1e11 && v < 2e12) return new Date(v).toISOString().slice(0, 10)
return String(v).trim()
}
function filterRowsByConfig(allRows, filters) {
if (!filters || filters.length === 0) return allRows
const knownFields = allRows.length > 0 ? new Set(Object.keys(allRows[0])) : new Set()
const applicable = filters.filter(([field]) => knownFields.has(field))
if (applicable.length === 0) return allRows
return allRows.filter(row =>
applicable.every(([field, op, value]) => {
const rawVal = row[field]
if (rawVal == null) return op === '!=' || op === 'not contains'
const a = normalize(rawVal)
const b = value != null ? String(value).trim() : ''
const aNum = parseFloat(a), bNum = parseFloat(b)
const numeric = !isNaN(aNum) && !isNaN(bNum)
switch (op) {
case '==': return a === b
case '!=': return a !== b
case '>': return numeric ? aNum > bNum : a > b
case '>=': return numeric ? aNum >= bNum : a >= b
case '<': return numeric ? aNum < bNum : a < b
case '<=': return numeric ? aNum <= bNum : a <= b
case 'contains': return a.toLowerCase().includes(b.toLowerCase())
case 'not contains': return !a.toLowerCase().includes(b.toLowerCase())
default: return true
}
})
)
}
const LAYOUT_KEY = (source) => `psp_layout_${source}`
const DEFAULT_PLUGIN_CONFIG = { edit_mode: 'SELECT_REGION' }
export default function Pivot({ source, selectedStack, setSelectedStack }) {
const { dark } = useTheme()
const viewerRef = useRef()
const workerRef = useRef()
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')
const selectedView = selectedStack ?? source
const viewType = selectedStack ? 'stack' : 'source'
useEffect(() => {
if (viewerRef.current) viewerRef.current.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
}, [dark])
// Named layouts — stacks use localStorage only (no server FK to sources)
const [layouts, setLayouts] = useState([])
const [activeLayoutId, setActiveLayoutId] = useState(null)
const [saveAsName, setSaveAsName] = useState('')
const [showSaveAs, setShowSaveAs] = useState(false)
const [layoutMsg, setLayoutMsg] = useState('')
const flashMsg = (msg) => {
setLayoutMsg(msg)
setTimeout(() => setLayoutMsg(''), 2000)
}
const loadLayouts = useCallback(async () => {
if (!selectedView) return
try {
const rows = viewType === 'source'
? await api.getPivotLayouts(selectedView)
: await api.getStackPivotLayouts(selectedView)
setLayouts(rows)
} catch {}
}, [selectedView])
useEffect(() => {
if (!selectedView) return
let cancelled = false
setInspectedRows(null)
setClickDetail(null)
lastClickKeyRef.current = null
setActiveLayoutId(null)
setShowSaveAs(false)
allRowsRef.current = []
loadLayouts()
async function init() {
setStatus('loading')
setError('')
try {
const [perspective, rows] = await Promise.all([
loadPerspective(),
fetchAllRows(selectedView),
])
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
const table = await worker.table(rows, { name: selectedView })
if (cancelled) return
tableRef.current = table
const viewer = viewerRef.current
const validCols = new Set(Object.keys(rows[0] || {}))
function cleanLayout(cfg) {
if (!cfg) return cfg
const clean = { ...cfg }
const exprNames = new Set(Object.keys(clean.expressions || {}))
const valid = (c) => validCols.has(c) || exprNames.has(c)
if (clean.columns) clean.columns = clean.columns.filter(c => c == null || valid(c))
if (clean.group_by) clean.group_by = clean.group_by.filter(valid)
if (clean.split_by) clean.split_by = clean.split_by.filter(valid)
if (clean.sort) clean.sort = clean.sort.filter(([c]) => valid(c))
if (clean.filter) clean.filter = clean.filter.filter(([c]) => valid(c))
return clean
}
perspClickHandlerRef.current = async (e) => {
const detail = e.detail || {}
const { row, column_names } = detail
if (!row) return
const eventFilters = (detail.config || {}).filter || []
const config = await viewer.save()
// Without a group_by hierarchy there are no coordinate filters, so the
// query would return the entire dataset — skip the inspector in that case
const hasHierarchy = (config.group_by || []).length > 0
if (!hasHierarchy) return
// 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 })
try {
const view = await tableRef.current.view({
filter: allFilters,
expressions: config.expressions || {},
})
const data = await view.to_json()
await view.delete()
// Strip expression columns — only show raw source columns
const exprNames = new Set(Object.keys(config.expressions || {}))
const cleaned = data.map(r =>
Object.fromEntries(Object.entries(r).filter(([k]) => !exprNames.has(k)))
)
setInspectedRows(cleaned)
} 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)
const plugin = await viewer.getPlugin()
const savedLayout = localStorage.getItem(LAYOUT_KEY(selectedView))
if (savedLayout) {
const parsed = cleanLayout(JSON.parse(savedLayout))
await viewer.restore(parsed)
await plugin.restore(parsed.plugin_config || DEFAULT_PLUGIN_CONFIG)
if (parsed.expand_depth != null) await applyExpandDepth(viewer, parsed.expand_depth)
} else {
await viewer.restore({ table: selectedView, settings: false, plugin_config: DEFAULT_PLUGIN_CONFIG })
await plugin.restore(DEFAULT_PLUGIN_CONFIG)
}
await viewer.flush()
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
setStatus('ready')
} catch (err) {
if (!cancelled) { setStatus('error'); setError(err.message) }
}
}
init()
return () => {
cancelled = true
if (perspClickHandlerRef.current && viewerRef.current) {
viewerRef.current.removeEventListener('perspective-click', perspClickHandlerRef.current)
perspClickHandlerRef.current = null
}
}
}, [selectedView])
async function applyExpandDepth(viewer, depth) {
if (depth == null) return
const view = await viewer.getView()
await view.set_depth(depth)
const plugin = await viewer.getPlugin()
await plugin.draw(view)
expandDepthRef.current = depth
}
async function applyLayout(layout) {
const viewer = viewerRef.current
if (!viewer) return
try {
const validCols = new Set(Object.keys(allRowsRef.current[0] || {}))
function cleanLayout(cfg) {
if (!cfg) return cfg
const clean = { ...cfg }
const exprNames = new Set(Object.keys(clean.expressions || {}))
const valid = (c) => validCols.has(c) || exprNames.has(c)
if (clean.columns) clean.columns = clean.columns.filter(c => c == null || valid(c))
if (clean.group_by) clean.group_by = clean.group_by.filter(valid)
if (clean.split_by) clean.split_by = clean.split_by.filter(valid)
if (clean.sort) clean.sort = clean.sort.filter(([c]) => valid(c))
if (clean.filter) clean.filter = clean.filter.filter(([c]) => valid(c))
return clean
}
const cleaned = cleanLayout(layout.config)
await viewer.restore(cleaned)
if (cleaned.plugin_config) {
const plugin = await viewer.getPlugin()
await plugin.restore(cleaned.plugin_config)
}
await applyExpandDepth(viewer, cleaned.expand_depth ?? null)
setActiveLayoutId(layout.id)
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(cleaned))
} catch {
// Layout references columns that no longer exist — remove it
localStorage.removeItem(LAYOUT_KEY(selectedView))
setActiveLayoutId(null)
await viewer.restore({ table: selectedView, settings: false })
}
}
async function captureConfig() {
const viewer = viewerRef.current
if (!viewer) return null
const plugin = await viewer.getPlugin()
const [viewerConfig, pluginConfig] = await Promise.all([viewer.save(), plugin.save()])
return { ...viewerConfig, plugin_config: pluginConfig, expand_depth: expandDepthRef.current }
}
const saveLayout = (name, config) => viewType === 'source'
? api.savePivotLayout(selectedView, name, config)
: api.saveStackPivotLayout(selectedView, name, config)
const deleteLayout = (id) => viewType === 'source'
? api.deletePivotLayout(selectedView, id)
: api.deleteStackPivotLayout(selectedView, id)
async function handleSaveOver() {
const layout = layouts.find(l => l.id === activeLayoutId)
if (!layout) return
const config = await captureConfig()
if (!config) return
try {
const saved = await saveLayout(layout.layout_name, config)
setActiveLayoutId(saved.id)
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(config))
await loadLayouts()
flashMsg('Saved!')
} catch (err) {
flashMsg(err.message)
}
}
async function handleSaveAs() {
const name = saveAsName.trim()
if (!name) return
const config = await captureConfig()
if (!config) return
try {
const saved = await saveLayout(name, config)
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(config))
await loadLayouts()
setActiveLayoutId(saved.id)
setShowSaveAs(false)
setSaveAsName('')
flashMsg('Saved!')
} catch (err) {
flashMsg(err.message)
}
}
async function handleDelete(layout, e) {
e.stopPropagation()
try {
await deleteLayout(layout.id)
if (activeLayoutId === layout.id) setActiveLayoutId(null)
await loadLayouts()
flashMsg('Deleted')
} catch (err) {
flashMsg(err.message)
}
}
function handleResetToDefault() {
const viewer = viewerRef.current
if (!viewer) return
localStorage.removeItem(LAYOUT_KEY(selectedView))
setActiveLayoutId(null)
viewer.restore({ table: selectedView, 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]) : []
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])
const coordMap = Object.fromEntries(
(clickDetail?.eventFilters || [])
.filter(([f, op]) => coordFields.has(f) && op === '==')
.map(([f, , v]) => [f, v])
)
const cellCoords = [...groupBy, ...splitBy].map(f => coordMap[f]).filter(Boolean)
// 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
return (
<div className="w-full h-full flex flex-col">
{/* Layouts sub-bar */}
<div className="flex items-center gap-2 px-3 h-9 bg-white border-b border-gray-200 shrink-0 text-xs">
{layouts.map(l => (
<div key={l.id}
onClick={() => applyLayout(l)}
className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors
${activeLayoutId === l.id
? 'bg-blue-50 border-blue-300 text-blue-700'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
{l.layout_name}
<button
onClick={(e) => handleDelete(l, e)}
className="text-gray-300 hover:text-red-400 leading-none ml-0.5 text-sm">×</button>
</div>
))}
{activeLayoutId !== null && !showSaveAs && (
<button onClick={handleSaveOver}
className="text-blue-500 hover:text-blue-700 border border-blue-200 rounded px-2 py-0.5">
Save
</button>
)}
{showSaveAs ? (
<div className="flex items-center gap-1">
<input
autoFocus
value={saveAsName}
onChange={e => setSaveAsName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
placeholder="Layout name…"
className="border border-gray-300 rounded px-2 py-0.5 w-36 focus:outline-none focus:border-blue-400"
/>
<button onClick={handleSaveAs} className="text-blue-600 hover:text-blue-800 px-1">Save</button>
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-gray-400 hover:text-gray-600 px-1">Cancel</button>
</div>
) : (
<button
onClick={() => setShowSaveAs(true)}
className="text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-0.5">
+ Save as
</button>
)}
{activeLayoutId !== null && (
<button onClick={handleResetToDefault} className="text-gray-300 hover:text-gray-500 ml-1">reset</button>
)}
{layoutMsg && <span className="text-green-600 ml-1">{layoutMsg}</span>}
<div className="ml-auto flex items-center gap-1">
<span className="text-gray-400">depth:</span>
{[0, 1, 2, 3].map(d => (
<button key={d} onClick={async () => {
const v = viewerRef.current; if (!v) return
const view = await v.getView()
await view.set_depth(d)
const p = await v.getPlugin()
await p.draw(view)
expandDepthRef.current = d
}} className="border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400">
{d}
</button>
))}
</div>
</div>
{/* Pivot + inspector */}
<div className="relative flex-1 flex min-h-0">
<div className="relative flex-1">
{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
style={{ width: paneWidth }}
className="relative border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0"
>
{/* Drag-to-resize handle on left edge */}
<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">
<button onClick={() => setDecimals(d => Math.max(0, d - 1))}
className="text-xs text-gray-400 hover:text-gray-600 w-4 text-center"></button>
<span className="text-xs text-gray-400 w-4 text-center">{decimals}</span>
<button onClick={() => setDecimals(d => Math.min(8, d + 1))}
className="text-xs text-gray-400 hover:text-gray-600 w-4 text-center">+</button>
</div>
<button onClick={() => { setInspectedRows(null); setClickDetail(null); lastClickKeyRef.current = null }}
className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{/* User-set filters (only shown when active) */}
{(() => {
const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f))
return userFilters.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">Filters</div>
{userFilters.map((f, i) => (
<div key={i} className="text-xs text-gray-500 py-0.5 font-mono">{f.join(' ')}</div>
))}
</div>
) : null
})()}
{/* 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 => {
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>
</thead>
<tbody>
{sortedRows.map((row, i) => (
<tr key={i} className="border-t border-gray-50 hover:bg-gray-50">
{cols.map(c => {
const f = formatVal(row[c], decimals)
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>
{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>
</div>
)}
</div>
</div>
)}
</div>
</div>
)
}