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
Select a source first.
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 (
{/* Layouts sub-bar */}
{layouts.map(l => (
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}
))} {activeLayoutId !== null && !showSaveAs && ( )} {showSaveAs ? (
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" />
) : ( )} {activeLayoutId !== null && ( )} {layoutMsg && {layoutMsg}}
depth: {[0, 1, 2, 3].map(d => ( ))}
{/* Pivot + inspector */}
{status === 'loading' && (

Loading…

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

Error: {error}

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

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

)}
{inspectedRows && clickDetail && (
{/* 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' : ''}
{decimals}
{/* User-set filters (only shown when active) */} {(() => { const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f)) return userFilters.length > 0 ? (
Filters
{userFilters.map((f, i) => (
{f.join(' ')}
))}
) : null })()} {/* Underlying rows */} {inspectedRows.length > 0 && (
{cols.map(c => { const active = sortCol === c return ( ) })} {sortedRows.map((row, i) => ( {cols.map(c => { const f = formatVal(row[c], decimals) return ( ) })} ))} {Object.keys(totals).length > 0 && ( {cols.map(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' ? ' ▲' : ' ▼') : ''}
{f == null ? : f}
{totals[c] != null ? formatVal(totals[c], decimals) : ''}
)}
)}
) }