pf_app/ui/src/views/Forecast.jsx
Paul Trowbridge 0520e5e542 Wait for the viewer's table before re-applying row depth on refocus
The instrumentation showed the re-apply mostly throwing "No table set": the
viewer is detached from its table for a while after a refocus, the fixed
60ms delay fired inside that window, set_depth threw, and the tree rendered
fully expanded. The handful of times the delay happened to be long enough,
the log reads "re-apply done depth=0" and the collapse survived.

So poll for the table instead of guessing, up to 5s. queued now clears in a
finally after the work rather than at the top of the callback, so the focus
and visibilitychange that both fire for one window switch no longer each
run a re-apply -- that was the doubled applyDepth in the log.

Does not address per-node +/- collapse, which is still not recorded at all:
expandDepthRef stays null because only the toolbar buttons set it, and the
view exposes expand()/collapse() with no getter to read the state back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:26:46 -04:00

1617 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from 'react'
import useTheme from '../theme.jsx'
import OperationPanel from '../components/OperationPanel.jsx'
import BridgeView from '../components/BridgeView.jsx'
// Perspective is bundled, not fetched at runtime. The /inline entrypoints embed the
// WASM in the build, so the version is fixed by package-lock.json. Do NOT go back to
// CDN <script>/import URLs: the 4.x CDN bundle resolves its server WASM to an
// unversioned path, so it silently pulls whatever @perspective-dev/server is newest.
import perspective from '@perspective-dev/client/inline'
import '@perspective-dev/viewer/inline'
import '@perspective-dev/viewer-datagrid'
import '@perspective-dev/viewer/themes'
const LAYOUT_KEY = (vid) => `pf_layout_v${vid}` // last-used layout (auto restore)
const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // named layout list
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)
// the uncollapsed column hierarchy travels with the layout (see applySplitDepth)
if (c.split_full) c.split_full = c.split_full.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({ sources = [], sourceId, versions = [], versionId, refreshSources }) {
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 — a selection is a LIST of slices; one entry is the common case
// The column hierarchy and how many of its levels are showing. Mirrored into
// splitFullRef for handlers registered once; held as state so the toolbar
// re-renders when either changes.
const [splitFull, setSplitFull] = useState([])
const [splitDepth, setSplitDepth] = useState(null)
const [slices, setSlices] = useState([])
const [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each'
const [activeOp, setActiveOp] = useState('scale')
const [currentTotals, setCurrentTotals] = useState(null) // { value, units }
// One entry per measure ('value' | 'units' | 'price'), each { field, raw } where
// field is 'new' | 'change' | 'pct'. There is no separate mode toggle: the row
// you type in decides how the number is interpreted, and the other two rows of
// that measure are derived from it.
const [scaleInputs, setScaleInputs] = useState({})
// what a target/percentage is measured against: the rows this operation can
// write, or everything the pivot shows for the slice (excluded rows included)
const [targetBasis, setTargetBasis] = useState('selected')
// one tag spans all three operations — an initiative is not per-operation
const [opTag, setOpTag] = useState('')
const [knownTags, setKnownTags] = useState([])
// logid -> { tag, note, operation }, so ledger lines can name themselves
const [logMeta, setLogMeta] = useState({})
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('')
// panel placement: 'bottom' | 'right' | 'float'. Persisted so it stays where you put it.
const [dock, setDock] = useState(() => localStorage.getItem('pf_dock') || 'bottom')
const [panelOpen, setPanelOpen] = useState(() => localStorage.getItem('pf_panel_open') !== 'closed')
const [panelWidth, setPanelWidth] = useState(() => Number(localStorage.getItem('pf_panel_w')) || 360)
const [panelHeight, setPanelHeight] = useState(() => Number(localStorage.getItem('pf_panel_h')) || 260)
useEffect(() => { localStorage.setItem('pf_dock', dock) }, [dock])
useEffect(() => { localStorage.setItem('pf_panel_open', panelOpen ? 'open' : 'closed') }, [panelOpen])
// Esc closes the panel — the usual way out of the floating window
useEffect(() => {
if (!panelOpen) return
const onKey = (e) => {
if (e.key !== 'Escape') return
const t = e.target
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
setPanelOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [panelOpen])
useEffect(() => { localStorage.setItem('pf_panel_w', String(panelWidth)) }, [panelWidth])
useEffect(() => { localStorage.setItem('pf_panel_h', String(panelHeight)) }, [panelHeight])
// Floating panel geometry — free position and size, unlike the docked modes
// which only have one adjustable axis. null until first opened, so the default
// can be placed against the actual viewport.
const [floatRect, setFloatRect] = useState(() => {
try {
const saved = JSON.parse(localStorage.getItem('pf_float_rect') || 'null')
if (saved && saved.w > 0 && saved.h > 0) return saved
} catch {}
return null
})
useEffect(() => {
if (floatRect) localStorage.setItem('pf_float_rect', JSON.stringify(floatRect))
}, [floatRect])
// keep at least a corner of the window on screen, whatever the viewport does
function clampRect(r) {
const w = Math.max(300, Math.min(r.w, window.innerWidth - 16))
const h = Math.max(180, Math.min(r.h, window.innerHeight - 16))
const KEEP = 100 // px of the window that must stay reachable
return {
w, h,
x: Math.max(KEEP - w, Math.min(r.x, window.innerWidth - KEEP)),
y: Math.max(0, Math.min(r.y, window.innerHeight - 40)),
}
}
useEffect(() => {
if (dock !== 'float' || floatRect) return
const w = 440, h = Math.min(460, window.innerHeight - 120)
setFloatRect(clampRect({ x: window.innerWidth - w - 24, y: window.innerHeight - h - 24, w, h }))
}, [dock, floatRect])
useEffect(() => {
if (dock !== 'float') return
const onResize = () => setFloatRect(r => r ? clampRect(r) : r)
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [dock])
// shared pointer-drag loop for both moving and resizing the floating window
function startFloatDrag(e, apply) {
if (!floatRect) return
e.preventDefault()
const sx = e.clientX, sy = e.clientY
const r0 = floatRect
const onMove = (ev) => setFloatRect(clampRect(apply(r0, ev.clientX - sx, ev.clientY - sy)))
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
document.body.style.userSelect = ''
}
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
const onFloatMove = (e) => {
if (e.target.closest('button')) return // let the dock switcher work
startFloatDrag(e, (r, dx, dy) => ({ ...r, x: r.x + dx, y: r.y + dy }))
}
const onFloatResize = (e) => {
e.stopPropagation()
startFloatDrag(e, (r, dx, dy) => ({ ...r, w: r.w + dx, h: r.h + dy }))
}
// history modal
const [showLog, setShowLog] = useState(false)
const [showBridge, setShowBridge] = useState(false)
const [logEntries, setLogEntries] = useState([])
const [logLoading, setLogLoading] = useState(false)
const [editingCell, setEditingCell] = useState(null) // { id, field: 'note'|'tag', 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)
// The column axis has no set_depth() — collapsing it means restoring a shorter
// split_by, so the full hierarchy has to be remembered separately to expand again.
const splitFullRef = useRef([])
// set while our own restore is in flight, so the config-update listener can tell
// a collapse from the user rearranging split_by themselves
const collapsingRef = useRef(false)
const initIdRef = useRef(0)
const modifierRef = useRef(false)
// the datagrid plugin element, for reading cell coordinates and driving its
// own selection highlight (see the selection-highlight effect below)
const gridRef = useRef(null)
// { x, y } of the body cell the last mousedown landed on, or null
const downCellRef = useRef(null)
// the region a drag is building, as a Perspective ViewWindow; committed on mouseup
const regionRef = useRef(null)
// sliceKey -> the grid rectangles that produced it. Kept parallel to `slices` so
// deselecting a slice removes exactly the cells that contributed it.
const areasRef = useRef(new Map())
// the current selection, readable from event handlers that were registered once
const slicesRef = useRef([])
// Perspective's ROLLUP view contains every level of the hierarchy; view.set_depth()
// is the only thing hiding the deeper ones, and it lives on the view rather than in
// the saved config. The viewer rebuilds its view when it redraws — which its
// Intersection/ResizeObserver triggers when you switch back to the tab — and the
// fresh view has no depth set, so the whole tree appears expanded. Re-apply the
// depth we last set once the redraw has settled.
// ---------------------------------------------------------------- DEBUG
// Temporary instrumentation for the "row groups re-expand on refocus" bug.
// set_depth() and per-node expand/collapse live on the *view*, not in
// ViewConfig, so a rebuilt view loses both. These lines exist to show whether
// that is what happens. Remove once the cause is known.
const viewSeqRef = useRef(0)
const viewIdsRef = useRef(new WeakMap())
const lastViewIdRef = useRef(null)
function dbg(msg, extra) {
const t = new Date().toISOString().slice(11, 23)
if (extra !== undefined) console.log(`[pf-depth ${t}] ${msg}`, extra)
else console.log(`[pf-depth ${t}] ${msg}`)
}
function viewId(v) {
if (!v) return 'none'
if (!viewIdsRef.current.has(v)) viewIdsRef.current.set(v, ++viewSeqRef.current)
return '#' + viewIdsRef.current.get(v)
}
// Read the viewer's current view and report whether it is the same object we
// saw last time. A changed id means the view was discarded and rebuilt.
async function probeView(where) {
const viewer = viewerRef.current
if (!viewer) return dbg(`probe(${where}): no viewer`)
try {
const v = await viewer.getView()
const id = viewId(v)
const changed = lastViewIdRef.current !== null && lastViewIdRef.current !== id
dbg(`probe(${where}): view ${id}${changed ? ` <-- REBUILT (was ${lastViewIdRef.current})` : ''}`)
lastViewIdRef.current = id
} catch (err) { dbg(`probe(${where}) threw`, err) }
}
// The viewer is briefly detached from its table around a refocus, and anything
// touching the view in that window throws "No table set". Poll until it is back
// rather than guessing at a delay.
async function waitForTable(deadlineMs = 5000, stepMs = 60) {
const started = Date.now()
let attempts = 0
while (Date.now() - started < deadlineMs) {
attempts++
try {
const viewer = viewerRef.current
if (viewer && await viewer.getTable()) {
if (attempts > 1) dbg(`table re-attached after ${attempts} polls / ${Date.now() - started}ms`)
return true
}
} catch { /* not yet */ }
await new Promise(r => setTimeout(r, stepMs))
}
return false
}
// -------------------------------------------------------------- END DEBUG
useEffect(() => {
let queued = false
const reapply = async (ev) => {
dbg(`event ${ev?.type || '?'}`, {
visibility: document.visibilityState,
expandDepthRef: expandDepthRef.current,
hasViewer: !!viewerRef.current,
})
// Probe the view identity before touching anything: if the id has changed
// since the last log line, the viewer rebuilt its view and that is where
// the depth went, rather than the re-apply below losing a race.
await probeView('on ' + (ev?.type || '?'))
if (document.visibilityState !== 'visible') return dbg('bail: not visible')
if (!viewerRef.current) return dbg('bail: no viewer')
if (expandDepthRef.current == null) return dbg('bail: no depth recorded (toolbar EXPAND never used, or per-node +/- only)')
if (queued) return dbg('bail: already queued')
queued = true
// Let the viewer finish its own redraw first, then wait for it to actually
// have a table again. A fixed delay loses this race most of the time -- the
// viewer is detached from its table for a while after a refocus, and
// set_depth on a detached viewer throws "No table set" and the tree renders
// fully expanded. queued stays set until the work is done, so a focus and a
// visibilitychange for the same switch don't both re-apply.
requestAnimationFrame(async () => {
try {
const d = expandDepthRef.current
if (d == null) return dbg('re-apply aborted: depth became null')
if (!(await waitForTable())) return dbg('re-apply gave up: viewer never re-attached a table')
await applyDepth(d)
dbg(`re-apply done depth=${d}`)
} catch (err) {
dbg('re-apply THREW', err)
} finally {
queued = false
}
})
}
document.addEventListener('visibilitychange', reapply)
window.addEventListener('focus', reapply)
window.addEventListener('pageshow', reapply)
return () => {
document.removeEventListener('visibilitychange', reapply)
window.removeEventListener('focus', reapply)
window.removeEventListener('pageshow', reapply)
}
}, [])
// perspective-click fires as a CustomEvent with no modifier state of its own,
// and carries no cell coordinates either, so record both from the mousedown that
// precedes it. The grid lives in a shadow root, so the real target is the head of
// the composed path, not event.target (which retargets to the host).
useEffect(() => {
const onDown = (e) => {
modifierRef.current = e.ctrlKey || e.metaKey || e.shiftKey
downCellRef.current = cellAt(e)
}
window.addEventListener('mousedown', onDown, true)
return () => window.removeEventListener('mousedown', onDown, true)
}, [])
// Reading the coordinates of the body cell under a mouse event, or null if the
// event did not land on one.
function cellAt(e) {
const table = gridRef.current?.regular_table
if (!table) return null
const target = (e.composedPath?.()[0]) || e.target
try {
const meta = table.getMeta(target)
if (meta?.type === 'body' && meta.x != null && meta.y != null) return { x: meta.x, y: meta.y }
} catch {
return null
}
return null
}
// Drag-select commit. perspective-select fires continuously while the mouse moves
// (once per mouseover, as the potential region grows), so the event handler only
// records the latest window and the commit happens here, on mouseup. A single-cell
// region is left alone: perspective-click already owns plain and modifier clicks,
// and handling it twice would undo a ctrl-click toggle.
useEffect(() => {
const onUp = async () => {
const win = regionRef.current
regionRef.current = null
if (!win || win.start_row == null || win.end_row == null) return
const nRows = win.end_row - win.start_row
const nCols = (win.end_col ?? 1) - (win.start_col ?? 0)
if (nRows <= 1 && nCols <= 1) return
const found = await slicesFromRegion(win)
if (!found.length) return
const additive = modifierRef.current
if (!additive) areasRef.current.clear()
let next = additive ? slicesRef.current : []
for (const { slice, area } of found) {
const k = sliceKey(slice)
areasRef.current.set(k, [...(areasRef.current.get(k) || []), area])
next = addSlice(next, slice)
}
setSlices(next)
}
window.addEventListener('mouseup', onUp)
return () => window.removeEventListener('mouseup', onUp)
}, [])
// A selected region is a rectangle of grid coordinates; turning it back into slices
// means re-deriving, per cell, the same [col, '==', value] filters Perspective
// attaches to a click. Row dimensions come from the view's __ROW_PATH__ (raw values,
// so dates stay epoch millis rather than whatever the grid formatted them as), and
// column dimensions from the split_by segments of the column name.
async function slicesFromRegion(win) {
const viewer = viewerRef.current
if (!viewer) return []
const cfg = await viewer.save()
const view = await viewer.getView()
const groupBy = cfg.group_by || []
const splitBy = cfg.split_by || []
const base = cfg.filter || []
const rows = await view.to_json({ start_row: win.start_row, end_row: win.end_row })
if (!rows.length) return []
const userKeys = Object.keys(rows[0]).filter(k => !META_COL_RE.test(k))
const c0 = win.start_col ?? 0
const c1 = win.end_col ?? userKeys.length
const out = []
for (let i = 0; i < rows.length; i++) {
const path = rows[i].__ROW_PATH__ || []
const rowFilters = groupBy
.map((col, ix) => (path[ix] ? [col, '==', path[ix]] : null))
.filter(Boolean)
// the grand-total row resolves to no dimension at all, which would mean
// "the whole version" — never what dragging over it is asking for
if (groupBy.length && !rowFilters.length) continue
for (let c = c0; c < c1; c++) {
const key = userKeys[c]
if (!key) continue
const colFilters = splitBy
.map((col, ix) => {
const v = key.split('|')[ix]
return (v && !META_COL_RE.test(v)) ? [col, '==', v] : null
})
.filter(Boolean)
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters])
if (!Object.keys(slice).length) continue
const y = win.start_row + i
out.push({ slice, area: { x0: c, x1: c, y0: y, y1: y } })
}
}
return out
}
// The datagrid highlights whatever sits in its own `selected_areas`, and wipes that
// list on every mousedown — so a ctrl-click selection built up over several clicks
// would only ever show the last cell. Push the full set back after each change and
// redraw, which gets the native highlight for every selected cell for free.
useEffect(() => {
slicesRef.current = slices
const grid = gridRef.current
const state = grid?.model?._selection_state
if (!state) return
const live = new Set(slices.map(sliceKey))
for (const k of [...areasRef.current.keys()]) {
if (!live.has(k)) areasRef.current.delete(k)
}
state.selected_areas = [...areasRef.current.values()].flat()
state.dirty = true
grid.regular_table?.draw({ preserve_width: true })?.catch?.(() => {})
}, [slices])
function onDragStart(e) {
e.preventDefault()
const vertical = dock === 'bottom'
const start = vertical ? e.clientY : e.clientX
const startVal = vertical ? panelHeight : panelWidth
const onMove = (ev) => {
const delta = (vertical ? ev.clientY : ev.clientX) - start
if (vertical) setPanelHeight(Math.max(140, Math.min(window.innerHeight - 160, startVal - delta)))
else setPanelWidth(Math.max(260, Math.min(760, startVal - delta)))
}
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) {
dbg(`theme effect -> ${dark ? 'Pro Dark' : 'Pro Light'} (a theme change rebuilds the view)`)
viewerRef.current.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
}
}, [dark, versionId])
useEffect(() => {
const keys = new Set(slices.flatMap(sl => Object.keys(sl)))
const blank = Object.fromEntries([...keys].map(k => [k, '']))
setRecodeSet(blank)
setCloneSet(blank)
setScaleInputs({})
if (slices.length > 0) fetchCurrentTotals(slices)
else setCurrentTotals(null)
}, [slices])
// Totals for the current selection. Perspective view filters are AND-only, so a
// union of slices can't be expressed as one view — each slice gets its own view
// and the results are summed. Per-slice totals are kept so the panel can show
// exactly what is selected rather than one opaque number.
async function fetchCurrentTotals(sliceList) {
if (!tableRef.current || !sliceList.length) { setCurrentTotals(null); 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
const version = versions.find(v => String(v.id) === String(versionId))
const excludeIters = new Set(version?.exclude_iters || ['reference'])
const dimNames = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
const dateNames = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone']
async function totalsFor(sliceObj) {
const filters = [
...Object.entries(sliceObj)
.filter(([col]) => dimNames.has(col))
.map(([col, val]) => [col, '==', val]),
...Object.entries(sliceObj)
.filter(([col]) => dateNames.has(col))
.map(([col, val]) => [col, '==', Number(val)]),
]
const view = await tableRef.current.view({ filter: filters })
const rows = await view.to_json()
await view.delete()
const buckets = new Map()
// one ledger line per change: the baseline, then each operation that has
// touched this slice since, keyed by its log id so they read in the order
// they were applied
const entries = new Map()
// rows the pivot shows but operations cannot write (usually 'reference').
// Kept separate rather than filtered away: the grid total includes them,
// so the panel has to account for them or the two disagree.
const excluded = { value: 0, units: 0, rows: 0 }
for (const r of rows) {
const k = r.pf_iter || '?'
const val = valueCol ? (parseFloat(r[valueCol]) || 0) : 0
const uni = unitsCol ? (parseFloat(r[unitsCol]) || 0) : 0
if (excludeIters.has(k)) {
excluded.value += val
excluded.units += uni
excluded.rows += 1
continue
}
const t = buckets.get(k) || { value: 0, units: 0 }
t.value += val
t.units += uni
buckets.set(k, t)
const ek = k === 'baseline' ? 'baseline' : `log:${r.pf_logid}`
const e = entries.get(ek) || { key: ek, iter: k, logid: r.pf_logid ?? null, value: 0, units: 0 }
e.value += val
e.units += uni
entries.set(ek, e)
}
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(
(acc, r) => ({ value: acc.value + (r.value || 0), units: acc.units + (r.units || 0) }),
{ value: 0, units: 0 })
return { byIter, byEntry: Array.from(entries.values()), total, excluded, rows: rows.length }
}
try {
const perSlice = []
for (const sl of sliceList) {
perSlice.push({ slice: sl, ...(await totalsFor(sl)) })
}
// roll the per-slice iter buckets up into one combined breakdown
const combined = new Map()
for (const ps of perSlice) {
for (const r of ps.byIter) {
const t = combined.get(r.iter) || { value: 0, units: 0 }
t.value += r.value || 0
t.units += r.units || 0
combined.set(r.iter, t)
}
}
const byIter = Array.from(combined, ([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(
(acc, r) => ({ value: acc.value + (r.value || 0), units: acc.units + (r.units || 0) }),
{ value: 0, units: 0 })
// same rollup for the ledger: baseline first, then adjustments by log order
const entryMap = new Map()
for (const ps of perSlice) {
for (const e of ps.byEntry || []) {
const t = entryMap.get(e.key) || { ...e, value: 0, units: 0 }
t.value += e.value || 0
t.units += e.units || 0
entryMap.set(e.key, t)
}
}
const byEntry = Array.from(entryMap.values()).sort((a, b) => {
if (a.key === 'baseline') return -1
if (b.key === 'baseline') return 1
return (a.logid ?? 0) - (b.logid ?? 0)
})
const excluded = perSlice.reduce(
(acc, ps) => ({
value: acc.value + (ps.excluded?.value || 0),
units: acc.units + (ps.excluded?.units || 0),
rows: acc.rows + (ps.excluded?.rows || 0),
}), { value: 0, units: 0, rows: 0 })
setCurrentTotals({
byIter, byEntry, total, excluded, valueCol, unitsCol, perSlice,
excludedIters: [...excludeIters],
})
} catch {
setCurrentTotals(null)
}
}
// Ledger lines show a tag or note rather than a bare log id, and the tag box
// completes from initiatives already used on this source.
async function refreshLogMeta(vid) {
if (!vid) { setLogMeta({}); return }
try {
const entries = await fetch(`/api/versions/${vid}/log`).then(r => r.json())
const map = {}
for (const e of entries) map[e.id] = { tag: e.tag || null, note: e.note || null, operation: e.operation }
setLogMeta(map)
} catch { setLogMeta({}) }
}
async function refreshTags(sid) {
if (!sid) { setKnownTags([]); return }
try {
const rows = await fetch(`/api/sources/${sid}/tags`).then(r => r.json())
setKnownTags(Array.isArray(rows) ? rows : [])
} catch { setKnownTags([]) }
}
useEffect(() => { refreshLogMeta(versionId) }, [versionId])
useEffect(() => { refreshTags(sourceId) }, [sourceId])
// Stream an Arrow IPC endpoint into one buffer, reporting download progress.
// Both /data and /agg speak the same protocol — a single record batch plus an
// X-Row-Count header — so the caller only picks the URL.
async function fetchArrow(url) {
const r = await fetch(url)
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
let lastUpdate = 0
setLoadProgress({ received: 0, total })
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.byteLength
const now = Date.now()
if (now - lastUpdate >= 100) {
setLoadProgress({ received, total })
lastUpdate = now
}
}
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 }
}
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
dbg(`initViewer(version=${vid}, source=${sid}) -- FULL RELOAD, depth reset to null`)
const myId = ++initIdRef.current
setLoading(true)
setLargeDataset(false)
setLoadProgress(null)
setSlices([])
expandDepthRef.current = null
adoptSplit([], 0)
try {
// col_meta first — it decides which endpoint to load from, and it is a tiny
// query next to the data fetch it gates.
const meta = await fetch(`/api/sources/${sid}/cols`).then(r => r.json())
colMetaRef.current = meta
// Grain mode: the source declares a display grain, so the server ships rows
// already aggregated to it and the table is indexed on pf_gkey. Without a
// grain we load raw forecast rows indexed on pf_id, as before.
const grainMeta = meta.filter(c => c.in_grain && ['dimension','date'].includes(c.role))
const grainMode = grainMeta.length > 0
const indexCol = grainMode ? 'pf_gkey' : 'pf_id'
const dataResult = await fetchArrow(`/api/versions/${vid}/${grainMode ? 'agg' : 'data'}`)
const { buffer, rowCount } = dataResult
const validCols = new Set(grainMode
? [
...grainMeta.map(c => c.cname),
...meta.filter(c => ['value','units'].includes(c.role)).map(c => c.cname),
'pf_gkey', 'pf_iter', 'pf_logid', 'pf_segment', 'pf_note', 'pf_op',
]
: [
...meta.filter(c => ['dimension','value','units','date'].includes(c.role)).map(c => c.cname),
'pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'created_at', 'pf_segment', 'pf_note', 'pf_op',
])
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
// Clean up the previous table — by JS reference first, then by name in the
// worker registry (covers the case where the ref was lost or delete failed).
if (tableRef.current) {
try { await tableRef.current.delete() } catch {}
tableRef.current = null
}
try {
const stale = await worker.open_table(tableName)
if (stale) await stale.delete()
} catch {}
const opts = { name: tableName, index: indexCol }
tableRef.current = await (rowCount > 0 ? worker.table(buffer, opts) : worker.table([], opts))
if (myId !== initIdRef.current) {
try { await tableRef.current.delete() } catch {}
tableRef.current = null
return
}
// Load by direct table reference — avoids "No Table attached" on large datasets
// that occurs when viewer.load(worker) + restore({ table: name }) can't resolve
// the named table in time.
await viewer.load(tableRef.current)
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
// restore last-used layout or build default
// Strip cfg.table — table is already loaded by reference above; a stale name
// in a saved config would cause Perspective to fail the name lookup.
const saved = localStorage.getItem(LAYOUT_KEY(vid))
if (saved) {
const { table: _t, ...rest } = cleanLayout(JSON.parse(saved), validCols)
const cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } }
await viewer.restore(cfg)
// split_full outlives split_by: a layout saved while collapsed still knows
// the levels it was collapsed from
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
} else {
const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout
let cfg
if (sourceDefault && Object.keys(sourceDefault).length > 0) {
const { table: _t, ...rest } = cleanLayout(sourceDefault, validCols)
cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } }
} else {
const valueCol = meta.find(c => c.role === 'value')?.cname
cfg = {
settings: false,
group_by: ['pf_iter'],
columns: valueCol ? [valueCol] : [],
plugin_config: { edit_mode: 'SELECT_REGION' }
}
}
await viewer.restore(cfg)
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
}
// 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 () => {
dbg(`perspective-config-update (collapsing=${collapsingRef.current})`)
await probeView('config-update')
try {
// A split_by change that is not ours is the user rearranging the pivot, and
// it redefines the hierarchy. Ours is a collapse, and must not overwrite it.
if (!collapsingRef.current) {
const live = await viewer.save()
adoptSplit(live.split_by || [], (live.split_by || []).length)
}
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).
// Plain click replaces the selection; ctrl/cmd/shift-click toggles a slice in or
// out of it, which is what makes multi-slice operations possible.
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 s = sliceFromFilters((detail.config || {}).filter || [])
if (!Object.keys(s).length) return
// the CustomEvent carries no modifier flags, so read them off the
// mousedown that produced it (captured on window below)
const additive = modifierRef.current
// the datagrid wipes its highlight on mousedown, so keep this cell's
// rectangle alongside the slice and re-apply the lot (see the effect above)
const cell = downCellRef.current
const k = sliceKey(s)
const next = additive ? toggleSlice(slicesRef.current, s) : [s]
if (!additive) areasRef.current.clear()
if (!next.some(x => sliceKey(x) === k)) areasRef.current.delete(k)
else if (cell) areasRef.current.set(k, [{ x0: cell.x, x1: cell.x, y0: cell.y, y1: cell.y }])
setSlices(next)
}
viewer.addEventListener('perspective-click', viewer._pspClick)
// Region selection (click and drag) reaches us as perspective-select carrying a
// ViewWindow — { start_row, end_row, start_col, end_col } — and fires on every
// mouseover as the region grows. Record the latest and let the window-level
// mouseup commit it; a null detail is the datagrid clearing its selection.
if (viewer._pspSelect) viewer.removeEventListener('perspective-select', viewer._pspSelect)
viewer._pspSelect = (e) => { regionRef.current = e.detail || null }
viewer.addEventListener('perspective-select', viewer._pspSelect)
gridRef.current = await viewer.getPlugin()
setLargeDataset(false)
} catch (err) {
console.error('[initViewer]', err)
flash(err.message || String(err), 'error')
} finally {
setLoading(false)
}
}
// Record the column hierarchy as the uncollapsed truth. Called whenever a layout
// arrives or the user rearranges split_by themselves — but never for our own
// collapse, which would otherwise overwrite the full list with the short one.
function adoptSplit(full, depth) {
const list = Array.isArray(full) ? full : []
splitFullRef.current = list
setSplitFull(list)
setSplitDepth(depth == null ? list.length : Math.min(depth, list.length))
}
// Collapse or expand the column axis to `n` split_by levels.
//
// The row axis gets this for free: its GROUP BY ROLLUP view holds every level at
// once and view.set_depth() hides the deeper ones. The column axis has no
// equivalent — there is no split_by_depth in ViewConfig and expand()/collapse()
// take a row index — so collapsing means restoring a truncated split_by, which
// rebuilds the view. Two consequences fall out of that: the row depth has to be
// re-applied afterwards (it lives on the discarded view), and it is whole-axis,
// not per-branch — every column group collapses to the same level together.
async function applySplitDepth(n) {
const viewer = viewerRef.current
const full = splitFullRef.current
if (!viewer || !full.length) return
const depth = Math.max(0, Math.min(n, full.length))
collapsingRef.current = true
try {
await viewer.restore({ split_by: full.slice(0, depth) })
setSplitDepth(depth)
// restore() rebuilt the view, so the row depth that lived on the old one is gone
if (expandDepthRef.current != null) await applyDepth(expandDepthRef.current)
} catch (err) {
console.error('[applySplitDepth]', err)
flash(err.message || String(err), 'error')
return
} finally {
collapsingRef.current = false
}
// a slice names the split_by dimensions it was cut from, and the highlight is
// keyed on grid coordinates — neither survives a column axis that just changed
setSlices([])
try {
const cfg = await captureConfig()
if (cfg) await persistLayout(versionId, cfg)
} catch (err) {
console.error('[applySplitDepth persist]', err)
}
}
async function applyDepth(d) {
const viewer = viewerRef.current
if (!viewer) return
const view = await viewer.getView()
dbg(`applyDepth(${d}) on view ${viewId(view)}`)
lastViewIdRef.current = viewId(view)
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, split_full: splitFullRef.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 saveAsSourceDefault() {
const cfg = await captureConfig()
if (!cfg) return
const { table, expand_depth, ...rest } = cfg
try {
const res = await fetch(`/api/sources/${sourceId}/default-layout`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rest)
})
if (!res.ok) { const data = await res.json(); flash(data.error || 'Failed', 'error'); return }
if (refreshSources) await refreshSources()
flash('Saved as source default')
} catch (err) { flash(err.message, 'error') }
}
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 = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' }
await viewer.restore(cfg)
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
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 (!slices.length) { flash('Select a slice first', 'error'); return }
const body = buildPayload(op)
if (!body) return
if (body.slices.some(sl => !Object.keys(sl).length)) {
flash('No dimension or date columns in slice — check col_meta', 'error'); return
}
if (op === 'scale') {
const has = ['target_value','target_units','target_price',
'value_incr','units_incr','value_pct','units_pct']
.some(k => body[k] !== undefined)
if (!has) { flash('Enter a target or increment', 'error'); return }
}
if ((op === 'recode' || op === 'clone') && !Object.keys(body.set || {}).length) {
flash(op === 'recode' ? 'Enter at least one new dimension value' : 'Enter at least one override value', 'error')
return
}
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)
const n = data.slices_applied > 1 ? ` across ${data.slices_applied} slices` : ''
// a slice that matched no rows, or was already at its target, writes nothing —
// say so rather than reporting a silent partial success
const skipped = data.slices_skipped?.length
? `${data.slices_skipped.length} slice${data.slices_skipped.length === 1 ? '' : 's'} unchanged (no rows, or already on target)`
: ''
flash(`${op}: ${data.rows_affected ?? data.rows?.length ?? ''} rows${n}${skipped}`, skipped ? 'warn' : 'ok')
// let the status bar re-read the forecast table's row count
window.dispatchEvent(new CustomEvent('pf-data-changed'))
refreshLogMeta(versionId)
if (opTag.trim()) refreshTags(sourceId)
if (op === 'scale') { setScaleInputs({}); setScaleNote(''); fetchCurrentTotals(slices) }
if (op === 'recode') { setRecodeNote('') }
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
} catch (err) { flash(err.message, 'error') }
}
async function lookupDerivedCols(col, value, setter) {
if (!sourceId || !value.trim()) return
const res = await fetch(`/api/sources/${sourceId}/lookup?col=${encodeURIComponent(col)}&value=${encodeURIComponent(value)}`)
if (!res.ok) return
const derived = await res.json()
if (!derived) return
setter(prev => {
const next = { ...prev }
for (const [k, v] of Object.entries(derived)) {
if (!prev[k] || prev[k] === '') next[k] = String(v ?? '')
}
return next
})
}
function buildEffectiveSlice(raw) {
const dimCols = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
const dateCols = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
const out = {}
for (const [k, v] of Object.entries(raw)) {
if (dimCols.has(k)) { out[k] = v; continue }
if (dateCols.has(k)) {
const ms = Number(v)
out[k] = isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : v
}
}
return out
}
function buildPayload(op) {
if (!slices.length) return null
// Two clicked cells can differ only by a column the operation cannot filter on
// (pf_iter, say, which is not in col_meta and so is dropped here). Those become
// the same effective slice, and sending it twice would apply the change twice
// under apply_mode 'each'. Collapse duplicates before they reach the API.
const seen = new Set()
const effectiveSlices = []
for (const sl of slices) {
const eff = buildEffectiveSlice(sl)
const key = JSON.stringify(Object.keys(eff).sort().map(k => [k, eff[k]]))
if (seen.has(key)) continue
seen.add(key)
effectiveSlices.push(eff)
}
// apply_mode only changes the maths when more than one slice is selected
let body = {
tag: opTag.trim() || undefined,
slices: effectiveSlices,
...(effectiveSlices.length > 1 ? { apply_mode: applyMode } : {}),
}
if (op === 'scale') {
body = { ...body, note: scaleNote || undefined }
const adj = currentTotals?.total || { value: 0, units: 0 }
const excl = currentTotals?.excluded || { value: 0, units: 0 }
// only meaningful when the slice actually contains rows operations cannot write
const hasExcl = excl.value !== 0 || excl.units !== 0
const useTotal = targetBasis !== 'adjustable' && hasExcl
if (useTotal) body.target_basis = 'selected'
const cur = useTotal
? { value: adj.value + excl.value, units: adj.units + excl.units }
: adj
const curPrice = cur.units ? cur.value / cur.units : null
const num = (raw) => {
const n = parseFloat(raw)
return (raw !== '' && raw != null && isFinite(n)) ? n : null
}
const v = scaleInputs.value, u = scaleInputs.units, p = scaleInputs.price
const vn = v && num(v.raw), un = u && num(u.raw), pn = p && num(p.raw)
if (vn != null) {
if (v.field === 'new') body.target_value = vn
else if (v.field === 'change') body.value_incr = vn
else body.value_pct = vn
}
if (un != null) {
if (u.field === 'new') body.target_units = un
else if (u.field === 'change') body.units_incr = un
else body.units_pct = un
}
// price has no increment form server-side, so every price edit is resolved
// to an absolute target price here
if (pn != null && curPrice != null) {
body.target_price = p.field === 'new' ? pn
: p.field === 'change' ? curPrice + pn
: curPrice * (1 + pn / 100)
}
} else if (op === 'recode') {
const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
body = { ...body, note: recodeNote || undefined, set }
} else if (op === 'clone') {
const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim()))
body = { ...body, note: cloneNote || undefined, set, scale: parseFloat(cloneScale) || 1 }
}
return body
}
function flash(text, type = 'ok') {
setMsg({ text, type })
// errors and warnings stay until dismissed or superseded; plain success fades
if (type === 'ok') 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))
// grain versions report pf_gkeys, raw versions pf_ids — either way these are
// the index values of the rows to drop, and the view re-sums what remains
const removed = data.pf_gkeys ?? data.pf_ids
if (removed?.length && tableRef.current) {
await tableRef.current.remove(removed)
}
flash(`Undone — ${data.rows_deleted} rows removed`)
} catch (err) {
flash(err.message, 'error')
} finally {
setUndoingId(null)
}
}
// note and tag are annotations on history — saving one never touches forecast rows
async function saveLogField(logId, field, text) {
const value = text.trim()
try {
const res = await fetch(`/api/log/${logId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value }),
})
if (!res.ok) {
const { error } = await res.json().catch(() => ({}))
flash(error || `Failed to save ${field}`, 'error')
return
}
const saved = await res.json()
setLogEntries(prev => prev.map(e => e.id === logId ? { ...e, [field]: saved[field] ?? null } : e))
setEditingCell(null)
if (field === 'tag') {
// the ledger names its lines from this, and the tag list gains a new entry
refreshLogMeta(versionId)
refreshTags(sourceId)
}
} catch (err) {
flash(err.message, 'error')
}
}
const dimCols = colMetaRef.current.filter(c => c.role === 'dimension')
const hasSlice = slices.length > 0
// how many of the selected cells actually resolve to distinct, operable slices
const distinctSlices = (() => {
const seen = new Set()
for (const sl of slices) {
const eff = buildEffectiveSlice(sl)
seen.add(JSON.stringify(Object.keys(eff).sort().map(k => [k, eff[k]])))
}
return seen.size
})()
const panelProps = {
distinctSlices,
dock,
slices, setSlices,
applyMode, setApplyMode,
currentTotals,
activeOp, setActiveOp,
scaleInputs, setScaleInputs,
targetBasis, setTargetBasis,
opTag, setOpTag, knownTags, logMeta,
scaleNote, setScaleNote,
recodeSet, setRecodeSet,
recodeNote, setRecodeNote,
cloneSet, setCloneSet,
cloneScale, setCloneScale,
cloneNote, setCloneNote,
dimCols, lookupDerivedCols,
buildPayload, submitOp,
}
return (
<div className="h-full flex flex-col">
{/* Tag completions, shared by the operation panel and the change log's
inline editor. Lives here so it is in the DOM even when the panel is shut. */}
<datalist id="pf-tag-options">
{knownTags.map(t => (
<option key={t.tag} value={t.tag}>{t.uses} use{t.uses === 1 ? '' : 's'}</option>
))}
</datalist>
{/* 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>
<button onClick={saveAsSourceDefault} disabled={!sourceId}
className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5 disabled:opacity-40"
title="Use this layout as the default for new versions of this source">
Set source default
</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>
{splitFull.length > 0 && (
<>
<div className="w-px h-4 bg-gray-200 shrink-0" />
{/* Column hierarchy group — the split_by equivalent of Expand */}
<div className="flex items-center gap-1.5">
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Columns</span>
{Array.from({ length: splitFull.length + 1 }, (_, n) => {
const label = n === 0 ? 'Total' : splitFull[n - 1]
return (
<button key={n} onClick={() => applySplitDepth(n)}
title={n === 0
? 'Collapse the columns to a single total'
: `Show columns down to ${splitFull.slice(0, n).join(' ')}`}
className={`border rounded px-1.5 py-0.5 transition-colors max-w-[9rem] truncate
${splitDepth === n ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
{label}
</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>
<button onClick={() => setShowBridge(true)} disabled={!versionId}
title="How this version got from baseline to current, by initiative"
className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
Bridge
</button>
<button onClick={() => setPanelOpen(o => !o)} disabled={!versionId}
title={panelOpen ? 'Hide the operations panel (Esc)' : 'Show the operations panel'}
className={`border rounded px-2 py-0.5 disabled:opacity-40 transition-colors ${
panelOpen ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:bg-gray-50'}`}>
{panelOpen ? 'Hide panel' : 'Operations'}
{!panelOpen && hasSlice && (
<span className="ml-1 text-blue-500">({slices.length})</span>
)}
</button>
</div>
{msg && (
<span className={`ml-2 text-xs font-medium px-2 py-0.5 rounded flex items-center gap-1.5 ${msg.type === 'error' ? 'bg-red-50 text-red-600' : msg.type === 'warn' ? 'bg-amber-50 text-amber-700' : 'bg-green-50 text-green-600'}`}>
{msg.text}
{msg.type !== 'ok' && (
<button onClick={() => setMsg(null)} className="opacity-60 hover:opacity-100 leading-none">×</button>
)}
</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 w-40">Tag</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>
<LogCell entry={entry} field="tag" placeholder="add tag"
editing={editingCell} setEditing={setEditingCell} onSave={saveLogField}
listId="pf-tag-options"
render={(v) => (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-700 border border-blue-200">
{v}
</span>
)} />
<LogCell entry={entry} field="note" placeholder="add note"
editing={editingCell} setEditing={setEditingCell} onSave={saveLogField} />
<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>
)}
<BridgeView
open={showBridge}
onClose={() => setShowBridge(false)}
tableRef={tableRef}
viewerRef={viewerRef}
logMeta={logMeta}
valueCol={colMetaRef.current.find(c => c.role === 'value')?.cname}
unitsCol={colMetaRef.current.find(c => c.role === 'units')?.cname}
colMeta={colMetaRef.current}
slices={slices}
excludeIters={versions.find(v => String(v.id) === String(versionId))?.exclude_iters || ['reference']}
versionName={versions.find(v => String(v.id) === String(versionId))?.name}
/>
{/* Main area — the panel lives in one of three shells, chosen by `dock` */}
<div className={`flex-1 min-h-0 flex ${dock === 'bottom' ? 'flex-col' : 'flex-row'}`}>
{/* Perspective viewer */}
<div className="relative flex-1 min-w-0 min-h-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>
{/* Docked panel: bottom strip or right rail, with a resize handle */}
{dock !== 'float' && panelOpen && (
<>
<div
onMouseDown={onDragStart}
className={`${dock === 'bottom' ? 'h-1 w-full cursor-row-resize' : 'w-1 h-full cursor-col-resize'} shrink-0 hover:bg-blue-400 bg-transparent transition-colors`}
/>
<div
className={`shrink-0 bg-white overflow-auto text-xs ${dock === 'bottom' ? 'border-t' : 'border-l'} border-gray-200`}
style={dock === 'bottom' ? { height: panelHeight } : { width: panelWidth }}
>
<PanelChrome dock={dock} setDock={setDock} onClose={() => setPanelOpen(false)} />
<OperationPanel {...panelProps} />
</div>
</>
)}
</div>
{/* Floating panel: the pivot keeps the full window and this floats over it,
draggable by its header and resizable from the bottom-right grip */}
{dock === 'float' && panelOpen && floatRect && (
<div
className="fixed z-40 bg-white border border-gray-200 rounded-lg shadow-2xl flex flex-col text-xs"
style={{ left: floatRect.x, top: floatRect.y, width: floatRect.w, height: floatRect.h }}
>
<PanelChrome dock={dock} setDock={setDock} floating onMouseDown={onFloatMove}
onClose={() => setPanelOpen(false)} />
<div className="flex-1 min-h-0 overflow-auto">
<OperationPanel {...panelProps} />
</div>
<div
onMouseDown={onFloatResize}
title="Drag to resize"
className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize text-gray-300 hover:text-gray-500"
>
<svg viewBox="0 0 16 16" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M15 6 L6 15 M15 11 L11 15" />
</svg>
</div>
</div>
)}
</div>
)
}
// Dock switcher — the panel goes where you want it, and remembers.
function PanelChrome({ dock, setDock, floating, onMouseDown, onClose }) {
const opts = [
['bottom', 'Dock to bottom', <svg key="b" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><rect x="1.5" y="9.5" width="13" height="5" fill="currentColor" opacity="0.35" stroke="none"/></svg>],
['right', 'Dock to right', <svg key="r" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><rect x="9.5" y="1.5" width="5" height="13" fill="currentColor" opacity="0.35" stroke="none"/></svg>],
['float', 'Float over grid',<svg key="f" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><rect x="5" y="5" width="8" height="7" rx="1" fill="currentColor" opacity="0.35" stroke="none"/></svg>],
]
return (
<div
onMouseDown={onMouseDown}
className={`flex items-center gap-1 px-2 py-1.5 border-b border-gray-100 shrink-0 ${floating ? 'bg-gray-50 rounded-t-lg cursor-move select-none' : ''}`}
>
<span className="text-gray-400 uppercase tracking-wide mr-auto" style={{ fontSize: '10px' }}>
Operations{floating ? ' — drag to move' : ''}
</span>
{opts.map(([val, title, icon]) => (
<button key={val} onClick={() => setDock(val)} title={title}
className={`w-6 h-6 flex items-center justify-center rounded ${dock === val ? 'bg-blue-50 text-blue-600' : 'text-gray-300 hover:text-gray-500 hover:bg-gray-100'}`}>
{icon}
</button>
))}
<span className="w-px h-4 bg-gray-200 mx-0.5" />
<button onClick={onClose} title="Close panel (Esc)"
className="w-6 h-6 flex items-center justify-center rounded text-gray-400 hover:text-red-500 hover:bg-gray-100 text-base leading-none">
×
</button>
</div>
)
}
// One inline-editable annotation cell in the change log. Click to edit, Enter to
// save, Escape to cancel — the same gesture for note and tag.
function LogCell({ entry, field, placeholder, editing, setEditing, onSave, listId, render }) {
const active = editing?.id === entry.id && editing?.field === field
const value = entry[field] || ''
if (active) {
return (
<td className="px-4 py-2">
<div className="flex items-center gap-1">
<input autoFocus value={editing.text} list={listId}
onChange={e => setEditing(c => ({ ...c, text: e.target.value }))}
onKeyDown={e => {
if (e.key === 'Enter') onSave(entry.id, field, editing.text)
if (e.key === 'Escape') setEditing(null)
}}
className="border border-blue-400 rounded px-1.5 py-0.5 text-xs flex-1 min-w-0 focus:outline-none" />
<button onClick={() => onSave(entry.id, field, editing.text)}
className="text-blue-600 hover:text-blue-800"></button>
<button onClick={() => setEditing(null)}
className="text-gray-500 hover:text-gray-700"></button>
</div>
</td>
)
}
return (
<td className="px-4 py-2 text-gray-700 max-w-xs">
<span onClick={() => setEditing({ id: entry.id, field, text: value })}
className="cursor-text hover:bg-blue-50 rounded px-1 -mx-1 block truncate"
title={value || `Click to ${placeholder}`}>
{value
? (render ? render(value) : value)
: <span className="text-gray-500 italic">{placeholder}</span>}
</span>
</td>
)
}
// Perspective's internal columns, which are never dimensions. Mirrors isMetaColumn()
// in @perspective-dev/viewer-datagrid — the DuckDB backend emits per-level
// __ROW_PATH_<n>__ columns alongside the __ROW_PATH__ sidecar.
const META_COL_RE = /^__(?:ROW_PATH(?:_\d+)?|ID|GROUPING_ID)__$/
// Perspective encodes a clicked/selected row position as [col, '==', value] triples
function sliceFromFilters(filters) {
const s = {}
for (const f of filters) {
if (!Array.isArray(f)) continue
const [col, op, val] = f
if (op === '==' && val != null) s[col] = String(val)
}
return s
}
// slices are compared by value — the same row clicked twice is the same slice
function sliceKey(s) {
return JSON.stringify(Object.keys(s).sort().map(k => [k, s[k]]))
}
function addSlice(list, s) {
return list.some(x => sliceKey(x) === sliceKey(s)) ? list : [...list, s]
}
function removeSlice(list, s) {
return list.filter(x => sliceKey(x) !== sliceKey(s))
}
function toggleSlice(list, s) {
return list.some(x => sliceKey(x) === sliceKey(s)) ? removeSlice(list, s) : [...list, s]
}
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' }