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 [scalePrice, setScalePrice] = 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('') setScalePrice('') 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 buckets = new Map() for (const r of rows) { const k = r.pf_iter || '?' const t = buckets.get(k) || { value: 0, units: 0 } if (valueCol) t.value += parseFloat(r[valueCol]) || 0 if (unitsCol) t.units += parseFloat(r[unitsCol]) || 0 buckets.set(k, t) } const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone'] const byIter = Array.from(buckets, ([iter, t]) => ({ iter, ...t })) .sort((a, b) => { const ai = ITER_ORDER.indexOf(a.iter), bi = ITER_ORDER.indexOf(b.iter) return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi) }) const total = byIter.reduce((s, r) => ({ value: s.value + (r.value || 0), units: s.units + (r.units || 0) }), { value: 0, units: 0 }) setCurrentTotals({ byIter, total, 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) cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) } await viewer.restore(cfg) if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) } else { const valueCol = meta.find(c => c.role === 'value')?.cname const cfg = { table: tableName, settings: false, group_by: ['pf_iter'], columns: valueCol ? [valueCol] : [], plugin_config: { edit_mode: 'SELECT_REGION' } } await viewer.restore(cfg) } // auto-persist viewer state (formatting, columns, etc.) to the last-used cache if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate) viewer._pspUpdate = async () => { try { const cfg = await captureConfig() if (cfg) await persistLayout(vid, cfg) } catch {} } viewer.addEventListener('perspective-config-update', viewer._pspUpdate) // 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 cfg = await viewer.save() return { ...cfg, 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) cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) } await viewer.restore(cfg) 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') { const curValue = currentTotals?.total?.value const curUnits = currentTotals?.total?.units if (scalePrice !== '' && curUnits != null && curValue != null) { // hold units constant; new value = price × current units vi = (parseFloat(scalePrice) * curUnits) - curValue } if (scaleValue !== '' && curValue != null) vi = parseFloat(scaleValue) - curValue if (scaleUnits !== '' && curUnits != null) ui = parseFloat(scaleUnits) - curUnits } 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(''); setScalePrice(''); 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 (
| 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 ?? '—'} |
| {currentTotals.valueCol && | {currentTotals.valueCol} | } {currentTotals.unitsCol &&{currentTotals.unitsCol} | } {currentTotals.valueCol && currentTotals.unitsCol &&price | }
|---|---|---|---|
| {r.iter} | {currentTotals.valueCol &&{fmtNum(r.value)} | } {currentTotals.unitsCol &&{fmtNum(r.units)} | } {currentTotals.valueCol && currentTotals.unitsCol &&{fmtNum(r.units ? r.value / r.units : null, 4)} | }
| total | {currentTotals.valueCol &&{fmtNum(currentTotals.total.value)} | } {currentTotals.unitsCol &&{fmtNum(currentTotals.total.units)} | } {currentTotals.valueCol && currentTotals.unitsCol &&{fmtNum(currentTotals.total.units ? currentTotals.total.value / currentTotals.total.units : null, 4)} | }
New values for dimensions to replace. Leave blank to keep.
{dimCols.map(c => (Override dimensions on cloned rows. Leave blank to keep.
{dimCols.map(c => (