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
Loading…
Error: {error}
No view data — generate a view and transform records first.
| {c} | ))}
|---|
| {f == null ? — : f} | ) })}