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>
623 lines
25 KiB
JavaScript
623 lines
25 KiB
JavaScript
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>
|
||
)
|
||
}
|