Pivot: row select default, click inspector with underlying rows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ebd88a2df8
commit
e3ceb70fc6
@ -1,19 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
// Fetch all rows for a source (no pagination limit)
|
||||
async function fetchAllRows(source) {
|
||||
const res = await api.getViewData(source, 100000, 0)
|
||||
return res.rows || []
|
||||
}
|
||||
|
||||
let perspectiveLoaded = false
|
||||
let perspectivePromise = null
|
||||
|
||||
function loadPerspective() {
|
||||
if (perspectivePromise) return perspectivePromise
|
||||
perspectivePromise = (async () => {
|
||||
// Inject theme CSS once
|
||||
if (!document.getElementById('psp-theme')) {
|
||||
const link = document.createElement('link')
|
||||
link.id = 'psp-theme'
|
||||
@ -28,45 +25,84 @@ function loadPerspective() {
|
||||
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'),
|
||||
])
|
||||
perspectiveLoaded = true
|
||||
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 || []
|
||||
const splitBy = config.split_by || []
|
||||
const pivotCols = [...groupBy, ...splitBy]
|
||||
|
||||
if (pivotCols.length === 0) {
|
||||
// Flat view — the clicked row IS one underlying row; return it directly
|
||||
// (no need to re-filter; Perspective already shows one row per record)
|
||||
return [row]
|
||||
}
|
||||
|
||||
// Pivoted — filter allRows by each group/split column value
|
||||
const criteria = pivotCols
|
||||
.map(col => ({ col, val: normalize(row[col]) }))
|
||||
.filter(({ val }) => val != null)
|
||||
|
||||
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 }
|
||||
|
||||
if (!rows.length) {
|
||||
setStatus('noview')
|
||||
return
|
||||
}
|
||||
allRowsRef.current = rows
|
||||
|
||||
if (workerRef.current) {
|
||||
try { workerRef.current.terminate() } catch {}
|
||||
}
|
||||
if (workerRef.current) { try { workerRef.current.terminate() } catch {} }
|
||||
|
||||
const worker = await perspective.worker()
|
||||
if (cancelled) { worker.terminate(); return }
|
||||
@ -76,13 +112,23 @@ export default function Pivot({ source }) {
|
||||
if (cancelled) return
|
||||
|
||||
const viewer = viewerRef.current
|
||||
|
||||
viewer.addEventListener('perspective-click', (e) => {
|
||||
const detail = e.detail || {}
|
||||
const { row, config, column_names } = detail
|
||||
if (!row) return
|
||||
setClickDetail({ row, config, column_names })
|
||||
const matched = filterRows(allRowsRef.current, row, config || {})
|
||||
setInspectedRows(matched.length > 0 ? matched : [row])
|
||||
})
|
||||
|
||||
await viewer.load(worker)
|
||||
|
||||
const saved = localStorage.getItem(LAYOUT_KEY(source))
|
||||
if (saved) {
|
||||
await viewer.restore(JSON.parse(saved))
|
||||
const savedLayout = localStorage.getItem(LAYOUT_KEY(source))
|
||||
if (savedLayout) {
|
||||
await viewer.restore(JSON.parse(savedLayout))
|
||||
} else {
|
||||
await viewer.restore({ table: source, settings: true })
|
||||
await viewer.restore({ table: source, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
|
||||
}
|
||||
setStatus('ready')
|
||||
} catch (err) {
|
||||
@ -106,26 +152,25 @@ export default function Pivot({ source }) {
|
||||
function clearLayout() {
|
||||
localStorage.removeItem(LAYOUT_KEY(source))
|
||||
const viewer = viewerRef.current
|
||||
if (viewer) viewer.restore({ table: source, settings: true })
|
||||
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">
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
@ -151,5 +196,78 @@ export default function Pivot({ source }) {
|
||||
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.column_names?.join(', ') || 'Cell'}
|
||||
</div>
|
||||
{Object.entries(clickDetail.row).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 truncate">{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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user