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
Select a source first.
const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : [] return (
{status === 'ready' && (
{localStorage.getItem(LAYOUT_KEY(source)) && ( )}
)} {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' : ''}
{/* Click context — row values from Perspective */}
{clickDetail.column_names?.join(', ') || 'Cell'}
{Object.entries(clickDetail.row).map(([k, v]) => (
{k} {formatVal(v) ?? '—'}
))}
{/* 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) && (
Pivot context
{clickDetail.config.group_by?.length > 0 && (
group by: {clickDetail.config.group_by.join(', ')}
)} {clickDetail.config.split_by?.length > 0 && (
split by: {clickDetail.config.split_by.join(', ')}
)} {clickDetail.config.filter?.map((f, i) => (
{f.join(' ')}
))}
)} {/* 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}
)}
)}
) }