import { useEffect, useRef, useState, useCallback } 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 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 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_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 [inspectedRows, setInspectedRows] = useState(null) const [clickDetail, setClickDetail] = useState(null) // Named layouts 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 (!source) return try { const rows = await api.getPivotLayouts(source) setLayouts(rows) } catch {} }, [source]) useEffect(() => { if (!source) return let cancelled = false setInspectedRows(null) setClickDetail(null) setActiveLayoutId(null) setShowSaveAs(false) allRowsRef.current = [] loadLayouts() 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 const eventFilters = (detail.config || {}).filter || [] const config = await viewer.save() setClickDetail({ row, config, column_names, eventFilters }) const matched = filterRowsByConfig(allRowsRef.current, eventFilters) 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 applyLayout(layout) { const viewer = viewerRef.current if (!viewer) return await viewer.restore(layout.config) setActiveLayoutId(layout.id) // also persist to localStorage so it survives refresh localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout.config)) } async function handleSaveAs() { const name = saveAsName.trim() if (!name) return const viewer = viewerRef.current if (!viewer) return const config = await viewer.save() try { const saved = await api.savePivotLayout(source, name, config) localStorage.setItem(LAYOUT_KEY(source), 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 api.deletePivotLayout(source, 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(source)) setActiveLayoutId(null) 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]) : [] 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) const splitVals = splitBy.map(f => coordMap[f]).filter(Boolean) const metrics = clickDetail?.column_names || [] const cellKey = splitVals.length > 0 && metrics.length > 0 ? [...splitVals, ...metrics].join('|') : null return (
{/* Layout toolbar */}
Layouts {layouts.map(l => (
applyLayout(l)} className={`flex items-center gap-1 text-xs 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}
))} {showSaveAs ? (
setSaveAsName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }} placeholder="Layout name…" className="text-xs border border-gray-300 rounded px-2 py-0.5 w-36 focus:outline-none focus:border-blue-400" />
) : ( )} {activeLayoutId !== null && ( )} {layoutMsg && {layoutMsg}} {/* expand_depth test buttons */}
expand test: {[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 && (
{inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''}
{/* Cell coordinates */}
{[...groupBy, ...splitBy].join(' › ') || clickDetail.column_names?.join(', ') || 'Cell'}
{cellCoords.length > 0 && (
{cellCoords.join(' › ')}
)} {Object.entries(clickDetail.row) .filter(([k, v]) => k !== '__ROW_PATH__' && v != null) .map(([k, v]) => { const isSelected = cellKey != null && k === cellKey return (
{k} {formatVal(v)}
) })}
{/* User-set filters */} {(() => { 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 => ( ))} {inspectedRows.map((row, i) => ( {cols.map(c => { const f = formatVal(row[c]) return ( ) })} ))}
{c}
{f == null ? : f}
)}
)}
) }