The effect decided which dim_groups to load from colMetaRef.current, but
that ref is filled inside initViewer, which is async. The effect ran first,
saw an empty array, found no groups, and fetched nothing -- and a ref
changing does not re-run an effect, so it never recovered. dimMembers stayed
{} for the whole session.
Everything therefore fell back to the source lookup, which is precisely the
query pf.dim_member exists to replace: XCP06500G18B112 has two attribute
combinations across history, so DISTINCT ... LIMIT 2 returned two rows and
the route answered null. The member row had the answer all along, picked as
the most recent by order date.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1831 lines
82 KiB
JavaScript
1831 lines
82 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')
|
||
// which change-log row has its payload open, if any
|
||
const [expandedLog, setExpandedLog] = useState(null)
|
||
// master data per dim_group, fetched once per source: { part: { key_col, siblings, members } }
|
||
const [dimMembers, setDimMembers] = 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_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([]) }
|
||
}
|
||
|
||
// One request per dim_group that has an is_key. The lists are small enough to
|
||
// hold whole -- 11,290 parts -- which is what lets completion and the sibling
|
||
// autofill run locally instead of querying the source per keystroke.
|
||
useEffect(() => {
|
||
if (!sourceId) { setDimMembers({}); return }
|
||
let cancelled = false
|
||
;(async () => {
|
||
// col_meta is fetched here rather than read from colMetaRef: that ref is
|
||
// filled inside initViewer, which is async, so this effect ran first, saw an
|
||
// empty array, found no groups and never fetched a list -- and a ref changing
|
||
// does not re-run an effect, so it never recovered. It is a small query and
|
||
// the browser will serve it from cache anyway.
|
||
let meta = []
|
||
try {
|
||
meta = await fetch(`/api/sources/${sourceId}/cols`).then(r => r.ok ? r.json() : [])
|
||
} catch { return }
|
||
if (cancelled) return
|
||
const groups = [...new Set(meta.filter(c => c.dim_group && c.is_key).map(c => c.dim_group))]
|
||
const loaded = {}
|
||
for (const g of groups) {
|
||
try {
|
||
const r = await fetch(`/api/sources/${sourceId}/dim/${encodeURIComponent(g)}`)
|
||
if (r.ok) loaded[g] = await r.json()
|
||
} catch { /* a group with no members yet just falls back */ }
|
||
}
|
||
if (!cancelled) setDimMembers(loaded)
|
||
})()
|
||
return () => { cancelled = true }
|
||
}, [sourceId, versionId])
|
||
|
||
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 meta = colMetaRef.current.find(c => c.cname === col)
|
||
const group = meta?.dim_group && dimMembers[meta.dim_group]
|
||
|
||
// With the member list in hand this is a local lookup, and it answers where the
|
||
// source query could not: the source holds every attribute set a part ever had,
|
||
// so anything with more than one came back ambiguous and filled nothing. A
|
||
// member row is a single definition by construction.
|
||
//
|
||
// A miss is not an answer though. The list can be empty (never refreshed, or a
|
||
// refresh still running) or simply not contain this key, and in either case the
|
||
// source still knows. Falling back costs one request on a path that only runs
|
||
// when someone types a value.
|
||
let derived = group?.members?.length
|
||
? (group.members.find(m => m.key_value === value.trim())?.attrs || null)
|
||
: null
|
||
|
||
if (!derived) {
|
||
const res = await fetch(`/api/sources/${sourceId}/lookup?col=${encodeURIComponent(col)}&value=${encodeURIComponent(value)}`)
|
||
if (!res.ok) return
|
||
derived = await res.json()
|
||
}
|
||
if (!derived) {
|
||
flash(`No attributes found for ${col} "${value.trim()}" — the other fields are unchanged`, 'warn')
|
||
return
|
||
}
|
||
|
||
// Overwrite rather than only filling blanks. These columns describe the key
|
||
// that was just entered, so whatever a previous key left behind is wrong, not
|
||
// worth preserving -- and leaving it made a second lookup look like it had
|
||
// done nothing at all, since every box was already full.
|
||
setter(prev => {
|
||
const next = { ...prev }
|
||
for (const [k, v] of Object.entries(derived)) 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)
|
||
setExpandedLog(null)
|
||
setLogLoading(true)
|
||
try {
|
||
// Baseline and reference loads are segment construction, not forecasting.
|
||
// They are managed in the Baseline view, where they can be edited in place
|
||
// and their date ranges seen; listing them here only buries the adjustments
|
||
// this log is for -- and offers an undo that would silently gut the version.
|
||
// Asked for by kind so the server does not total rows we are not showing.
|
||
const data = await fetch(`/api/versions/${versionId}/log?kind=adjustments`).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, dimMembers, versionId,
|
||
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-6xl mx-4 flex flex-col max-h-[85vh]" 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 table-fixed">
|
||
<thead className="sticky top-0 bg-gray-50 text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>
|
||
<tr>
|
||
<th className="text-left px-3 py-2 font-medium w-28">Time</th>
|
||
<th className="text-left px-3 py-2 font-medium w-20">Op</th>
|
||
<th className="text-left px-3 py-2 font-medium">Slice</th>
|
||
<th className="text-left px-3 py-2 font-medium w-32">Tag</th>
|
||
<th className="text-left px-3 py-2 font-medium w-48">Note</th>
|
||
<th className="text-right px-3 py-2 font-medium w-28">Value</th>
|
||
<th className="text-right px-3 py-2 font-medium w-16">Rows</th>
|
||
<th className="px-3 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-3 py-2 text-gray-400 whitespace-nowrap">{fmtStamp(entry.stamp)}</td>
|
||
<td className="px-3 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-3 py-2 text-gray-600 font-mono overflow-hidden">
|
||
<button
|
||
onClick={() => setExpandedLog(prev => prev === entry.id ? null : entry.id)}
|
||
className="text-left w-full truncate hover:text-blue-600"
|
||
title="Show the payload">
|
||
<span className="text-gray-400 mr-1">{expandedLog === entry.id ? '▾' : '▸'}</span>
|
||
{fmtSliceSummary(entry.slice)}
|
||
</button>
|
||
</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-3 py-2 text-right tabular-nums whitespace-nowrap ${
|
||
entry.value_total > 0 ? 'text-green-700' : entry.value_total < 0 ? 'text-red-600' : 'text-gray-400'}`}>
|
||
{entry.value_total == null ? '—'
|
||
: entry.value_total.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||
</td>
|
||
<td className="px-3 py-2 text-right text-gray-500 tabular-nums">{entry.row_count ?? '—'}</td>
|
||
<td className="px-3 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>
|
||
)).flatMap((row, i) => {
|
||
const entry = logEntries[i]
|
||
if (expandedLog !== entry.id) return [row]
|
||
return [row, (
|
||
<tr key={`${entry.id}-detail`} className="bg-gray-50 border-t border-gray-100">
|
||
<td colSpan={8} className="px-3 py-3">
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<LogJson label="slice" value={entry.slice} />
|
||
<LogJson label="params" value={entry.params} />
|
||
</div>
|
||
</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-3 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-3 py-2 text-gray-700 overflow-hidden">
|
||
<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' })
|
||
}
|
||
|
||
// A multi-slice operation logs an array of slices, and Object.entries over an
|
||
// array yields its indices -- which is where "0 = [object Object]" came from.
|
||
// Each slice gets its own line; a date arrives as epoch millis and is useless
|
||
// as a number.
|
||
function fmtSliceLines(slice) {
|
||
if (!slice) return []
|
||
const one = (sl) => Object.entries(sl)
|
||
.map(([k, v]) => `${k} = ${fmtSliceValue(v)}`)
|
||
.join(' · ')
|
||
if (!Array.isArray(slice)) return Object.keys(slice).length ? [one(slice)] : []
|
||
|
||
const list = slice.filter(sl => sl && Object.keys(sl).length)
|
||
if (list.length <= 1) return list.map(one)
|
||
|
||
// A dragged region is one dimension walked across a fixed set of others -- the
|
||
// five slices of log 118 differ only in omon. Printing the other four fields
|
||
// five times buries the one that actually varies, so pull the shared part out
|
||
// and list the varying values on their own line.
|
||
const keys = [...new Set(list.flatMap(sl => Object.keys(sl)))]
|
||
const varying = keys.filter(k => new Set(list.map(sl => JSON.stringify(sl[k]))).size > 1)
|
||
|
||
// Two or more independent axes would lose their pairing if flattened this way,
|
||
// so only collapse when a single dimension is doing the varying.
|
||
if (varying.length !== 1) return list.map(one)
|
||
|
||
const [vk] = varying
|
||
const fixed = Object.fromEntries(keys.filter(k => k !== vk).map(k => [k, list[0][k]]))
|
||
const values = [...new Set(list.map(sl => fmtSliceValue(sl[vk])))]
|
||
return [
|
||
...(Object.keys(fixed).length ? [one(fixed)] : []),
|
||
`${vk} = ${values.join(', ')}`,
|
||
]
|
||
}
|
||
|
||
// One line, whatever the shape. The full payload is a click away, so this only
|
||
// has to say enough to recognise the entry: what varied, and how much of it.
|
||
function LogJson({ label, value }) {
|
||
const empty = value == null || (typeof value === 'object' && Object.keys(value).length === 0)
|
||
return (
|
||
<div>
|
||
<div className="text-[10px] uppercase tracking-wide text-gray-400 mb-1">{label}</div>
|
||
<pre className="text-[11px] leading-snug font-mono text-gray-600 bg-white border border-gray-200
|
||
rounded p-2 overflow-auto max-h-64 whitespace-pre">
|
||
{empty ? '—' : JSON.stringify(value, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function fmtSliceSummary(slice) {
|
||
const lines = fmtSliceLines(slice)
|
||
if (lines.length === 0) return '—'
|
||
if (!Array.isArray(slice) || slice.length <= 1) return lines.join(' · ')
|
||
|
||
const keys = [...new Set(slice.flatMap(sl => Object.keys(sl || {})))]
|
||
const varying = keys.filter(k => new Set(slice.map(sl => JSON.stringify(sl?.[k]))).size > 1)
|
||
|
||
if (varying.length === 1) {
|
||
const [vk] = varying
|
||
const vals = [...new Set(slice.map(sl => fmtSliceValue(sl[vk])))]
|
||
const span = vals.length > 2 ? `${vals[0]} … ${vals[vals.length - 1]}` : vals.join(', ')
|
||
return `${slice.length} slices · ${vk} = ${span}`
|
||
}
|
||
return `${slice.length} slices · ${varying.join(', ')} vary`
|
||
}
|
||
|
||
function fmtSliceValue(v) {
|
||
if (v === null || v === undefined || v === '') return '∅'
|
||
if (typeof v === 'number' && v > 1e11) {
|
||
const d = new Date(v)
|
||
if (!isNaN(d)) return d.toISOString().slice(0, 10)
|
||
}
|
||
if (typeof v === 'object') return JSON.stringify(v)
|
||
return String(v)
|
||
}
|
||
|
||
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' }
|
||
|