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 (
{/* Toolbar */}
{/* Layout group */}
Layout {layouts.map(l => (
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}
))} {showSaveAs ? (
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" />
) : ( <> {activeLayoutId !== null && ( )} {activeLayoutId !== null && ( )} )}
{/* Expand group */}
Expand {[0, 1, 2, 3].map(d => ( ))}
{/* Data group */}
{msg && ( {msg.text} )}
{/* History modal */} {showLog && (
setShowLog(false)}>
e.stopPropagation()}>
Change History
{logLoading ? (
Loading…
) : logEntries.length === 0 ? (
No log entries yet.
) : ( {logEntries.map(entry => ( ))}
Time Op Slice Note Rows
{fmtStamp(entry.stamp)} {entry.operation} {fmtSlice(entry.slice)} {editingNote?.id === entry.id ? (
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" />
) : ( 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 || add note} )}
{entry.row_count ?? '—'}
)}
)} {/* Main area */}
{/* Perspective viewer */}
{loading && (
Loading… {loadProgress && ( <> {fmtBytes(loadProgress.received)} {loadProgress.total ? ` / ${fmtBytes(loadProgress.total)}` : ''} {loadProgress.total > 0 && (
)} )}
)} {!loading && largeDataset && (
Large dataset — pivot may take a moment to render
)}
{/* Drag handle */}
{/* Operation panel */}
Slice
{!hasSlice ? (
Click a pivot row to select a slice
) : (
{Object.entries(slice).map(([k, v]) => (
{k} = {v}
))}
)}
{hasSlice && ( <>
{['scale', 'recode', 'clone'].map(op => ( ))}
{activeOp === 'scale' && <> {/* Mode toggle */}
{[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => ( ))}
{/* Value row */} {currentTotals?.valueCol && (
{currentTotals.valueCol} {currentTotals.value?.toLocaleString(undefined, {maximumFractionDigits:2})}
setScaleValue(e.target.value)} placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'} className={inp} />
)} {/* Units row */} {currentTotals?.unitsCol && (
{currentTotals.unitsCol} {currentTotals.units?.toLocaleString(undefined, {maximumFractionDigits:2})}
setScaleUnits(e.target.value)} placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'} className={inp} />
)} {scaleMode === 'delta' && ( )} setScaleNote(e.target.value)} placeholder="optional" className={inp} /> submitOp('scale')}>Apply Scale } {activeOp === 'recode' && <>

New values for dimensions to replace. Leave blank to keep.

{dimCols.map(c => ( setRecodeSet(s => ({ ...s, [c.cname]: e.target.value }))} placeholder={slice[c.cname] || '—'} className={`${inp} font-mono`} /> ))} setRecodeNote(e.target.value)} placeholder="optional" className={inp} /> submitOp('recode')}>Apply Recode } {activeOp === 'clone' && <>

Override dimensions on cloned rows. Leave blank to keep.

{dimCols.map(c => ( setCloneSet(s => ({ ...s, [c.cname]: e.target.value }))} placeholder={slice[c.cname] || '—'} className={`${inp} font-mono`} /> ))} setCloneScale(e.target.value)} className={inp} /> setCloneNote(e.target.value)} placeholder="optional" className={inp} /> submitOp('clone')}>Apply Clone }
)}
) } 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 (
{label} {children}
) } function Submit({ onClick, children }) { return ( ) }