Reuse a single Perspective worker across version switches and delete the previous table instead of terminating the worker — terminate was returning a rejecting promise the sync try/catch missed, and each new worker leaked WASM memory. applyLayout no longer leaks a view per call; it reads schema directly from the table. An init id guards against concurrent runs (StrictMode, rapid version switches) clobbering each other, and a catch on "already exists" recovers via open_table+delete when a stale table from a previous run is still hosted. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
776 lines
34 KiB
JavaScript
776 lines
34 KiB
JavaScript
import { useState, useEffect, useRef } from 'react'
|
||
import useTheme from '../theme.jsx'
|
||
|
||
const LAYOUT_KEY = (vid) => `pf_layout_v${vid}` // last-used layout (auto restore)
|
||
const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // named layout list
|
||
|
||
let perspectivePromise = null
|
||
function loadPerspective() {
|
||
if (perspectivePromise) return perspectivePromise
|
||
perspectivePromise = 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'),
|
||
]).then(([{ default: perspective }]) => perspective)
|
||
return perspectivePromise
|
||
}
|
||
|
||
function cleanLayout(cfg, validCols) {
|
||
if (!cfg) return cfg
|
||
const c = { ...cfg }
|
||
const exprNames = new Set(Object.keys(cfg.expressions || {}))
|
||
const ok = (col) => validCols.has(col) || exprNames.has(col)
|
||
if (c.columns) c.columns = c.columns.filter(col => col == null || ok(col))
|
||
if (c.group_by) c.group_by = c.group_by.filter(ok)
|
||
if (c.split_by) c.split_by = c.split_by.filter(ok)
|
||
if (c.sort) c.sort = c.sort.filter(([col]) => ok(col))
|
||
if (c.filter) c.filter = c.filter.filter(([col]) => ok(col))
|
||
return c
|
||
}
|
||
|
||
export default function Forecast({ sourceId, versionId }) {
|
||
const { dark } = useTheme()
|
||
const [loading, setLoading] = useState(false)
|
||
const [largeDataset, setLargeDataset] = useState(false)
|
||
const [loadProgress, setLoadProgress] = useState(null) // { received, total }
|
||
const [msg, setMsg] = useState(null)
|
||
|
||
// layouts
|
||
const [layouts, setLayouts] = useState([])
|
||
const [activeLayoutId, setActiveLayoutId] = useState(null)
|
||
const [showSaveAs, setShowSaveAs] = useState(false)
|
||
const [saveAsName, setSaveAsName] = useState('')
|
||
|
||
// operation panel
|
||
const [slice, setSlice] = useState({})
|
||
const [activeOp, setActiveOp] = useState('scale')
|
||
const [currentTotals, setCurrentTotals] = useState(null) // { value, units }
|
||
const [scaleMode, setScaleMode] = useState('target') // 'target' | 'delta'
|
||
const [scaleValue, setScaleValue] = useState('')
|
||
const [scaleUnits, setScaleUnits] = useState('')
|
||
const [scalePct, setScalePct] = useState(false)
|
||
const [scaleNote, setScaleNote] = useState('')
|
||
const [recodeSet, setRecodeSet] = useState({})
|
||
const [recodeNote, setRecodeNote] = useState('')
|
||
const [cloneSet, setCloneSet] = useState({})
|
||
const [cloneScale, setCloneScale] = useState('1')
|
||
const [cloneNote, setCloneNote] = useState('')
|
||
|
||
const [panelWidth, setPanelWidth] = useState(224)
|
||
|
||
// history modal
|
||
const [showLog, setShowLog] = useState(false)
|
||
const [logEntries, setLogEntries] = useState([])
|
||
const [logLoading, setLogLoading] = useState(false)
|
||
const [editingNote, setEditingNote] = useState(null) // { id, text }
|
||
const [undoingId, setUndoingId] = useState(null)
|
||
|
||
const viewerRef = useRef(null)
|
||
const workerRef = useRef(null)
|
||
const tableRef = useRef(null)
|
||
const colMetaRef = useRef([])
|
||
const expandDepthRef = useRef(null)
|
||
const initIdRef = useRef(0)
|
||
|
||
function onDragStart(e) {
|
||
e.preventDefault()
|
||
const startX = e.clientX
|
||
const startW = panelWidth
|
||
const onMove = (ev) => setPanelWidth(Math.max(160, Math.min(480, startW - (ev.clientX - startX))))
|
||
const onUp = () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) }
|
||
window.addEventListener('mousemove', onMove)
|
||
window.addEventListener('mouseup', onUp)
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!versionId || !sourceId) return
|
||
loadLayouts(versionId)
|
||
initViewer(versionId, sourceId)
|
||
}, [versionId, sourceId])
|
||
|
||
useEffect(() => {
|
||
if (viewerRef.current) {
|
||
viewerRef.current.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
|
||
}
|
||
}, [dark, versionId])
|
||
|
||
useEffect(() => {
|
||
const blank = Object.fromEntries(Object.keys(slice).map(k => [k, '']))
|
||
setRecodeSet(blank)
|
||
setCloneSet(blank)
|
||
setScaleValue('')
|
||
setScaleUnits('')
|
||
if (Object.keys(slice).length > 0) fetchCurrentTotals(slice)
|
||
else setCurrentTotals(null)
|
||
}, [slice])
|
||
|
||
async function fetchCurrentTotals(sliceObj) {
|
||
if (!tableRef.current) return
|
||
const valueCol = colMetaRef.current.find(c => c.role === 'value')?.cname
|
||
const unitsCol = colMetaRef.current.find(c => c.role === 'units')?.cname
|
||
if (!valueCol && !unitsCol) return
|
||
try {
|
||
const dimNames = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
|
||
const filters = [
|
||
...Object.entries(sliceObj)
|
||
.filter(([col]) => dimNames.has(col))
|
||
.map(([col, val]) => [col, '==', val]),
|
||
['pf_iter', '!=', 'reference'],
|
||
]
|
||
const view = await tableRef.current.view({ filter: filters })
|
||
const rows = await view.to_json()
|
||
await view.delete()
|
||
const sum = (col) => col ? rows.reduce((s, r) => s + (parseFloat(r[col]) || 0), 0) : null
|
||
setCurrentTotals({ value: sum(valueCol), units: sum(unitsCol), valueCol, unitsCol })
|
||
} catch {
|
||
setCurrentTotals(null)
|
||
}
|
||
}
|
||
|
||
function loadLayouts(vid) {
|
||
const stored = localStorage.getItem(LAYOUTS_KEY(vid))
|
||
setLayouts(stored ? JSON.parse(stored) : [])
|
||
setActiveLayoutId(null)
|
||
}
|
||
|
||
async function initViewer(vid, sid) {
|
||
const viewer = viewerRef.current
|
||
if (!viewer) return
|
||
const myId = ++initIdRef.current
|
||
setLoading(true)
|
||
setLargeDataset(false)
|
||
setLoadProgress(null)
|
||
setSlice({})
|
||
expandDepthRef.current = null
|
||
try {
|
||
const [perspective, dataResult, meta] = await Promise.all([
|
||
loadPerspective(),
|
||
fetch(`/api/versions/${vid}/data`).then(async r => {
|
||
if (!r.ok) { const { error } = await r.json(); throw new Error(error || 'Failed to load data') }
|
||
const rowCount = parseInt(r.headers.get('X-Row-Count') || '0')
|
||
const total = parseInt(r.headers.get('Content-Length') || '0') || null
|
||
const reader = r.body.getReader()
|
||
const chunks = []
|
||
let received = 0
|
||
setLoadProgress({ received: 0, total })
|
||
while (true) {
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
chunks.push(value)
|
||
received += value.byteLength
|
||
setLoadProgress({ received, total })
|
||
}
|
||
const merged = new Uint8Array(received)
|
||
let pos = 0
|
||
for (const c of chunks) { merged.set(c, pos); pos += c.byteLength }
|
||
return { buffer: merged.buffer, rowCount }
|
||
}),
|
||
fetch(`/api/sources/${sid}/cols`).then(r => r.json()),
|
||
])
|
||
|
||
const { buffer, rowCount } = dataResult
|
||
colMetaRef.current = meta
|
||
const validCols = new Set([
|
||
...meta.filter(c => ['dimension','value','units','date'].includes(c.role)).map(c => c.cname),
|
||
'pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'created_at',
|
||
])
|
||
const tableName = `fc_${vid}`
|
||
|
||
if (rowCount >= 500000) setLargeDataset(true)
|
||
|
||
if (myId !== initIdRef.current) return
|
||
|
||
if (!workerRef.current) workerRef.current = await perspective.worker()
|
||
const worker = workerRef.current
|
||
|
||
if (tableRef.current) {
|
||
try { await tableRef.current.delete() } catch {}
|
||
tableRef.current = null
|
||
}
|
||
|
||
const opts = { name: tableName, index: 'pf_id' }
|
||
const makeTable = async () => rowCount > 0 ? worker.table(buffer, opts) : worker.table([], opts)
|
||
try {
|
||
tableRef.current = await makeTable()
|
||
} catch (err) {
|
||
if (/already exists/i.test(String(err?.message || err))) {
|
||
try {
|
||
const existing = await worker.open_table(tableName)
|
||
if (existing) await existing.delete()
|
||
} catch {}
|
||
tableRef.current = await makeTable()
|
||
} else {
|
||
throw err
|
||
}
|
||
}
|
||
|
||
if (myId !== initIdRef.current) {
|
||
try { await tableRef.current.delete() } catch {}
|
||
tableRef.current = null
|
||
return
|
||
}
|
||
|
||
await viewer.load(worker)
|
||
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
|
||
|
||
// restore last-used layout or build default
|
||
const saved = localStorage.getItem(LAYOUT_KEY(vid))
|
||
if (saved) {
|
||
const cfg = cleanLayout(JSON.parse(saved), validCols)
|
||
await viewer.restore(cfg)
|
||
const plugin = await viewer.getPlugin()
|
||
await plugin.restore({ edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) })
|
||
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
||
} else {
|
||
const dims = meta.filter(c => c.role === 'dimension').map(c => c.cname)
|
||
const dateCol = meta.find(c => c.role === 'date')?.cname
|
||
const cfg = { table: tableName, settings: false, plugin_config: { edit_mode: 'SELECT_REGION' } }
|
||
if (dims.length) cfg.group_by = dims.slice(0, 2)
|
||
if (dateCol) cfg.split_by = [dateCol]
|
||
await viewer.restore(cfg)
|
||
const plugin = await viewer.getPlugin()
|
||
await plugin.restore({ edit_mode: 'SELECT_REGION' })
|
||
}
|
||
|
||
// click → slice via event filters (Perspective encodes row position as [col,'==',val] triples)
|
||
if (viewer._pspClick) viewer.removeEventListener('perspective-click', viewer._pspClick)
|
||
viewer._pspClick = async (e) => {
|
||
const detail = e.detail || {}
|
||
if (!detail.row) return
|
||
const config = await viewer.save()
|
||
if (!(config.group_by || []).length) return
|
||
const eventFilters = (detail.config || {}).filter || []
|
||
const s = {}
|
||
eventFilters.forEach(([col, op, val]) => {
|
||
if (op === '==' && val != null) s[col] = String(val)
|
||
})
|
||
if (Object.keys(s).length > 0) setSlice(s)
|
||
}
|
||
viewer.addEventListener('perspective-click', viewer._pspClick)
|
||
setLargeDataset(false)
|
||
|
||
} catch (err) {
|
||
flash(err.message, 'error')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
async function applyDepth(d) {
|
||
const viewer = viewerRef.current
|
||
if (!viewer) return
|
||
const view = await viewer.getView()
|
||
await view.set_depth(d)
|
||
const plugin = await viewer.getPlugin()
|
||
await plugin.draw(view)
|
||
expandDepthRef.current = d
|
||
}
|
||
|
||
async function captureConfig() {
|
||
const viewer = viewerRef.current
|
||
if (!viewer) return null
|
||
const plugin = await viewer.getPlugin()
|
||
const [cfg, pluginCfg] = await Promise.all([viewer.save(), plugin.save()])
|
||
return { ...cfg, plugin_config: pluginCfg, expand_depth: expandDepthRef.current }
|
||
}
|
||
|
||
async function persistLayout(vid, cfg) {
|
||
localStorage.setItem(LAYOUT_KEY(vid), JSON.stringify(cfg))
|
||
}
|
||
|
||
async function handleSaveAs() {
|
||
const name = saveAsName.trim()
|
||
if (!name) return
|
||
const cfg = await captureConfig()
|
||
if (!cfg) return
|
||
const id = Date.now()
|
||
const updated = [...layouts, { id, name, config: cfg }]
|
||
localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
|
||
await persistLayout(versionId, cfg)
|
||
setLayouts(updated)
|
||
setActiveLayoutId(id)
|
||
setShowSaveAs(false)
|
||
setSaveAsName('')
|
||
flash('Saved')
|
||
}
|
||
|
||
async function handleSaveOver() {
|
||
const layout = layouts.find(l => l.id === activeLayoutId)
|
||
if (!layout) return
|
||
const cfg = await captureConfig()
|
||
if (!cfg) return
|
||
const updated = layouts.map(l => l.id === activeLayoutId ? { ...l, config: cfg } : l)
|
||
localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
|
||
await persistLayout(versionId, cfg)
|
||
setLayouts(updated)
|
||
flash('Saved')
|
||
}
|
||
|
||
async function applyLayout(layout) {
|
||
const viewer = viewerRef.current
|
||
if (!viewer) return
|
||
const validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : [])
|
||
const cfg = cleanLayout(layout.config, validCols)
|
||
await viewer.restore(cfg)
|
||
if (cfg.plugin_config) {
|
||
const plugin = await viewer.getPlugin()
|
||
await plugin.restore(cfg.plugin_config)
|
||
}
|
||
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
||
setActiveLayoutId(layout.id)
|
||
await persistLayout(versionId, cfg)
|
||
}
|
||
|
||
function deleteLayout(id, e) {
|
||
e.stopPropagation()
|
||
const updated = layouts.filter(l => l.id !== id)
|
||
localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
|
||
setLayouts(updated)
|
||
if (activeLayoutId === id) setActiveLayoutId(null)
|
||
}
|
||
|
||
function resetLayout() {
|
||
localStorage.removeItem(LAYOUT_KEY(versionId))
|
||
setActiveLayoutId(null)
|
||
const viewer = viewerRef.current
|
||
if (viewer) viewer.restore({ settings: true })
|
||
}
|
||
|
||
async function submitOp(op) {
|
||
if (!Object.keys(slice).length) { flash('Select a slice first', 'error'); return }
|
||
let body = { pf_user: 'admin', slice }
|
||
|
||
if (op === 'scale') {
|
||
let vi = null, ui = null
|
||
if (scaleMode === 'target') {
|
||
if (scaleValue !== '' && currentTotals?.value != null)
|
||
vi = parseFloat(scaleValue) - currentTotals.value
|
||
if (scaleUnits !== '' && currentTotals?.units != null)
|
||
ui = parseFloat(scaleUnits) - currentTotals.units
|
||
} else {
|
||
if (scaleValue !== '') vi = scalePct ? parseFloat(scaleValue) : parseFloat(scaleValue)
|
||
if (scaleUnits !== '') ui = scalePct ? parseFloat(scaleUnits) : parseFloat(scaleUnits)
|
||
}
|
||
if (vi == null && ui == null) { flash('Enter a target or increment', 'error'); return }
|
||
body = { ...body, note: scaleNote, value_incr: vi, units_incr: ui, pct: scaleMode === 'delta' && scalePct }
|
||
} else if (op === 'recode') {
|
||
const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
|
||
if (!Object.keys(set).length) { flash('Enter at least one new dimension value', 'error'); return }
|
||
body = { ...body, note: recodeNote, set }
|
||
} else if (op === 'clone') {
|
||
const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim()))
|
||
if (!Object.keys(set).length) { flash('Enter at least one override value', 'error'); return }
|
||
body = { ...body, note: cloneNote, set, scale: parseFloat(cloneScale) || 1 }
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(`/api/versions/${versionId}/${op}`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok) { flash(data.error, 'error'); return }
|
||
if (data.rows?.length && tableRef.current) await tableRef.current.update(data.rows)
|
||
flash(`${op}: ${data.rows_affected ?? data.rows?.length ?? ''} rows`)
|
||
if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScaleNote(''); fetchCurrentTotals(slice) }
|
||
if (op === 'recode') { setRecodeNote('') }
|
||
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
|
||
} catch (err) { flash(err.message, 'error') }
|
||
}
|
||
|
||
function flash(text, type = 'ok') {
|
||
setMsg({ text, type })
|
||
setTimeout(() => setMsg(null), 3000)
|
||
}
|
||
|
||
async function openLog() {
|
||
setShowLog(true)
|
||
setLogLoading(true)
|
||
try {
|
||
const data = await fetch(`/api/versions/${versionId}/log`).then(r => r.json())
|
||
setLogEntries(data)
|
||
} catch (err) {
|
||
flash(err.message, 'error')
|
||
} finally {
|
||
setLogLoading(false)
|
||
}
|
||
}
|
||
|
||
async function undoEntry(logId) {
|
||
setUndoingId(logId)
|
||
try {
|
||
const res = await fetch(`/api/log/${logId}`, { method: 'DELETE' })
|
||
const data = await res.json()
|
||
if (!res.ok) { flash(data.error, 'error'); return }
|
||
setLogEntries(prev => prev.filter(e => e.id !== logId))
|
||
if (data.pf_ids?.length && tableRef.current) {
|
||
await tableRef.current.remove(data.pf_ids)
|
||
}
|
||
flash(`Undone — ${data.rows_deleted} rows removed`)
|
||
} catch (err) {
|
||
flash(err.message, 'error')
|
||
} finally {
|
||
setUndoingId(null)
|
||
}
|
||
}
|
||
|
||
async function saveNote(logId, text) {
|
||
try {
|
||
const res = await fetch(`/api/log/${logId}`, {
|
||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ note: text })
|
||
})
|
||
if (!res.ok) { flash('Failed to save note', 'error'); return }
|
||
setLogEntries(prev => prev.map(e => e.id === logId ? { ...e, note: text } : e))
|
||
setEditingNote(null)
|
||
} catch (err) {
|
||
flash(err.message, 'error')
|
||
}
|
||
}
|
||
|
||
const dimCols = colMetaRef.current.filter(c => c.role === 'dimension')
|
||
const hasSlice = Object.keys(slice).length > 0
|
||
|
||
return (
|
||
<div className="h-full flex flex-col">
|
||
|
||
{/* Toolbar */}
|
||
<div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap text-xs">
|
||
|
||
{/* Layout group */}
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Layout</span>
|
||
{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.name}
|
||
<button onClick={e => deleteLayout(l.id, e)} className="text-gray-300 hover:text-red-400 text-sm leading-none ml-0.5">×</button>
|
||
</div>
|
||
))}
|
||
{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-32 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 px-1">Cancel</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{activeLayoutId !== null && (
|
||
<button onClick={handleSaveOver} className="border border-blue-200 text-blue-500 hover:text-blue-700 rounded px-2 py-0.5">Save</button>
|
||
)}
|
||
<button onClick={() => setShowSaveAs(true)} className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5">
|
||
Save as…
|
||
</button>
|
||
{activeLayoutId !== null && (
|
||
<button onClick={resetLayout} className="text-gray-300 hover:text-red-400">Reset</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||
|
||
{/* Expand group */}
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Expand</span>
|
||
{[0, 1, 2, 3].map(d => (
|
||
<button key={d} onClick={() => applyDepth(d)}
|
||
className={`border rounded px-1.5 py-0.5 transition-colors
|
||
${expandDepthRef.current === d ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
|
||
{d}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||
|
||
{/* Data group */}
|
||
<div className="flex items-center gap-1.5">
|
||
<button onClick={() => initViewer(versionId, sourceId)} disabled={loading || !versionId}
|
||
className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
|
||
{loading ? 'Loading…' : 'Refresh data'}
|
||
</button>
|
||
<button onClick={openLog} disabled={!versionId}
|
||
className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
|
||
Change log
|
||
</button>
|
||
</div>
|
||
|
||
{msg && (
|
||
<span className={`ml-2 text-xs font-medium px-2 py-0.5 rounded ${msg.type === 'error' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>
|
||
{msg.text}
|
||
</span>
|
||
)}
|
||
|
||
</div>
|
||
|
||
{/* History modal */}
|
||
{showLog && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setShowLog(false)}>
|
||
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl mx-4 flex flex-col max-h-[80vh]" onClick={e => e.stopPropagation()}>
|
||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
|
||
<span className="font-medium text-gray-700 text-sm">Change History</span>
|
||
<button onClick={() => setShowLog(false)} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||
</div>
|
||
|
||
<div className="overflow-y-auto flex-1">
|
||
{logLoading ? (
|
||
<div className="p-8 text-center text-sm text-gray-400">Loading…</div>
|
||
) : logEntries.length === 0 ? (
|
||
<div className="p-8 text-center text-sm text-gray-400">No log entries yet.</div>
|
||
) : (
|
||
<table className="w-full text-xs border-collapse">
|
||
<thead className="sticky top-0 bg-gray-50 text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>
|
||
<tr>
|
||
<th className="text-left px-4 py-2 font-medium w-32">Time</th>
|
||
<th className="text-left px-4 py-2 font-medium w-24">Op</th>
|
||
<th className="text-left px-4 py-2 font-medium">Slice</th>
|
||
<th className="text-left px-4 py-2 font-medium">Note</th>
|
||
<th className="text-right px-4 py-2 font-medium w-16">Rows</th>
|
||
<th className="px-4 py-2 w-16"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{logEntries.map(entry => (
|
||
<tr key={entry.id} className="border-t border-gray-100 hover:bg-gray-50">
|
||
<td className="px-4 py-2 text-gray-400 whitespace-nowrap">{fmtStamp(entry.stamp)}</td>
|
||
<td className="px-4 py-2">
|
||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${opBadge(entry.operation)}`}>
|
||
{entry.operation}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-2 text-gray-600 font-mono">{fmtSlice(entry.slice)}</td>
|
||
<td className="px-4 py-2 text-gray-600 max-w-xs">
|
||
{editingNote?.id === entry.id ? (
|
||
<div className="flex items-center gap-1">
|
||
<input autoFocus value={editingNote.text}
|
||
onChange={e => setEditingNote(n => ({ ...n, text: e.target.value }))}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') saveNote(entry.id, editingNote.text)
|
||
if (e.key === 'Escape') setEditingNote(null)
|
||
}}
|
||
className="border border-blue-300 rounded px-1.5 py-0.5 text-xs flex-1 focus:outline-none" />
|
||
<button onClick={() => saveNote(entry.id, editingNote.text)} className="text-blue-600 hover:text-blue-800">✓</button>
|
||
<button onClick={() => setEditingNote(null)} className="text-gray-400 hover:text-gray-600">✕</button>
|
||
</div>
|
||
) : (
|
||
<span onClick={() => setEditingNote({ id: entry.id, text: entry.note || '' })}
|
||
className="cursor-text hover:bg-blue-50 rounded px-1 -mx-1 block truncate"
|
||
title={entry.note || 'Click to add note'}>
|
||
{entry.note || <span className="text-gray-300 italic">add note</span>}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-2 text-right text-gray-500 tabular-nums">{entry.row_count ?? '—'}</td>
|
||
<td className="px-4 py-2">
|
||
<button
|
||
onClick={() => undoEntry(entry.id)}
|
||
disabled={undoingId === entry.id}
|
||
className="text-xs border border-red-200 text-red-400 hover:text-red-600 hover:border-red-400 rounded px-2 py-0.5 disabled:opacity-40 whitespace-nowrap">
|
||
{undoingId === entry.id ? '…' : 'Undo'}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Main area */}
|
||
<div className="flex-1 flex min-h-0">
|
||
{/* Perspective viewer */}
|
||
<div className="relative flex-1 min-w-0">
|
||
{loading && (
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-50 z-10 gap-2">
|
||
<span className="text-sm text-gray-400">Loading…</span>
|
||
{loadProgress && (
|
||
<>
|
||
<span className="text-xs text-gray-400 font-mono">
|
||
{fmtBytes(loadProgress.received)}
|
||
{loadProgress.total ? ` / ${fmtBytes(loadProgress.total)}` : ''}
|
||
</span>
|
||
{loadProgress.total > 0 && (
|
||
<div className="w-48 h-1 bg-gray-200 rounded overflow-hidden">
|
||
<div
|
||
className="h-full bg-blue-400 transition-all"
|
||
style={{ width: `${Math.min(100, (loadProgress.received / loadProgress.total) * 100)}%` }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
{!loading && largeDataset && (
|
||
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-10 bg-amber-50 border border-amber-200 text-amber-800 text-xs px-3 py-1.5 rounded shadow-sm">
|
||
Large dataset — pivot may take a moment to render
|
||
</div>
|
||
)}
|
||
<perspective-viewer ref={viewerRef} style={{ position: 'absolute', inset: 0 }} />
|
||
</div>
|
||
|
||
{/* Drag handle */}
|
||
<div onMouseDown={onDragStart} className="w-1 shrink-0 cursor-col-resize hover:bg-blue-400 bg-transparent transition-colors" />
|
||
|
||
{/* Operation panel */}
|
||
<div className="shrink-0 border-l border-gray-200 bg-white flex flex-col overflow-y-auto text-xs" style={{ width: panelWidth }}>
|
||
<div className="p-3 border-b border-gray-100">
|
||
<div className="font-medium text-gray-400 uppercase tracking-wide mb-2" style={{fontSize:'10px'}}>Slice</div>
|
||
{!hasSlice ? (
|
||
<div className="text-gray-300 italic">Click a pivot row to select a slice</div>
|
||
) : (
|
||
<div className="flex flex-col gap-1">
|
||
{Object.entries(slice).map(([k, v]) => (
|
||
<div key={k} className="text-gray-700">
|
||
<span className="text-gray-400">{k}</span> = <span className="font-medium">{v}</span>
|
||
</div>
|
||
))}
|
||
<button onClick={() => setSlice({})} className="text-gray-300 hover:text-red-500 mt-1 text-left">Clear</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{hasSlice && (
|
||
<>
|
||
<div className="flex border-b border-gray-100">
|
||
{['scale', 'recode', 'clone'].map(op => (
|
||
<button key={op} onClick={() => setActiveOp(op)}
|
||
className={`flex-1 py-2 capitalize ${activeOp === op ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-400 hover:text-gray-600'}`}>
|
||
{op}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="p-3 flex flex-col gap-2.5">
|
||
{activeOp === 'scale' && <>
|
||
{/* Mode toggle */}
|
||
<div className="flex rounded border border-gray-200 overflow-hidden">
|
||
{[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => (
|
||
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits('') }}
|
||
className={`flex-1 py-1 text-xs ${scaleMode === m ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Value row */}
|
||
{currentTotals?.valueCol && (
|
||
<div className="flex flex-col gap-1">
|
||
<div className="flex items-center justify-between text-gray-400">
|
||
<span>{currentTotals.valueCol}</span>
|
||
<span className="font-mono">{currentTotals.value?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
|
||
</div>
|
||
<input type="number" step="any" value={scaleValue}
|
||
onChange={e => setScaleValue(e.target.value)}
|
||
placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
|
||
className={inp} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Units row */}
|
||
{currentTotals?.unitsCol && (
|
||
<div className="flex flex-col gap-1">
|
||
<div className="flex items-center justify-between text-gray-400">
|
||
<span>{currentTotals.unitsCol}</span>
|
||
<span className="font-mono">{currentTotals.units?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
|
||
</div>
|
||
<input type="number" step="any" value={scaleUnits}
|
||
onChange={e => setScaleUnits(e.target.value)}
|
||
placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
|
||
className={inp} />
|
||
</div>
|
||
)}
|
||
|
||
{scaleMode === 'delta' && (
|
||
<label className="flex items-center gap-2 text-gray-500">
|
||
<input type="checkbox" checked={scalePct} onChange={e => setScalePct(e.target.checked)} /> % of slice
|
||
</label>
|
||
)}
|
||
|
||
<Row label="Note"><input value={scaleNote} onChange={e => setScaleNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
||
<Submit onClick={() => submitOp('scale')}>Apply Scale</Submit>
|
||
</>}
|
||
|
||
{activeOp === 'recode' && <>
|
||
<p className="text-gray-400">New values for dimensions to replace. Leave blank to keep.</p>
|
||
{dimCols.map(c => (
|
||
<Row key={c.cname} label={c.cname}>
|
||
<input value={recodeSet[c.cname] || ''} onChange={e => setRecodeSet(s => ({ ...s, [c.cname]: e.target.value }))}
|
||
placeholder={slice[c.cname] || '—'} className={`${inp} font-mono`} />
|
||
</Row>
|
||
))}
|
||
<Row label="Note"><input value={recodeNote} onChange={e => setRecodeNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
||
<Submit onClick={() => submitOp('recode')}>Apply Recode</Submit>
|
||
</>}
|
||
|
||
{activeOp === 'clone' && <>
|
||
<p className="text-gray-400">Override dimensions on cloned rows. Leave blank to keep.</p>
|
||
{dimCols.map(c => (
|
||
<Row key={c.cname} label={c.cname}>
|
||
<input value={cloneSet[c.cname] || ''} onChange={e => setCloneSet(s => ({ ...s, [c.cname]: e.target.value }))}
|
||
placeholder={slice[c.cname] || '—'} className={`${inp} font-mono`} />
|
||
</Row>
|
||
))}
|
||
<Row label="Scale"><input type="number" step="any" value={cloneScale} onChange={e => setCloneScale(e.target.value)} className={inp} /></Row>
|
||
<Row label="Note"><input value={cloneNote} onChange={e => setCloneNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
||
<Submit onClick={() => submitOp('clone')}>Apply Clone</Submit>
|
||
</>}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const inp = 'border border-gray-200 rounded px-2 py-1 text-xs flex-1 bg-white min-w-0'
|
||
|
||
function fmtBytes(n) {
|
||
if (n < 1024) return `${n} B`
|
||
if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`
|
||
return `${(n / 1048576).toFixed(1)} MB`
|
||
}
|
||
|
||
function fmtStamp(stamp) {
|
||
return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||
}
|
||
|
||
function fmtSlice(slice) {
|
||
if (!slice || !Object.keys(slice).length) return '—'
|
||
return Object.entries(slice).map(([k, v]) => `${k} = ${v}`).join(', ')
|
||
}
|
||
|
||
const OP_BADGE = {
|
||
baseline: 'bg-gray-100 text-gray-600',
|
||
reference: 'bg-blue-50 text-blue-600',
|
||
scale: 'bg-green-50 text-green-700',
|
||
recode: 'bg-amber-50 text-amber-700',
|
||
clone: 'bg-purple-50 text-purple-700',
|
||
}
|
||
function opBadge(op) { return OP_BADGE[op] || 'bg-gray-100 text-gray-500' }
|
||
|
||
function Row({ label, children }) {
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-gray-400 w-14 shrink-0 truncate" title={label}>{label}</span>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Submit({ onClick, children }) {
|
||
return (
|
||
<button onClick={onClick} className="mt-1 bg-blue-600 text-white text-xs px-3 py-1.5 rounded hover:bg-blue-700 w-full">
|
||
{children}
|
||
</button>
|
||
)
|
||
}
|