import { useState, useEffect, useRef } from 'react' 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() { const [sources, setSources] = useState([]) const [sourceId, setSourceId] = useState('') const [versions, setVersions] = useState([]) const [versionId, setVersionId] = useState('') const [loading, setLoading] = useState(false) 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) 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(() => { fetch('/api/sources').then(r => r.json()).then(data => { setSources(data) if (data.length > 0) setSourceId(String(data[0].id)) }) }, []) useEffect(() => { if (!sourceId) return fetch(`/api/sources/${sourceId}/versions`).then(r => r.json()).then(data => { setVersions(data) setVersionId(data.length > 0 ? String(data[0].id) : '') }) }, [sourceId]) useEffect(() => { if (!versionId || !sourceId) return loadLayouts(versionId) initViewer(versionId, sourceId) }, [versionId, sourceId]) 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 filters = [ ...Object.entries(sliceObj).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 setLoading(true) setSlice({}) expandDepthRef.current = null try { const [perspective, rows, meta] = await Promise.all([ loadPerspective(), fetch(`/api/versions/${vid}/data`).then(r => r.json()), fetch(`/api/sources/${sid}/cols`).then(r => r.json()), ]) colMetaRef.current = meta const validCols = new Set(rows.length ? Object.keys(rows[0]) : []) const tableName = `fc_${vid}` if (workerRef.current) { try { workerRef.current.terminate() } catch {} } const worker = await perspective.worker() workerRef.current = worker tableRef.current = await worker.table(rows, { name: tableName }) await viewer.load(worker) // 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) } 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 (await tableRef.current.view()).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)) flash(`Undone — ${data.rows_deleted} rows removed`) initViewer(versionId, sourceId) } 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 selectedVersion = versions.find(v => String(v.id) === versionId) 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 ?? '—'} |
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 => (