A sales figure on its own does not say which of price or volume moved, and
scale was answering silently: it wrote the value increment and left units
alone, so volume stayed flat and price absorbed everything. That is one of
the three behaviours the Excel form offered, picked by default rather than
by the user.
/opt/forecast_api/VBA/fpvt.frm has the semantics. Edit Sales, then plug one
side:
plug volume: pchange = fVal/(pVal+bVal); fVol = (pVol+bVol)*pchange
plug price: fVol = pVol + bVol
So 'volume' moves units in the same proportion as the dollars, holding
price; 'price' leaves units alone, as now. Checked against those formulas:
+100 on 1000/500 gives units 500 -> 550 with price held at 2.0000, and
-400 on 2000/800 gives 800 -> 640 at 2.5000.
Price still defaults, so nothing changes for an existing caller that does
not send `plug`. The control only appears once the edit is dollars-only --
naming units or price has already settled the question. A price target now
also honours a units target alongside it, which is the form's Edit Price
mode where both are inputs and dollars fall out.
Holding price when the selection has no value is refused rather than
divided by zero, the same case the form guarded with "Zero times any number
is zero".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1669 lines
74 KiB
JavaScript
1669 lines
74 KiB
JavaScript
import { useState, useEffect, useRef } from 'react'
|
||
import { PF_OBSERVER_EVENT } from '../observerShim.js'
|
||
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({})
|
||
// which side of price x volume absorbs a dollar-only change: 'price' keeps
|
||
// volume flat, 'volume' keeps price flat. Mirrors the Excel form's
|
||
// Plug Price / Plug Volume choice.
|
||
const [scalePlug, setScalePlug] = useState(() => localStorage.getItem('pf_scale_plug') || 'price')
|
||
// 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_scale_plug', scalePlug) }, [scalePlug])
|
||
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
|
||
// Tracing for the "row groups re-expand on refocus" bug. set_depth() and
|
||
// per-node expand/collapse live on the *view*, not in ViewConfig, so every
|
||
// view rebuild starts fully expanded and we have to re-apply depth ourselves.
|
||
// Silent unless localStorage.pf_debug is set, since this is the kind of thing
|
||
// you want back the next time the timing shifts.
|
||
//
|
||
// localStorage.setItem('pf_debug', '1') // then reload
|
||
const viewSeqRef = useRef(0)
|
||
const viewIdsRef = useRef(new WeakMap())
|
||
const lastViewIdRef = useRef(null)
|
||
|
||
function dbg(msg, extra) {
|
||
let on = false
|
||
try { on = !!localStorage.getItem('pf_debug') } catch { /* private mode */ }
|
||
if (!on) return
|
||
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) {
|
||
let on = false
|
||
try { on = !!localStorage.getItem('pf_debug') } catch { /* private mode */ }
|
||
if (!on) return
|
||
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) }
|
||
}
|
||
// Around a rebuild the viewer throws "No table set" from getView() -- note it
|
||
// is the *view* that is missing, not the table: getTable() resolves happily
|
||
// through the same window, which is why gating on it did not help. There is no
|
||
// "is the view ready" predicate to poll, so just attempt the thing we want and
|
||
// retry until it stops throwing.
|
||
async function applyDepthWhenReady(d, deadlineMs = 5000, stepMs = 80) {
|
||
const started = Date.now()
|
||
let attempts = 0
|
||
for (;;) {
|
||
attempts++
|
||
try {
|
||
await applyDepth(d)
|
||
if (attempts > 1) dbg(`depth ${d} applied on attempt ${attempts} after ${Date.now() - started}ms`)
|
||
return true
|
||
} catch (err) {
|
||
if (Date.now() - started >= deadlineMs) {
|
||
dbg(`gave up re-applying depth ${d} after ${attempts} attempts`, err)
|
||
return false
|
||
}
|
||
await new Promise(r => setTimeout(r, stepMs))
|
||
}
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------- 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 applyDepthWhenReady(d)) dbg(`re-apply done depth=${d}`)
|
||
} catch (err) {
|
||
dbg('re-apply THREW', err)
|
||
} finally {
|
||
queued = false
|
||
}
|
||
})
|
||
}
|
||
// The observer callback is the real trigger. The engine has no notion of
|
||
// focus -- it re-renders because IntersectionObserver or ResizeObserver told
|
||
// it the element is visible again, and that re-render is what discards the
|
||
// view. observerShim.js sits in front of those callbacks and re-emits them,
|
||
// so this fires at the rebuild rather than at a proxy for it.
|
||
//
|
||
// The focus/visibilitychange listeners stay as a backstop: if the shim was
|
||
// loaded too late to patch anything, or a browser refused the assignment,
|
||
// they still catch the common case.
|
||
// ResizeObserver fires on every frame of a panel drag, and each event would
|
||
// otherwise queue its own re-apply. Trail the burst and act once it settles.
|
||
let settle = null
|
||
const onObserved = (ev) => {
|
||
const d = ev.detail || {}
|
||
// A resize to zero, or losing intersection, is the element going away --
|
||
// nothing to re-apply to yet. Only act on it coming back.
|
||
const returning = d.entries?.some(e =>
|
||
e.isIntersecting === true || (e.width > 0 && e.height > 0))
|
||
if (!returning) return
|
||
clearTimeout(settle)
|
||
settle = setTimeout(() => {
|
||
dbg(`observer ${d.kind} settled -> viewer visible again`)
|
||
reapply({ type: `observer:${d.kind}` })
|
||
}, 150)
|
||
}
|
||
|
||
window.addEventListener(PF_OBSERVER_EVENT, onObserved)
|
||
document.addEventListener('visibilitychange', reapply)
|
||
window.addEventListener('focus', reapply)
|
||
window.addEventListener('pageshow', reapply)
|
||
return () => {
|
||
clearTimeout(settle)
|
||
window.removeEventListener(PF_OBSERVER_EVENT, onObserved)
|
||
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)
|
||
}
|
||
// A dollar change on its own is ambiguous -- price x volume, and the number
|
||
// says nothing about which moved. plug names the one that absorbs it. Only
|
||
// sent when dollars are the sole input; naming units or price has already
|
||
// answered the question.
|
||
if (vn != null && un == null && pn == null) body.plug = scalePlug
|
||
} 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,
|
||
scalePlug, setScalePlug,
|
||
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' }
|
||
|