The previous commit stopped stored layouts overriding the theme, which
was necessary and not sufficient: nothing was setting it either.
setAttribute('theme', ...) does nothing in 5.4.0 -- the theme is a
ViewerConfig field (ViewerConfig.theme, a theme name) and the element
observes no such attribute -- so the pivot stayed light whatever the
toggle said. Presumably it worked on an older Perspective; CLAUDE.md
documented it and is corrected here too.
A partial restore, so it does not rebuild the view the way applyDepth's
whole-config restore does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2742 lines
130 KiB
JavaScript
2742 lines
130 KiB
JavaScript
import { useState, useEffect, useRef } from 'react'
|
||
import useTheme from '../theme.jsx'
|
||
import LayoutMenu from '../components/LayoutMenu.jsx'
|
||
import DepthButtons from '../components/DepthButtons.jsx'
|
||
import useAuth from '../auth.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'
|
||
|
||
// Slice keys that are not col_meta columns: computed from pf.log when the rows are
|
||
// served, so they are real columns in the loaded table but have to be resolved back
|
||
// to log ids server-side. Mirrors COMPUTED_SLICE_COLS in lib/sql_generator.js.
|
||
const COMPUTED_SLICE_COLS = new Set(['pf_segment', 'pf_bucket'])
|
||
|
||
// The unnamed last-used config stays in localStorage: it is per-browser session
|
||
// continuity, not a thing anyone names or shares. LAYOUTS_KEY is the old named
|
||
// list, read once per version and lifted into pf.layout (see loadLayouts).
|
||
const LAYOUT_KEY = (vid) => `pf_layout_v${vid}` // last-used layout (auto restore)
|
||
const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // legacy named list, migrated away
|
||
|
||
function cleanLayout(cfg, validCols) {
|
||
if (!cfg) return cfg
|
||
const c = { ...cfg }
|
||
// The theme is the reader's own light/dark choice, not a property of the
|
||
// layout. viewer.save() includes it, so it ends up baked into every stored
|
||
// config -- and restoring one then snapped the pivot back to whatever the
|
||
// author had, silently undoing the toggle. Dropped on the way in, which makes
|
||
// the app's `dark` state the only thing that sets it, and means a published
|
||
// layout cannot impose its author's theme on everyone who applies it.
|
||
delete c.theme
|
||
// Dead expressions go before the axis filter, not after: dropping them from
|
||
// `expressions` is what makes `ok()` reject them everywhere else.
|
||
if (DEAD_ORDER_EXPRS.some(n => c.expressions?.[n] !== undefined)) {
|
||
c.expressions = { ...c.expressions }
|
||
for (const name of DEAD_ORDER_EXPRS) delete c.expressions[name]
|
||
}
|
||
const exprNames = new Set(Object.keys(c.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))
|
||
// aggregates is keyed by column and, in the multi-arg form, names a second
|
||
// one: ["weighted mean", "units"] depends on the weight column as much as on
|
||
// the column it is set against, so both have to survive or the entry goes.
|
||
// Drop the entry, never the layout -- restore() is all-or-nothing, so one
|
||
// stale aggregate would otherwise take the whole config down with it.
|
||
if (c.aggregates) {
|
||
c.aggregates = Object.fromEntries(
|
||
Object.entries(c.aggregates).filter(([col, agg]) =>
|
||
ok(col) && (!Array.isArray(agg) || agg.slice(1).every(a => typeof a !== 'string' || ok(a))))
|
||
)
|
||
}
|
||
return c
|
||
}
|
||
|
||
// Expression columns this view used to manage, back when the pf_bucket and
|
||
// pf_segment ordering prefix was computed in the pivot rather than stored in
|
||
// pf.log.label.
|
||
//
|
||
// Stripped by cleanLayout, never created: a layout saved under that scheme still
|
||
// names them, and without this they would sit there forever, ordering by a rule
|
||
// nothing updates. They have to leave the axes at the same time as the
|
||
// expressions themselves -- restore() rejects a config whose group_by or sort
|
||
// names a column that no longer exists.
|
||
const DEAD_ORDER_EXPRS = ['pf_bucket_ord', 'pf_segment_ord', 'Bucket', 'Segment']
|
||
|
||
export default function Forecast({ sources = [], sourceId, versions = [], versionId, refreshSources }) {
|
||
const { dark } = useTheme()
|
||
const { user } = useAuth()
|
||
// Undo removes an entry's rows wholesale, so the server allows it only to the
|
||
// account that made it, or an admin. Mirrored here to say so before the click
|
||
// rather than after the 403.
|
||
const canUndo = (entry) => !!user && (user.is_admin || entry.pf_user === user.username)
|
||
// Strictly authorship, which is not the same test as canUndo: an admin can
|
||
// undo everything, and marking every row as theirs would say nothing.
|
||
const isMine = (entry) => !!user && entry.pf_user === user.username
|
||
const [loading, setLoading] = useState(false)
|
||
const [largeDataset, setLargeDataset] = useState(false)
|
||
// The pivot's own filter, refreshed whenever the ledger recomputes. A ref
|
||
// rather than state: it is read at dispatch and at totals time, never
|
||
// rendered from directly, and making it state would re-run the totals effect
|
||
// that sets it.
|
||
const viewFilterRef = useRef([])
|
||
const [loadProgress, setLoadProgress] = useState(null) // { received, total }
|
||
// Rows the load is waiting on, so the wait can say what it is waiting for --
|
||
// on this data the row count is the wait (see CLAUDE.md, "Load time is
|
||
// dominated by row count").
|
||
//
|
||
// Filled twice. X-Row-Count is exact but arrives with the response headers,
|
||
// and in grain mode the server aggregates before sending any: the number
|
||
// turned up just as the wait ended. So the forecast table's own count goes in
|
||
// first, from the same table-info the status bar reads, and the exact figure
|
||
// replaces it when the headers land.
|
||
const [loadRows, setLoadRows] = useState(null)
|
||
// the same filter, for display: the panel has to show the scope it is acting
|
||
// inside or the slice it prints is not the slice that gets written
|
||
const [viewScope, setViewScope] = useState([])
|
||
// which operation is in flight, so the panel can say so and refuse a second click
|
||
const [opBusy, setOpBusy] = useState(null)
|
||
const [msg, setMsg] = useState(null)
|
||
|
||
// layouts
|
||
const [layouts, setLayouts] = useState([])
|
||
const [activeLayoutId, setActiveLayoutId] = useState(null)
|
||
// Whether the pivot has drifted from the layout it was applied from. Compared
|
||
// rather than flagged: every restore fires perspective-config-update, so a
|
||
// flag set by the event alone would read dirty the moment a layout was applied.
|
||
const [layoutDirty, setLayoutDirty] = useState(false)
|
||
const activeConfigRef = useRef(null)
|
||
|
||
// 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)
|
||
// The row hierarchy, for the same reason: the depth buttons name the level
|
||
// they show rather than counting to a number nobody can map back to a field.
|
||
const [groupFull, setGroupFull] = useState([])
|
||
|
||
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.
|
||
// Price holds unless somebody says otherwise. The key is versioned because the
|
||
// default flipped: a browser carrying the old 'price' choice would otherwise
|
||
// keep moving price silently, which is the behaviour being corrected.
|
||
const [scalePlug, setScalePlug] = useState(() => localStorage.getItem('pf_scale_plug_v2') || 'volume')
|
||
// 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({})
|
||
// clone can borrow its mix from a named segment instead of the current selection
|
||
const [cloneFrom, setCloneFrom] = useState('')
|
||
const [cloneOffset, setCloneOffset] = useState('12 months')
|
||
// 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_v2', 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)
|
||
// Whose entries the log lists. Not persisted, and it resets to mine on
|
||
// every open, so opening the log starts on what you're most likely looking
|
||
// for -- your own recent work -- rather than everyone's.
|
||
const [logWho, setLogWho] = useState('mine') // 'all' | 'mine'
|
||
const [editingCell, setEditingCell] = useState(null) // { id, field: 'note'|'tag', text }
|
||
const [undoingId, setUndoingId] = useState(null)
|
||
|
||
const viewerRef = useRef(null)
|
||
// Depth, not a boolean: config updates overlap, and the first one to finish
|
||
// would otherwise clear the flag while the others are still rebuilding.
|
||
const busyRef = useRef(0)
|
||
const workerRef = useRef(null)
|
||
const tableRef = useRef(null)
|
||
const colMetaRef = useRef([])
|
||
// Mirrors ViewConfig.group_by_depth for the toolbar's active state. The
|
||
// config is the source of truth; this only exists so the buttons can render.
|
||
const [expandDepth, setExpandDepth] = useState(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([])
|
||
// a collapse from the user rearranging split_by themselves
|
||
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)
|
||
// the worker-registry name of the table currently loaded, so the next load can
|
||
// try to free it
|
||
const tableNameRef = 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-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 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 = []
|
||
// cells whose column-axis value is blank, which cannot be isolated (see
|
||
// cellFilters) -- counted so the drag can say why it skipped them once,
|
||
// rather than per cell
|
||
let blankCols = 0
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const path = rows[i].__ROW_PATH__ || []
|
||
// 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 && !path.length) continue
|
||
for (let c = c0; c < c1; c++) {
|
||
const key = userKeys[c]
|
||
if (!key) continue
|
||
const { rowFilters, colFilters, colBlank } = cellFilters(cfg, rows[i], key)
|
||
if (colBlank) { blankCols++; continue }
|
||
if (groupBy.length && !rowFilters.length) continue
|
||
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters],
|
||
(cfg.columns || []).filter(Boolean))
|
||
if (!Object.keys(slice).length) continue
|
||
const y = win.start_row + i
|
||
out.push({ slice, area: { x0: c, x1: c, y0: y, y1: y } })
|
||
}
|
||
}
|
||
if (blankCols) {
|
||
flash(`${blankCols} cell${blankCols > 1 ? 's' : ''} skipped: a blank column-axis value cannot be isolated`, 'error')
|
||
}
|
||
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
|
||
// The layouts decide which config the first load restores, so they are
|
||
// fetched before the viewer rather than beside it.
|
||
;(async () => {
|
||
const list = await loadLayouts(versionId)
|
||
initViewer(versionId, sourceId, list)
|
||
})()
|
||
}, [versionId, sourceId])
|
||
|
||
useEffect(() => {
|
||
if (viewerRef.current) {
|
||
// restore(), not setAttribute('theme'): the theme is a ViewerConfig field
|
||
// in 5.4.0 (ViewerConfig.theme, a theme *name* like "Pro Dark"), and the
|
||
// element observes no such attribute -- the attribute call this replaced
|
||
// did nothing at all, so the pivot simply stayed light.
|
||
//
|
||
// A partial update: everything else is left as it is, so this does not
|
||
// rebuild the view the way applyDepth's whole-config restore does.
|
||
// cleanLayout drops `theme` on the way in, so no layout restore puts it
|
||
// back afterwards.
|
||
viewerRef.current.restore({ 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']
|
||
|
||
// A slice carries every value as a string -- it is built from filters the
|
||
// grid reports and from a payload that has to survive JSON. Perspective
|
||
// matches on type, so a string '2027' against an integer column does not
|
||
// filter to nothing, it is dropped: the ledger then totalled rows the pivot
|
||
// was hiding, which is how a season filter on sseas_e went unnoticed while
|
||
// the numbers disagreed by exactly the out-of-season rows.
|
||
// The pivot's own filter is not part of a clicked slice -- perspective-click
|
||
// reports only the cell's own dimensions -- so the ledger has to read it off
|
||
// the viewer and apply it alongside. Without this the ledger totals rows the
|
||
// grid is hiding, and the operation writes them: a grid scoped to
|
||
// sseas_e = 2027 gave a cell of 921,225.71 against a ledger of 956,485.13.
|
||
//
|
||
// Taken from viewer.save(), so the values are already in the table's own
|
||
// types and the operators are whatever the user set -- ranges and in-lists
|
||
// included, which a slice cannot express.
|
||
const viewFilter = await (async () => {
|
||
try {
|
||
const cfg = await viewerRef.current?.save()
|
||
return (cfg?.filter || []).filter(f => Array.isArray(f) && f.length >= 2)
|
||
} catch { return [] }
|
||
})()
|
||
viewFilterRef.current = viewFilter
|
||
setViewScope(viewFilter)
|
||
|
||
const schema = await tableRef.current.schema()
|
||
const typed = (col, val) => {
|
||
switch (schema[col]) {
|
||
case 'integer': case 'float': return Number(val)
|
||
case 'boolean': return val === true || val === 'true'
|
||
case 'date': case 'datetime': return Number(val)
|
||
default: return String(val)
|
||
}
|
||
}
|
||
|
||
async function totalsFor(sliceObj) {
|
||
// pf_segment and pf_bucket are computed server-side but are ordinary
|
||
// columns in the loaded table, so here they filter directly. They have to
|
||
// be applied, or the ledger totals a wider selection than the operation
|
||
// will write.
|
||
const filters = [
|
||
...viewFilter,
|
||
...Object.entries(sliceObj)
|
||
.filter(([col]) => COMPUTED_SLICE_COLS.has(col) || dimNames.has(col) || dateNames.has(col)
|
||
|| schema[col] !== undefined)
|
||
// a cell inside the filtered view cannot contradict it, so a repeated
|
||
// column is the same predicate twice and harmless
|
||
.filter(([col]) => !viewFilter.some(f => f[0] === col))
|
||
// A null is filtered with `is null`, not `== null`: the engine matches
|
||
// on type, and a null threshold against a string column is the same
|
||
// dropped predicate that widened the slice in the first place. The
|
||
// ledger has to total the rows the operation will write, and the
|
||
// operation writes IS NULL.
|
||
.map(([col, val]) => (val === null ? [col, 'is null'] : [col, '==', typed(col, 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.
|
||
// Per segment, not one lump: YTD Sales and Open Orders are different
|
||
// things, and a single "final" line hides which part of the number is
|
||
// which.
|
||
const excluded = { value: 0, units: 0, rows: 0, names: new Set(), bySegment: new Map() }
|
||
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
|
||
// Name them by what they are, not by the iter band that happens to
|
||
// exclude them: "02 - Prior Year" means something to a forecaster,
|
||
// "reference" is the mechanism.
|
||
const name = String(r.pf_segment || 'excluded')
|
||
excluded.names.add(name)
|
||
const seg = excluded.bySegment.get(name) || { name, value: 0, units: 0, rows: 0 }
|
||
seg.value += val
|
||
seg.units += uni
|
||
seg.rows += 1
|
||
excluded.bySegment.set(name, seg)
|
||
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),
|
||
names: new Set([...acc.names, ...(ps.excluded?.names || [])]),
|
||
bySegment: (() => {
|
||
const m = acc.bySegment
|
||
for (const seg of (ps.excluded?.bySegment?.values?.() || [])) {
|
||
const t = m.get(seg.name) || { name: seg.name, value: 0, units: 0, rows: 0 }
|
||
t.value += seg.value; t.units += seg.units; t.rows += seg.rows
|
||
m.set(seg.name, t)
|
||
}
|
||
return m
|
||
})(),
|
||
}), { value: 0, units: 0, rows: 0, names: new Set(), bySegment: new Map() })
|
||
|
||
setCurrentTotals({
|
||
byIter, byEntry, total, valueCol, unitsCol, perSlice,
|
||
excluded: {
|
||
...excluded,
|
||
names: [...excluded.names].sort(),
|
||
bySegment: [...excluded.bySegment.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||
},
|
||
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] = {
|
||
label: e.label || null,
|
||
tag: e.tag || null, note: e.note || null, operation: e.operation,
|
||
bucket: e.bucket || null,
|
||
}
|
||
setLogMeta(map)
|
||
} catch { setLogMeta({}) }
|
||
}
|
||
|
||
async function refreshTags(vid) {
|
||
if (!vid) { setKnownTags([]); return }
|
||
try {
|
||
const rows = await fetch(`/api/versions/${vid}/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])
|
||
// Relabelling on the Baseline page needs a reload to show here: the label is
|
||
// part of the aggregated row, so the pivot cannot re-derive it in place.
|
||
useEffect(() => { refreshTags(versionId) }, [versionId])
|
||
|
||
// 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
|
||
if (rowCount) setLoadRows(rowCount)
|
||
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 }
|
||
}
|
||
|
||
// Named layouts live in pf.layout, not localStorage: a published one has to
|
||
// reach everyone on the forecast, and a private one has to survive a new
|
||
// browser.
|
||
async function loadLayouts(vid) {
|
||
try {
|
||
// One-time lift of whatever this browser accumulated under the old scheme.
|
||
// A name that already exists comes back 409 and is simply already
|
||
// migrated, so the key is cleared either way -- the alternative is
|
||
// re-offering the same failed migration on every load forever.
|
||
const legacy = localStorage.getItem(LAYOUTS_KEY(vid))
|
||
if (legacy) {
|
||
for (const l of (JSON.parse(legacy) || [])) {
|
||
if (!l?.name || !l?.config) continue
|
||
try {
|
||
await fetch(`/api/versions/${vid}/layouts`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: l.name, config: l.config, visibility: 'private' })
|
||
})
|
||
} catch { /* keep going: one bad row must not strand the rest */ }
|
||
}
|
||
localStorage.removeItem(LAYOUTS_KEY(vid))
|
||
}
|
||
const list = await fetch(`/api/versions/${vid}/layouts`).then(r => r.ok ? r.json() : [])
|
||
setLayouts(list)
|
||
setActiveLayoutId(null)
|
||
setLayoutDirty(false)
|
||
return list
|
||
} catch (err) {
|
||
console.error('[loadLayouts]', err)
|
||
setLayouts([])
|
||
return []
|
||
}
|
||
}
|
||
|
||
// Config equality for the dirty dot. `table` is the per-load table name and
|
||
// differs on every refresh, so it is never a real difference.
|
||
function sameConfig(a, b) {
|
||
if (!a || !b) return false
|
||
// table is the per-load table name, different on every refresh. theme is
|
||
// the reader's light/dark choice and is not part of the layout (see
|
||
// cleanLayout) -- without it here, toggling the theme would light the dirty
|
||
// dot and offer to save a layout nothing had edited.
|
||
const strip = ({ table, theme, ...rest }) => JSON.stringify(rest)
|
||
return strip(a) === strip(b)
|
||
}
|
||
|
||
// Remember what the pivot currently matches, so the dot can be computed
|
||
// against it rather than guessed from events.
|
||
function markClean(cfg, id) {
|
||
activeConfigRef.current = cfg || null
|
||
if (id !== undefined) setActiveLayoutId(id)
|
||
setLayoutDirty(false)
|
||
}
|
||
|
||
async function initViewer(vid, sid, layoutList = []) {
|
||
const viewer = viewerRef.current
|
||
if (!viewer) return
|
||
const myId = ++initIdRef.current
|
||
setLoading(true)
|
||
setLargeDataset(false)
|
||
setLoadProgress(null)
|
||
setLoadRows(null)
|
||
// deliberately not awaited: it is a count over the whole forecast table and
|
||
// the load must not wait on it
|
||
fetch(`/api/versions/${vid}/table-info`)
|
||
.then(r => r.ok ? r.json() : null)
|
||
.then(info => { if (info?.rows && initIdRef.current === myId) setLoadRows(n => n ?? info.rows) })
|
||
.catch(() => {})
|
||
setSlices([])
|
||
setExpandDepth(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_bucket', '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_bucket', 'pf_note', 'pf_op',
|
||
])
|
||
// Unique per load, not per version. A fixed name collides on refresh:
|
||
// the viewer still holds the previous table, so deleting it does not
|
||
// actually free the registry entry, and creating the new one aborts with
|
||
// "Table `fc_29` already exists". Nothing reads the name -- the viewer is
|
||
// loaded by reference and cleanLayout strips the name out of saved
|
||
// configs -- so it only has to be unique.
|
||
const tableName = `fc_${vid}_${myId}`
|
||
|
||
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 the name we
|
||
// gave it (covers the case where the ref was lost or the delete failed).
|
||
// Best-effort: a table the viewer is still holding will refuse, which is
|
||
// why the new one no longer reuses its name.
|
||
if (tableRef.current) {
|
||
try { await tableRef.current.delete() } catch {}
|
||
tableRef.current = null
|
||
}
|
||
if (tableNameRef.current && tableNameRef.current !== tableName) {
|
||
try {
|
||
const stale = await worker.open_table(tableNameRef.current)
|
||
if (stale) await stale.delete()
|
||
} catch {}
|
||
}
|
||
tableNameRef.current = tableName
|
||
|
||
// An empty result gets no index. `[]` carries no columns, so naming one
|
||
// aborts the worker outright -- "Specified index `pf_gkey` does not exist
|
||
// in dataset" -- and the page dies rather than saying it found nothing.
|
||
// Nothing is a legitimate answer: an empty version, or a territory with no
|
||
// rows in it.
|
||
const opts = rowCount > 0
|
||
? { name: tableName, index: indexCol }
|
||
: { name: tableName }
|
||
tableRef.current = await (rowCount > 0 ? worker.table(buffer, opts) : worker.table([], opts))
|
||
if (rowCount === 0) {
|
||
flash('No rows to show — the version is empty, or none of it is in your territory', 'error')
|
||
}
|
||
|
||
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)
|
||
|
||
// Auto-pause is on by default, and pausing *deletes the view*:
|
||
// AutoPauseState::apply() fires on visibilitychange and on the viewer's own
|
||
// IntersectionObserver, and set_pause(true) does
|
||
// `view_sub.take().delete()`. Coming back is therefore not a redraw but
|
||
// `restore_and_render()` -- a fresh view, a fresh traversal of the whole
|
||
// grain -- which is the multi-second chug on every tab switch, and why any
|
||
// per-node expansion is gone when you return.
|
||
//
|
||
// Nothing updates the table while the tab is hidden (operations are driven
|
||
// from this page), so the pause buys nothing here and costs a rebuild.
|
||
// Guarded: it is a method on the custom element, absent on older builds.
|
||
try { if (viewer.setAutoPause) await viewer.setAutoPause(false) } catch (err) {
|
||
console.error('[setAutoPause]', err)
|
||
}
|
||
|
||
await viewer.restore({ theme: dark ? 'Pro Dark' : 'Pro Light' })
|
||
if (!hideSplitTotal()) setTimeout(hideSplitTotal, 400)
|
||
|
||
// 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.
|
||
//
|
||
// An empty table has no schema at all, so any config naming any column
|
||
// aborts the worker -- "Could not get dtype for column `sseas_e`". There
|
||
// is nothing to lay out, so nothing is restored; the saved layout stays in
|
||
// localStorage and comes back when there are rows again.
|
||
const saved = rowCount > 0 ? localStorage.getItem(LAYOUT_KEY(vid)) : null
|
||
if (rowCount === 0) {
|
||
await viewer.restore({ settings: false, plugin_config: { edit_mode: 'SELECT_REGION' } })
|
||
} else 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 is the legacy key, from when collapsing truncated split_by
|
||
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
|
||
cfg.split_by_depth != null ? cfg.split_by_depth : null)
|
||
setGroupFull(cfg.group_by || [])
|
||
// restore() has already applied group_by_depth; this only syncs the
|
||
// toolbar. The config's count and the stepper's are the same unit, so
|
||
// it reads across as it is -- it used to subtract the 1 that applyDepth
|
||
// added, which kept the toolbar agreeing with itself and with nothing
|
||
// else. expand_depth is the legacy key, from when depth was imperative
|
||
// and had to be stored beside the config rather than in it.
|
||
if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth)
|
||
else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
||
} else {
|
||
// The default is a published layout flagged is_default. A version-scoped
|
||
// one wins over a source-wide one: the narrower answer is the one
|
||
// somebody chose for this forecast specifically.
|
||
const defaults = layoutList.filter(l => l.is_default)
|
||
const defaultRow = defaults.find(l => l.version_id != null) || defaults[0]
|
||
const sourceDefault = defaultRow?.config
|
||
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)
|
||
setGroupFull(cfg.group_by || [])
|
||
// Name what we landed on, so the menu says which layout this is and the
|
||
// dirty dot has something to compare against. Read back rather than
|
||
// reusing cfg: restore() normalises, so the live config is what a later
|
||
// save() will be compared with.
|
||
if (defaultRow) markClean(await viewer.save(), defaultRow.id)
|
||
}
|
||
|
||
// Its own try: the restore above has one, but this sits outside it, and a
|
||
// throw here would abort the rest of initViewer silently.
|
||
try {
|
||
await ensureRowLabelWidth()
|
||
} catch (err) {
|
||
console.error('[pf-layout] threw', err)
|
||
}
|
||
|
||
if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate)
|
||
viewer._pspUpdate = async () => {
|
||
try {
|
||
// split_by is never truncated now, so the live value is always the real
|
||
// hierarchy and can be adopted unconditionally -- no need to tell our own
|
||
// collapse apart from the user rearranging the pivot.
|
||
const live = await viewer.save()
|
||
adoptSplit(live.split_by || [],
|
||
live.split_by_depth != null ? live.split_by_depth : null)
|
||
setGroupFull(live.group_by || [])
|
||
// the plugin element is replaced when the plugin changes, so re-assert
|
||
hideSplitTotal()
|
||
const cfg = await captureConfig()
|
||
if (cfg) await persistLayout(vid, cfg)
|
||
// Dirty is a comparison, not a flag: restore() itself fires this
|
||
// event, so anything set unconditionally here would light up the
|
||
// moment a layout was applied.
|
||
if (cfg) setLayoutDirty(!!activeConfigRef.current && !sameConfig(cfg, activeConfigRef.current))
|
||
} 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
|
||
// 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
|
||
// detail.config.filter has already lost any blank group key -- the engine
|
||
// drops a falsy pivot value when it builds that list -- so the slice is
|
||
// re-derived from detail.row's own row path, exactly as a dragged region
|
||
// is. The clicked column comes from the mousedown's cell, since the event
|
||
// names only the measure. Without a cell there is nothing to index the
|
||
// column axis with, so fall back to the event's own list.
|
||
const colKey = cell ? userKeysOf(detail.row)[cell.x] : undefined
|
||
const measures = (config.columns || []).filter(Boolean)
|
||
let s
|
||
if (cell) {
|
||
const { rowFilters, colFilters, colBlank } = cellFilters(config, detail.row, colKey)
|
||
if (colBlank) {
|
||
flash('A blank column-axis value cannot be isolated — filter on it instead', 'error')
|
||
return
|
||
}
|
||
if (!rowFilters.length) return
|
||
s = sliceFromFilters([...(config.filter || []), ...rowFilters, ...colFilters], measures)
|
||
} else {
|
||
s = sliceFromFilters((detail.config || {}).filter || [], measures)
|
||
}
|
||
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
|
||
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()
|
||
applyGroupRules()
|
||
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.
|
||
// The column axis as it stands, for rendering the collapse buttons. split_by is
|
||
// no longer truncated to collapse, so it *is* the hierarchy -- nothing has to be
|
||
// remembered alongside it.
|
||
function adoptSplit(list, depth) {
|
||
const cols = Array.isArray(list) ? list : []
|
||
splitFullRef.current = cols
|
||
setSplitFull(cols)
|
||
setSplitDepth(depth == null ? cols.length : Math.min(depth, cols.length))
|
||
}
|
||
|
||
// Collapse or expand the column axis to `n` split_by levels.
|
||
//
|
||
// Both axes are now the same shape: a depth in ViewConfig. This used to restore
|
||
// a *truncated* split_by instead, because split_by_depth was accepted and then
|
||
// dropped by ViewConfig::apply_update -- see ui/vendor's patch. Truncating had
|
||
// to be undone to expand again, which is why the full hierarchy needed
|
||
// remembering separately (splitFull, persisted as split_full), why our own
|
||
// collapse had to be told apart from the user rearranging the pivot
|
||
// (collapsingRef and the prefix test), and why the selection was cleared each
|
||
// time the axis changed shape.
|
||
//
|
||
// None of that is needed for a depth. split_by keeps every level, so the
|
||
// hierarchy is simply `cfg.split_by`; the depth rides along in saved layouts and
|
||
// survives a view rebuild; and the dimensions a slice was cut from are still
|
||
// there, so the selection can stay.
|
||
async function applySplitDepth(n) {
|
||
const viewer = viewerRef.current
|
||
if (!viewer) return
|
||
const { table: _t, ...cfg } = await viewer.save()
|
||
const levels = (cfg.split_by || []).length
|
||
if (!levels) return
|
||
const depth = Math.max(0, Math.min(n, levels))
|
||
pivotBusy(true)
|
||
try {
|
||
// A count of levels to show, exactly as group_by_depth is -- the engine
|
||
// treats both axes identically. See applyDepth for why 0 is floored to 1.
|
||
await viewer.restore({ ...cfg, split_by_depth: Math.max(1, depth) })
|
||
await viewer.flush()
|
||
setSplitDepth(depth)
|
||
} catch (err) {
|
||
console.error('[applySplitDepth]', err)
|
||
flash(err.message || String(err), 'error')
|
||
return
|
||
} finally { pivotBusy(false) }
|
||
try {
|
||
const cfg2 = await captureConfig()
|
||
if (cfg2) await persistLayout(versionId, cfg2)
|
||
} catch (err) {
|
||
console.error('[applySplitDepth persist]', err)
|
||
}
|
||
}
|
||
|
||
// Rule off the column groups, and mark each group's subtotal.
|
||
//
|
||
// Scanning across "prior · plan · forecast, each with twelve months and a
|
||
// total" is twelve columns of identical-looking numbers with nothing to say
|
||
// where one domain ends and the next begins. The annual figures and the
|
||
// monthly ones read as one run.
|
||
//
|
||
// Done through regular_table's style listener rather than CSS: which column
|
||
// starts a group, and which one is a group's subtotal, are facts about the
|
||
// data that only the cell metadata knows. The listener runs on every draw, so
|
||
// it survives scrolling and virtualisation -- a stylesheet cannot, since the
|
||
// DOM cells are recycled across columns as you scroll.
|
||
// Blank for this purpose means "no value at this level", which Perspective
|
||
// writes as a zero-width space rather than an empty string.
|
||
const notBlank = (v) =>
|
||
v != null && String(v).replace(/[\s\u200b-\u200d\ufeff]/g, '') !== ''
|
||
|
||
function applyGroupRules() {
|
||
const grid = gridRef.current
|
||
const table = grid?.regular_table
|
||
if (!table || table._pfGroupRules) return
|
||
table._pfGroupRules = true
|
||
|
||
// The grid lives in a shadow root, so a stylesheet on the page cannot reach
|
||
// these cells. Inject into whichever root actually contains the table.
|
||
// currentColor rather than a fixed grey, so the rule follows the theme
|
||
// instead of vanishing against Pro Dark.
|
||
const root = table.getRootNode() || document
|
||
if (!root.querySelector('#pf-group-rules')) {
|
||
const style = document.createElement('style')
|
||
style.id = 'pf-group-rules'
|
||
style.textContent = `
|
||
td.pf-group-start { border-left: 2px solid currentColor; opacity: 1; }
|
||
td.pf-subtotal { font-weight: 600; background: color-mix(in srgb, currentColor 7%, transparent); }
|
||
`
|
||
;(root.head || root).appendChild(style)
|
||
}
|
||
|
||
table.addStyleListener(() => {
|
||
const body = table.querySelectorAll('tbody td')
|
||
// The deepest column path is a leaf; anything shorter is an aggregate of
|
||
// the levels below it, which is what makes a subtotal a subtotal.
|
||
//
|
||
// The empty levels are not empty strings. Perspective pads a subtotal's
|
||
// path with zero-width spaces -- ['04 - Forecast', '\u200b', 'sales_usd']
|
||
// -- so every path is the same length and a naive `!== ''` test finds no
|
||
// subtotals at all.
|
||
let depth = 0
|
||
const metas = []
|
||
for (const td of body) {
|
||
let meta
|
||
try { meta = table.getMeta(td) } catch { meta = null }
|
||
metas.push([td, meta])
|
||
const path = meta?.column_header
|
||
if (Array.isArray(path)) depth = Math.max(depth, path.filter(notBlank).length)
|
||
}
|
||
|
||
let prevGroup = null
|
||
for (const [td, meta] of metas) {
|
||
td.classList.remove('pf-group-start', 'pf-subtotal')
|
||
const path = meta?.column_header
|
||
if (!Array.isArray(path) || !path.length) { prevGroup = null; continue }
|
||
const named = path.filter(notBlank)
|
||
const group = named[0]
|
||
if (group !== prevGroup) { td.classList.add('pf-group-start'); prevGroup = group }
|
||
if (named.length < depth) td.classList.add('pf-subtotal')
|
||
}
|
||
})
|
||
table.draw()
|
||
}
|
||
|
||
// Size every column to its contents.
|
||
//
|
||
// Values fit on their own: draw() calls regular_table.resetAutoSize(), which
|
||
// clears the width caches so the next draw measures cells and sets each
|
||
// column's min-width from the result. The complication is that the plugin's own
|
||
// draw saves the *live* widths first and restores them immediately after the
|
||
// reset, so going through it preserves whatever the columns already are. Hence
|
||
// driving regular_table directly.
|
||
//
|
||
// Two things that measurement will never fix:
|
||
//
|
||
// - Group headers are excluded on purpose. pro.css says so:
|
||
// /* Header groups should overflow and not contribute to auto-sizing. */
|
||
// thead tr:not(.rt-autosize) th { overflow: hidden; max-width: 0px; }
|
||
// Only the leaf header row participates. Letting a group label set width
|
||
// would widen every column beneath it, so this is left alone -- with the
|
||
// ordinal prefix in front, "01 - Prior…" still reads when truncated.
|
||
//
|
||
// - The row-label column measures its *header*, which for row headers is a
|
||
// blank corner cell, so it never reflects the labels underneath. That one
|
||
// is worth fixing, and is what fitRowLabels does below.
|
||
async function fitColumns() {
|
||
const viewer = viewerRef.current
|
||
if (!viewer) return
|
||
try {
|
||
const plugin = await viewer.getPlugin()
|
||
const grid = plugin?.regular_table
|
||
if (!grid?.resetAutoSize) {
|
||
flash('This plugin does not support fitting columns', 'error')
|
||
return
|
||
}
|
||
// _cached_column_sizes is consumed by the next save_column_size_overrides
|
||
// and would otherwise be restored over the measurement
|
||
plugin._cached_column_sizes = undefined
|
||
plugin._reset_column_size = false
|
||
grid.resetAutoSize()
|
||
await grid.draw({ invalid_columns: true })
|
||
|
||
// Row labels are deliberately left at their default width. Each group_by
|
||
// level is its own column, so fitting the first to its longest label pushes
|
||
// the second to start after it, and the default spacing reads better than
|
||
// the fitted one. fitRowLabels below still does it, one call away, if that
|
||
// judgement changes.
|
||
|
||
// Widths are not part of ViewConfig, so persist the layout to keep the
|
||
// saved copy in step with what is on screen.
|
||
const cfg = await captureConfig()
|
||
if (cfg) await persistLayout(versionId, cfg)
|
||
} catch (err) {
|
||
console.error('[fitColumns]', err)
|
||
flash(err.message || String(err), 'error')
|
||
}
|
||
}
|
||
|
||
// A floor under the row-label columns, not a fit.
|
||
//
|
||
// Restoring a layout resets the widths, and the row-header columns are then
|
||
// sized from their *header* — which for row headers is a blank corner cell —
|
||
// so they come back a few pixels wide and have to be dragged open by hand.
|
||
// Fitting them to content is the other extreme: each group_by level is its own
|
||
// column, so the first widens to its longest label and shoves the second
|
||
// rightwards.
|
||
//
|
||
// A minimum leaves the default spacing alone where it is already reasonable and
|
||
// only intervenes where a column came back unusably narrow. Anything already
|
||
// wider — set by dragging, or recorded in a layout — is untouched.
|
||
// Measured in the grid's own font rather than guessed in pixels, so it holds
|
||
// up if the theme or zoom changes.
|
||
//
|
||
// The same floor goes under every row-header column, which is what makes it
|
||
// safe for the indentation: a level's indent is the previous column's width,
|
||
// so a uniform floor steps evenly, where a per-column fit does not.
|
||
//
|
||
// It is deliberately small, and must stay small: this number IS the
|
||
// indentation. Raising it to give a clipped label more room to render widens
|
||
// every level's inset by the same amount, which is the opposite of what a
|
||
// compact tree wants. Making labels readable is a separate job -- the text
|
||
// has to overflow its column, not the column grow to hold the text.
|
||
const MIN_ROW_LABEL_CHARS = 3
|
||
|
||
// The wall: the last row-label column, which is where the deepest level's
|
||
// labels land once the tree is expanded to full depth.
|
||
//
|
||
// Its index is group_by.length, and the geometry is worth writing down because
|
||
// the comment on fitRowLabels had it wrong and cost a day. With four group_by
|
||
// levels the grid lays out rt-col-0..4 for row labels and starts the data at
|
||
// rt-col-5 (= tree_header_offset, group_by.length + 1). Column 0 is the indent
|
||
// gutter, and columns 1..N are the N levels — so the last level is column N,
|
||
// not N - 1. The hidden grand-total column is the *data* one at
|
||
// tree_header_offset, which the psp-split-total rule zeroes out; it is not
|
||
// this one.
|
||
//
|
||
// Only the columns for levels currently on show are rendered, so at
|
||
// group_by_depth 1 the deeper ones have no cells at all. Sizing this one by
|
||
// measuring the DOM therefore cannot work — it is sized blind, ahead of being
|
||
// needed, which is exactly why it is a constant rather than a fit.
|
||
//
|
||
// 218px was measured by hand: the width a rep/customer/month/part tree wants
|
||
// once it is open. This is the number to turn.
|
||
const ROW_LABEL_WALL_W = 218
|
||
|
||
async function ensureRowLabelWidth() {
|
||
const viewer = viewerRef.current
|
||
const grid = (await viewer?.getPlugin())?.regular_table
|
||
if (!grid?.saveColumnSizes) return
|
||
|
||
const cells = [...grid.querySelectorAll('tbody th')]
|
||
const idxs = new Set()
|
||
for (const cell of cells) {
|
||
const m = [...cell.classList].map(c => /^rt-col-(\d+)$/.exec(c)).find(Boolean)
|
||
if (m) idxs.add(Number(m[1]))
|
||
}
|
||
if (idxs.size === 0) return
|
||
|
||
const style = getComputedStyle(cells[0])
|
||
const ctx = (ensureRowLabelWidth._ctx ||= document.createElement('canvas').getContext('2d'))
|
||
ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
|
||
const min = Math.ceil(
|
||
ctx.measureText('0'.repeat(MIN_ROW_LABEL_CHARS)).width
|
||
+ parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
|
||
)
|
||
|
||
const sizes = { ...grid.saveColumnSizes() }
|
||
let changed = false
|
||
for (const idx of idxs) {
|
||
if ((sizes[idx] ?? 0) < min) { sizes[idx] = min; changed = true }
|
||
}
|
||
|
||
// A floor, like the rest: a wall someone has dragged wider is left alone.
|
||
const { group_by = [] } = (await viewer.save()) || {}
|
||
const wall = group_by.length
|
||
if (wall > 0 && (sizes[wall] ?? 0) < ROW_LABEL_WALL_W) {
|
||
sizes[wall] = ROW_LABEL_WALL_W
|
||
changed = true
|
||
}
|
||
|
||
if (!changed) return
|
||
grid.restoreColumnSizes(sizes)
|
||
await grid.draw({ invalid_columns: true })
|
||
}
|
||
|
||
// Pin the outermost row-label column wide enough for the labels it shows.
|
||
// Every level is measured, because which column is the outermost depends on
|
||
// the depth on show, but only that one is resized — see below.
|
||
//
|
||
// UNUSED, and parked for the second time. It was called from initViewer and
|
||
// applyLayout for a while, and the trouble is that a level's indent *is* the
|
||
// previous column's width: widening any row-header column to fit its content
|
||
// deepens the inset of everything below it. Which column got widened depended
|
||
// on how deep the tree happened to be drawn when the layout loaded, so one
|
||
// level per layout came back deeply inset while the rest stepped evenly —
|
||
// deterministic, but unreadable as a pattern from the outside. Fitting and
|
||
// even Excel-style increments cannot both come from column widths. Doing it
|
||
// properly means pinning every level but the last to a fixed increment and
|
||
// letting a long label overflow rightwards into the last column, which is a
|
||
// change to the grid CSS as well as to this.
|
||
//
|
||
// Measured with a canvas rather than the DOM: the cell is clipped, so reading
|
||
// its box back gives the width it was allotted, not the width of its text —
|
||
// the same circularity that stops headers sizing themselves.
|
||
//
|
||
// Set by column *index* through regular-table's own saveColumnSizes /
|
||
// restoreColumnSizes, not as a plugin_config column_size_override, which maps
|
||
// the single key "__ROW_PATH__" onto one index and so cannot address a
|
||
// multi-level row header at all.
|
||
//
|
||
// Note the index arithmetic, which an earlier version of this comment had
|
||
// wrong: tree_header_offset is group_by.length + 1 and is where the DATA
|
||
// starts. The row labels are columns 0..group_by.length, of which 0 is the
|
||
// indent gutter and 1..N are the N levels. tree_header_offset - 1 is the last
|
||
// *label* column, not a data column; the hidden grand-total column is the
|
||
// data one at tree_header_offset itself.
|
||
async function fitRowLabels(grid) {
|
||
const cells = [...grid.querySelectorAll('tbody th')]
|
||
if (cells.length === 0) return
|
||
|
||
const style = getComputedStyle(cells[0])
|
||
const ctx = (fitRowLabels._ctx ||= document.createElement('canvas').getContext('2d'))
|
||
ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
|
||
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
|
||
|
||
// One width per row-header column, keyed by the rt-col-N the cell carries.
|
||
const widest = new Map()
|
||
for (const cell of cells) {
|
||
const col = [...cell.classList]
|
||
.map(c => /^rt-col-(\d+)$/.exec(c))
|
||
.find(Boolean)
|
||
if (!col) continue
|
||
const idx = Number(col[1])
|
||
const text = (cell.textContent || '').trim()
|
||
if (!text) continue
|
||
// Tree indentation occupies real width, so it counts toward the fit.
|
||
const inner = cell.querySelector('span.rt-tree-container')
|
||
const indent = inner ? (parseFloat(getComputedStyle(inner).paddingLeft) || 0) : 0
|
||
const w = ctx.measureText(text).width + indent
|
||
widest.set(idx, Math.max(widest.get(idx) ?? 0, w))
|
||
}
|
||
if (widest.size === 0) return
|
||
|
||
// Only the outermost row-header column is fitted.
|
||
//
|
||
// Each group_by level is its own column, so widening any but the last moves
|
||
// the start of the one after it and the deeper bands step rightwards —
|
||
// which reads as the deeper level being indented past the end of the
|
||
// shallower one. The last column has only data columns after it, so it is
|
||
// the one that can grow without disturbing the levels above it. Fitting
|
||
// every level is what pushed the intermediate bands too far right.
|
||
//
|
||
// Capped, and never narrowed. The cap trades a rare truncation for a layout
|
||
// that stays legible — this column still pushes the data columns rightward,
|
||
// it just no longer pushes other labels. Taking the max with the current
|
||
// width means a width set by dragging survives.
|
||
const outer = Math.max(...widest.keys())
|
||
const MAX_ROW_LABEL_W = 260
|
||
const sizes = { ...grid.saveColumnSizes() }
|
||
// A few px over: canvas metrics and rendered text differ slightly with font
|
||
// fallback and letter-spacing.
|
||
const fitted = Math.min(Math.ceil(widest.get(outer) + padding + 8), MAX_ROW_LABEL_W)
|
||
sizes[outer] = Math.max(fitted, sizes[outer] ?? 0)
|
||
grid.restoreColumnSizes(sizes)
|
||
await grid.draw({ invalid_columns: true })
|
||
}
|
||
|
||
const GRID_CSS = `
|
||
/* Let headers and row labels size their own column.
|
||
*
|
||
* Auto-fit measures cells with getBoundingClientRect() and sets each column's
|
||
* min-width from the result — but the datagrid's own CSS wraps and clips
|
||
* header text, so a clipped th measures at the width it was *allotted*, not
|
||
* the width of its content. The column can therefore never grow to fit its
|
||
* own heading, which is why the values fit and the headings did not. Row
|
||
* labels are th elements in tbody and clip for the same reason.
|
||
*
|
||
* nowrap alone: no width or overflow is touched, so the measurement sees the
|
||
* full text and the existing sizing logic does the rest. */
|
||
thead th,
|
||
tbody th {
|
||
white-space: nowrap !important;
|
||
}
|
||
|
||
th.psp-split-total:not(.psp-split-subtotal),
|
||
td.psp-split-total:not(.psp-split-subtotal) {
|
||
visibility: hidden !important;
|
||
width: 0 !important;
|
||
min-width: 0 !important;
|
||
max-width: 0 !important;
|
||
padding: 0 !important;
|
||
border-left-width: 0 !important;
|
||
border-right-width: 0 !important;
|
||
overflow: hidden !important;
|
||
}
|
||
`
|
||
|
||
// Tell the app's status bar whether the pivot is rebuilding, so the wait is
|
||
// visible with the settings panel closed.
|
||
//
|
||
// Perspective shows this as the beaker in its own status bar, which is the
|
||
// honest signal -- it is driven by the session's update_count. It cannot be
|
||
// read from here, though: that bar lives in the viewer's shadow root, and the
|
||
// component carrying it is only *rendered* while settings are open once the
|
||
// table has loaded, so with the panel closed there is no element to watch. An
|
||
// earlier attempt did exactly that and only ever lit up when the panel was
|
||
// open, which is precisely when you do not need it.
|
||
//
|
||
// So it is raised by the callers that *start* a rebuild, and lowered once
|
||
// viewer.flush() says everything pending has rendered.
|
||
//
|
||
// Not from `perspective-config-update`, which was the obvious hook and is the
|
||
// wrong one: it is dispatched *after* the rebuild it describes. Measured on a
|
||
// depth change over a large grain, the event arrived seconds late and the
|
||
// flush that followed resolved in 6ms -- so the flag went up and down well
|
||
// after the wait it was meant to cover.
|
||
//
|
||
// Consequence: this covers what the toolbar drives -- depth on both axes, and
|
||
// applying a layout. Sorting or filtering from the viewer's own chrome still
|
||
// shows nothing, because the app never learns those have begun.
|
||
function pivotBusy(on) {
|
||
busyRef.current = Math.max(0, busyRef.current + (on ? 1 : -1))
|
||
window.dispatchEvent(
|
||
new CustomEvent('pf-pivot-busy', { detail: { busy: busyRef.current > 0 } }))
|
||
}
|
||
|
||
// Find the grid wherever it is. Guessing the nesting was wrong once already --
|
||
// the plugin element's own shadow root is not where the cells live -- so locate
|
||
// regular-table by walking through shadow roots, then inject into whatever root
|
||
// actually contains it via getRootNode(). No structural assumption survives a
|
||
// Perspective upgrade; this one asks the DOM instead.
|
||
function deepFindRegularTable(node, depth = 0) {
|
||
if (!node || depth > 12) return null
|
||
if (node.tagName === 'REGULAR-TABLE') return node
|
||
for (const child of node.children || []) {
|
||
const hit = deepFindRegularTable(child, depth + 1)
|
||
if (hit) return hit
|
||
}
|
||
if (node.shadowRoot) return deepFindRegularTable(node.shadowRoot, depth + 1)
|
||
return null
|
||
}
|
||
|
||
function hideSplitTotal() {
|
||
const viewer = viewerRef.current
|
||
if (!viewer) return
|
||
const grid = deepFindRegularTable(viewer)
|
||
// The grid is built asynchronously after load, so it may not exist yet.
|
||
// Retried from the config-update handler, and once on a short delay.
|
||
if (!grid) return false
|
||
const root = grid.getRootNode()
|
||
if (!root || root.querySelector?.('#pf-grid-css')) return true
|
||
const style = document.createElement('style')
|
||
style.id = 'pf-grid-css'
|
||
style.textContent = GRID_CSS
|
||
;(root.appendChild ? root : document.head).appendChild(style)
|
||
return true
|
||
}
|
||
|
||
// Row depth is a ViewConfig field, so it is set through restore() rather than
|
||
// by calling set_depth() on the view. That is the whole difference: the config
|
||
// is what the viewer rebuilds its view *from*, so the depth survives every
|
||
// rebuild by construction, and viewer.save() carries it into the persisted and
|
||
// named layouts for free.
|
||
//
|
||
// It used to be imperative -- getView() then view.set_depth() -- which put the
|
||
// depth on an object the viewer discards whenever it re-renders. Everything
|
||
// that grew around that (an observer shim over two global browser APIs, focus
|
||
// and visibility listeners, a retry loop for "No table set", and a flag
|
||
// tracking whether the viewer had "gone away") existed only to guess when a
|
||
// rebuild had happened and put the depth back. None of it is needed now, and
|
||
// all of it is gone.
|
||
async function applyDepth(d) {
|
||
const viewer = viewerRef.current
|
||
if (!viewer) return
|
||
// group_by_depth is a count of levels to show, and so is the stepper's own
|
||
// d -- DepthButtons labels d as levels[d - 1]. Same unit, so d goes across
|
||
// unchanged. It used to be sent as d + 1, which showed one level more than
|
||
// was picked at every position: server.cpp turns group_by_depth into
|
||
// set_depth(n - 1), and t_traversal::set_depth adds the 1 back, so n levels
|
||
// is what n asks for.
|
||
//
|
||
// d === 0 is the stepper's "Total" slot, which has never worked: 0 reaches
|
||
// the engine as set_depth(-1) on an unsigned depth, underflows, and clamps
|
||
// to fully expanded. Sending 1 keeps it showing the first level, which is
|
||
// what it has always shown -- still wrong, but wrong in the small way
|
||
// rather than the large one. A bare total needs group_rollup_mode.
|
||
//
|
||
// The whole config goes back rather than a partial update, which had no
|
||
// effect on its own. table is dropped: it is loaded by reference, and a stale
|
||
// name in a restored config fails the lookup.
|
||
const target = Math.max(1, d)
|
||
pivotBusy(true)
|
||
try {
|
||
const { table: _t, ...cfg } = await viewer.save()
|
||
await viewer.restore({ ...cfg, group_by_depth: target })
|
||
|
||
// Until the engine carries the patch in ui/vendor, restore() drops the depth:
|
||
// ViewConfig::apply_update applies ten fields and neither depth is among
|
||
// them, so the value arrives, deserializes, and is discarded before the
|
||
// engine sees it. Read it back to find out which build we are on, and fall
|
||
// back to the imperative call when it did not stick.
|
||
//
|
||
// The fallback is the old behaviour, warts and all: set_depth lives on the
|
||
// view, so it is lost whenever the viewer rebuilds one. What is deliberately
|
||
// not back is the machinery that used to chase that -- no observer shim, no
|
||
// focus listeners, no retries. Depth is simply re-applied when you next press
|
||
// a button, which is predictable, and it becomes durable for free once the
|
||
// engine is rebuilt.
|
||
const after = await viewer.save()
|
||
if (after.group_by_depth !== target) {
|
||
const view = await viewer.getView()
|
||
// set_depth counts the boundary below the levels shown, so it is one
|
||
// less than the config's count of them.
|
||
await view.set_depth(target - 1)
|
||
const plugin = await viewer.getPlugin()
|
||
await plugin.draw(view)
|
||
}
|
||
await viewer.flush()
|
||
setExpandDepth(d)
|
||
} finally { pivotBusy(false) }
|
||
}
|
||
|
||
async function captureConfig() {
|
||
const viewer = viewerRef.current
|
||
if (!viewer) return null
|
||
const cfg = await viewer.save()
|
||
// Both depths are already in cfg, straight from save(). Nothing of ours needs
|
||
// to travel beside it any more -- split_full existed only because collapsing
|
||
// truncated split_by and the discarded levels had to be remembered.
|
||
return cfg
|
||
}
|
||
|
||
async function persistLayout(vid, cfg) {
|
||
localStorage.setItem(LAYOUT_KEY(vid), JSON.stringify(cfg))
|
||
}
|
||
|
||
// One place every layout write goes through, so the 403 the server returns for
|
||
// somebody else's layout is reported the same way everywhere.
|
||
async function layoutFetch(url, opts) {
|
||
const res = await fetch(url, opts)
|
||
const data = await res.json().catch(() => ({}))
|
||
if (!res.ok) throw new Error(data.error || 'Failed')
|
||
return data
|
||
}
|
||
|
||
async function handleSaveAs(name, visibility) {
|
||
const cfg = await captureConfig()
|
||
if (!cfg) return
|
||
const { table: _t, ...rest } = cfg
|
||
try {
|
||
const saved = await layoutFetch(`/api/versions/${versionId}/layouts`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, config: rest, visibility })
|
||
})
|
||
setLayouts(ls => [...ls, saved])
|
||
markClean(cfg, saved.id)
|
||
await persistLayout(versionId, cfg)
|
||
flash(visibility === 'published' ? `Published “${saved.name}”` : 'Saved')
|
||
} catch (err) { flash(err.message, 'error') }
|
||
}
|
||
|
||
async function handleSaveOver(layout) {
|
||
const target = layout || layouts.find(l => l.id === activeLayoutId)
|
||
if (!target) return
|
||
const cfg = await captureConfig()
|
||
if (!cfg) return
|
||
const { table: _t, ...rest } = cfg
|
||
try {
|
||
const saved = await layoutFetch(`/api/layouts/${target.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ config: rest })
|
||
})
|
||
setLayouts(ls => ls.map(l => l.id === saved.id ? saved : l))
|
||
markClean(cfg, saved.id)
|
||
await persistLayout(versionId, cfg)
|
||
flash('Saved')
|
||
} catch (err) { flash(err.message, 'error') }
|
||
}
|
||
|
||
// Publish / unpublish / rename / make default are all the same PATCH; the
|
||
// server decides whether this account may make it.
|
||
async function patchLayout(layout, body, ok) {
|
||
try {
|
||
const saved = await layoutFetch(`/api/layouts/${layout.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
})
|
||
// is_default is exclusive per scope, so the row the server stood down is
|
||
// stale here too -- reload rather than patching one row into the list.
|
||
if (body.is_default) await loadLayouts(versionId).then(() => setActiveLayoutId(saved.id))
|
||
else setLayouts(ls => ls.map(l => l.id === saved.id ? saved : l))
|
||
flash(ok)
|
||
} catch (err) { flash(err.message, 'error') }
|
||
}
|
||
|
||
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' }
|
||
pivotBusy(true)
|
||
try {
|
||
await viewer.restore(cfg)
|
||
await viewer.flush()
|
||
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
|
||
cfg.split_by_depth != null ? cfg.split_by_depth : null)
|
||
setGroupFull(cfg.group_by || [])
|
||
if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth)
|
||
else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
||
|
||
await ensureRowLabelWidth()
|
||
|
||
// The persisted copy is taken after the restore, so it carries whatever
|
||
// cleanLayout dropped on the way in rather than the stale original -- and
|
||
// that same copy is what the dirty dot compares against, so applying a
|
||
// layout whose config named a dropped column does not read as changed.
|
||
const merged = await captureConfig()
|
||
markClean(merged || cfg, layout.id)
|
||
await persistLayout(versionId, merged || cfg)
|
||
} finally { pivotBusy(false) }
|
||
}
|
||
|
||
async function deleteLayout(layout) {
|
||
if (layout.visibility === 'published'
|
||
&& !window.confirm(`Delete “${layout.name}”? Everyone on this forecast loses it.`)) return
|
||
try {
|
||
await layoutFetch(`/api/layouts/${layout.id}`, { method: 'DELETE' })
|
||
setLayouts(ls => ls.filter(l => l.id !== layout.id))
|
||
if (activeLayoutId === layout.id) markClean(null, null)
|
||
} catch (err) { flash(err.message, 'error') }
|
||
}
|
||
|
||
function resetLayout() {
|
||
localStorage.removeItem(LAYOUT_KEY(versionId))
|
||
markClean(null, 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 }
|
||
|
||
// The pivot's filter scopes what the ledger counted, so it has to scope what
|
||
// gets written too -- otherwise the panel shows one number and the operation
|
||
// changes a larger set. It travels as [col, op, value] rather than folded
|
||
// into the slices, because a slice is {col: value} and can only mean
|
||
// equality: a view filtered to sseas_e <= 2027 has no slice form at all.
|
||
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 }
|
||
}
|
||
// Recode with nothing set would rewrite rows as themselves. Clone would not:
|
||
// copying a segment forward in time changes the dates and the period
|
||
// dimensions, which is the whole point of cloning from prior year, so it needs
|
||
// no dimension override at all. It only needs to be doing *something* --
|
||
// an override, a shift, or a factor.
|
||
if (op === 'recode' && !Object.keys(body.set || {}).length) {
|
||
flash('Enter at least one new dimension value', 'error')
|
||
return
|
||
}
|
||
// A clone that changes nothing is only pointless when the source and the
|
||
// destination are the same band. Copying a plan or a prior year out of
|
||
// reference and into adjustments changes what the rows *are*, even at factor 1
|
||
// with no shift -- that is the whole operation. Only warn when the selection
|
||
// is already adjustable.
|
||
if (op === 'clone') {
|
||
const shifts = body.date_offset && body.date_offset !== '0 days'
|
||
const scales = body.scale != null && body.scale !== 1
|
||
const changes = Object.keys(body.set || {}).length > 0
|
||
const fromRef = !!body.from_logid ||
|
||
(currentTotals?.excluded?.rows > 0 && !(currentTotals?.total?.value || currentTotals?.total?.units))
|
||
if (!shifts && !scales && !changes && !fromRef) {
|
||
flash('A clone with no override, no date shift and a factor of 1 would just duplicate the rows', 'error')
|
||
return
|
||
}
|
||
}
|
||
|
||
// A large scale writes a row per coordinate and can run for a while, and
|
||
// until now the button stayed live and said nothing -- so the only feedback
|
||
// that anything was happening was the absence of feedback, and clicking
|
||
// twice would apply it twice.
|
||
setOpBusy(op)
|
||
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(versionId)
|
||
// Every operation changes what the selection holds, so every operation
|
||
// has to re-count it. This used to fire for scale alone, and a recode or
|
||
// clone left the ledger showing the totals from before it ran -- the
|
||
// effect that normally recounts is keyed on `slices`, and none of these
|
||
// change the selection.
|
||
fetchCurrentTotals(slices)
|
||
if (op === 'scale') { setScaleInputs({}); setScaleNote('') }
|
||
if (op === 'recode') { setRecodeNote('') }
|
||
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
|
||
} catch (err) {
|
||
flash(err.message, 'error')
|
||
} finally {
|
||
setOpBusy(null)
|
||
}
|
||
}
|
||
|
||
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
|
||
})
|
||
}
|
||
|
||
// Segments worth copying a mix from: the loads. Adjustments are already in the
|
||
// forecast, so cloning one would double it rather than seed anything.
|
||
const cloneSources = Object.entries(logMeta)
|
||
.filter(([, m]) => ['baseline', 'reference'].includes(m.operation))
|
||
.map(([id, m]) => ({
|
||
id: Number(id),
|
||
operation: m.operation,
|
||
label: (m.label || m.tag || m.note || '').trim(),
|
||
}))
|
||
.sort((a, b) => a.id - b.id)
|
||
|
||
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)) {
|
||
// Not col_meta columns, but real ones here and resolvable server-side to the
|
||
// set of pf.log ids that carry the name. Dropping them is what let a click on
|
||
// one bucket's cell scale every bucket at that intersection.
|
||
if (COMPUTED_SLICE_COLS.has(k)) { out[k] = v; continue }
|
||
if (dimCols.has(k)) { out[k] = v; continue }
|
||
if (dateCols.has(k)) {
|
||
// a blank date node is a null date, not epoch zero
|
||
if (v === null || v === '') { out[k] = null; continue }
|
||
const ms = Number(v)
|
||
out[k] = isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : v
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// What the history modal lists. The fetch is unfiltered -- the counts on the
|
||
// Everyone/Mine control need the whole set -- so the narrowing happens here.
|
||
const visibleLog = logWho === 'mine' ? logEntries.filter(isMine) : logEntries
|
||
|
||
// The scope is read from the ref rather than passed in: the request preview in
|
||
// the panel calls this too, and when it was a parameter the preview defaulted
|
||
// it away -- showing a payload with no scope for a write that had one.
|
||
function buildPayload(op) {
|
||
const viewFilter = viewFilterRef.current || []
|
||
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,
|
||
...(viewFilter.length ? { scope: viewFilter } : {}),
|
||
...(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 }
|
||
// The offset stands on its own: shifting a selection through time is the
|
||
// common case, and it used to be sent only when a segment was named.
|
||
if (cloneFrom) body.from_logid = Number(cloneFrom)
|
||
const off = cloneOffset.trim()
|
||
if (off && off !== '0 days') body.date_offset = off
|
||
}
|
||
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)
|
||
setLogWho('mine')
|
||
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)
|
||
}
|
||
// Recount the selection, now the rows are actually gone. Undone after the
|
||
// remove, not beside it: fetchCurrentTotals reads the table through its
|
||
// own views, so running it first would count what was just deleted.
|
||
//
|
||
// The walk lines come from the log, so that is re-read too -- an undone
|
||
// entry has to leave the ledger as well as the table.
|
||
window.dispatchEvent(new CustomEvent('pf-data-changed'))
|
||
refreshLogMeta(versionId)
|
||
if (slicesRef.current.length) fetchCurrentTotals(slicesRef.current)
|
||
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(versionId)
|
||
}
|
||
} 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,
|
||
viewScope,
|
||
opBusy,
|
||
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,
|
||
cloneFrom, setCloneFrom, cloneOffset, setCloneOffset, cloneSources,
|
||
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. */}
|
||
{/* Value only. A datalist option's text content renders as a second
|
||
column beside the value, so the use count widened every row of the
|
||
pick list to carry a number nobody picks on. */}
|
||
<datalist id="pf-tag-options">
|
||
{knownTags.map(t => <option key={t.tag} value={t.tag} />)}
|
||
</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">
|
||
|
||
{/* ── View: everything here changes how the loaded rows are displayed.
|
||
Nothing in this zone talks to the server or alters the forecast,
|
||
which is the whole reason it is fenced off from the zone on the
|
||
right — the two used to sit in one undifferentiated row of
|
||
buttons separated by identical rules, so "Fit" and "Bridge"
|
||
read as peers when one is a column width and the other opens
|
||
the version's history. ── */}
|
||
<div className="flex items-center gap-2.5 flex-wrap bg-gray-50 border border-gray-100
|
||
rounded px-2 py-1">
|
||
|
||
<LayoutMenu
|
||
layouts={layouts}
|
||
activeLayoutId={activeLayoutId}
|
||
dirty={layoutDirty}
|
||
onApply={applyLayout}
|
||
onSave={handleSaveOver}
|
||
onSaveAs={handleSaveAs}
|
||
onSetVisibility={(l, v) => patchLayout(l, { visibility: v },
|
||
v === 'published' ? `Published “${l.name}”` : `“${l.name}” is private again`)}
|
||
onSetDefault={l => patchLayout(l, { is_default: true },
|
||
`“${l.name}” opens this forecast`)}
|
||
onRename={(l, name) => patchLayout(l, { name }, 'Renamed')}
|
||
onDelete={deleteLayout}
|
||
onReset={resetLayout} />
|
||
|
||
{/* Rows and Columns are the same control on the two axes — a depth in
|
||
ViewConfig — so they are built from one renderer and read alike.
|
||
The buttons name the level they reveal: "2" meant nothing without
|
||
already knowing what group_by held, and the old fixed 0–3 range
|
||
offered levels that did not exist and hid ones that did. */}
|
||
{groupFull.length > 0 && (
|
||
<>
|
||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||
<DepthButtons label="Rows" levels={groupFull} depth={expandDepth}
|
||
onPick={applyDepth} totalLabel="Total" />
|
||
</>
|
||
)}
|
||
|
||
{splitFull.length > 0 && (
|
||
<>
|
||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||
<DepthButtons label="Columns" levels={splitFull} depth={splitDepth}
|
||
onPick={applySplitDepth} totalLabel="Total" />
|
||
</>
|
||
)}
|
||
|
||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||
|
||
<button onClick={fitColumns}
|
||
title="Size every column to its contents, releasing any widths pinned by dragging or carried in a saved layout"
|
||
className="border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400
|
||
transition-colors whitespace-nowrap">
|
||
Fit widths
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Forecast: reaches the server, or opens something over the pivot.
|
||
Refresh discards the current view's state and re-runs the load;
|
||
Operations is where the version is actually written to. ── */}
|
||
<div className="flex items-center gap-1.5 flex-wrap ml-auto">
|
||
<button onClick={() => initViewer(versionId, sourceId, layouts)} disabled={loading || !versionId}
|
||
title="Re-run the load from the database"
|
||
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">
|
||
<div className="flex items-baseline gap-4 min-w-0">
|
||
<span className="font-medium text-gray-700 text-sm">Change History</span>
|
||
{/* Whose work this is. A version under active use carries
|
||
everyone's adjustments, and finding your own in it meant
|
||
reading the By column down a list that is mostly other
|
||
people. Own entries are marked in place as well, so the
|
||
filter is a way to narrow rather than the only way to tell. */}
|
||
{!!user && logEntries.length > 0 && (
|
||
<div className="inline-flex rounded border border-gray-200 overflow-hidden text-xs">
|
||
{[['all', `Everyone`, logEntries.length],
|
||
['mine', `Mine`, logEntries.filter(isMine).length]].map(([val, label, n]) => (
|
||
<button key={val} onClick={() => setLogWho(val)}
|
||
className={`px-2.5 py-0.5 whitespace-nowrap transition-colors ${
|
||
logWho === val ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}>
|
||
{label} <span className={logWho === val ? 'text-blue-100' : 'text-gray-400'}>{n}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<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>
|
||
) : visibleLog.length === 0 ? (
|
||
<div className="p-8 text-center text-sm text-gray-400">
|
||
{logEntries.length === 0
|
||
? 'No log entries yet.'
|
||
: `None of the ${logEntries.length} entries here are yours.`}
|
||
</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-left px-3 py-2 font-medium w-24">By</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>
|
||
{visibleLog.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 ${
|
||
isMine(entry) ? 'border-l-2 border-blue-500' : 'border-l-2 border-transparent'}`}>
|
||
{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" canEdit={canUndo(entry)} 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" canEdit={canUndo(entry)} placeholder="add note"
|
||
editing={editingCell} setEditing={setEditingCell} onSave={saveLogField} />
|
||
<td className={`px-3 py-2 truncate ${isMine(entry) ? 'text-blue-700 font-medium' : 'text-gray-500'}`}
|
||
title={entry.pf_user || ''}>
|
||
{entry.pf_user || '—'}
|
||
</td>
|
||
<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 || !canUndo(entry)}
|
||
title={canUndo(entry)
|
||
? 'Remove this entry and its rows'
|
||
: `Only ${entry.pf_user || 'the author'} or an administrator can undo this`}
|
||
className={`text-xs rounded px-2 py-0.5 whitespace-nowrap border ${
|
||
canUndo(entry)
|
||
? 'border-red-200 text-red-400 hover:text-red-600 hover:border-red-400'
|
||
: 'border-gray-100 text-gray-300 cursor-not-allowed'
|
||
} disabled:opacity-100`}>
|
||
{undoingId === entry.id ? '…' : 'Undo'}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
)).flatMap((row, i) => {
|
||
const entry = visibleLog[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={9} 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} />
|
||
{entry.env && <LogJson label="env" value={entry.env} />}
|
||
</div>
|
||
{/* The statement is fetched on demand: it is
|
||
kilobytes, and the list is opened to scan rather
|
||
than to read SQL. */}
|
||
{entry.has_sql && <LogSql logId={entry.id} />}
|
||
</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}
|
||
// Which bucket *is* the forecast. Not a constant: it is the version's
|
||
// adjustment_bucket, which is how an adjustment with no bucket of its
|
||
// own gets labelled, so the two always agree by construction.
|
||
forecastBucket={versions.find(v => String(v.id) === String(versionId))?.adjustment_bucket
|
||
|| '04 - Forecast'}
|
||
/>
|
||
|
||
{/* 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">
|
||
{loadRows ? `Loading ${loadRows.toLocaleString()} rows…` : '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, canEdit = true }) {
|
||
const active = canEdit && editing?.id === entry.id && editing?.field === field
|
||
const value = entry[field] || ''
|
||
|
||
// Someone else's entry: shown, not editable. label and bucket name the
|
||
// pivot's columns for everyone, so these are not the private annotations
|
||
// they look like.
|
||
if (!canEdit) {
|
||
return (
|
||
<td className="px-3 py-2 text-gray-400 overflow-hidden">
|
||
<span className="block truncate px-1 -mx-1"
|
||
title={value ? `${value} — ${entry.pf_user || 'another account'}'s entry` : ''}>
|
||
{value ? (render ? render(value) : value) : <span className="text-gray-300">—</span>}
|
||
</span>
|
||
</td>
|
||
)
|
||
}
|
||
|
||
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>
|
||
)
|
||
}
|
||
|
||
// The statement as executed, with territory and scope already resolved into it.
|
||
// Read this when the rows look wrong: params says what was asked for, and this
|
||
// says what actually ran -- the gap between them being where the bug lives.
|
||
function LogSql({ logId }) {
|
||
const [sql, setSql] = useState(null)
|
||
const [open, setOpen] = useState(false)
|
||
const [err, setErr] = useState(null)
|
||
|
||
async function toggle() {
|
||
const next = !open
|
||
setOpen(next)
|
||
if (next && sql == null && !err) {
|
||
try {
|
||
const r = await fetch(`/api/log/${logId}/debug`)
|
||
const d = await r.json()
|
||
if (!r.ok) throw new Error(d.error || 'Could not load the statement')
|
||
setSql(d.sql_text || '(not recorded)')
|
||
} catch (e) { setErr(e.message) }
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="mt-3">
|
||
<button onClick={toggle} className="text-xs text-blue-600 hover:text-blue-700">
|
||
{open ? '▾' : '▸'} executed SQL
|
||
</button>
|
||
{open && (
|
||
<pre className="mt-1 font-mono text-[11px] text-gray-600 bg-white border border-gray-200
|
||
rounded p-2 overflow-auto max-h-72 leading-relaxed whitespace-pre">
|
||
{err || sql || 'Loading…'}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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
|
||
// `measures` is the view's `columns` list. Clicking a cell whose column axis is
|
||
// collapsed makes Perspective emit the measure name as the value of the first
|
||
// hidden split_by dimension -- a bucket subtotal arrives as
|
||
// ["smon_e", "==", "sales_usd"] -- because the engine maps split_by positionally
|
||
// over a column name that no longer has that many segments. Left in, the slice
|
||
// asks for a month equal to a measure, matches nothing, and the operation
|
||
// silently has no rows to act on.
|
||
// A blank dimension value is a value, not an absence. `null` and `''` are both
|
||
// real and both present in this data -- partgroup has 51,964 of one and 111,696
|
||
// of the other -- so they stay distinct all the way to the WHERE clause, where
|
||
// null becomes IS NULL and '' becomes = ''. Dropping either is what widened a
|
||
// selection: with partgroup gone from the slice, clicking the blank node under
|
||
// ALTMAN PLANTS selected all $27.6M of the customer rather than the $6.2M under
|
||
// the node clicked.
|
||
//
|
||
// Only `undefined` means absent, which is how a subtotal row (a shorter row
|
||
// path) and a collapsed column level still read as "this level is not part of
|
||
// the slice".
|
||
function sliceFromFilters(filters, measures = []) {
|
||
const measureSet = new Set(measures)
|
||
const s = {}
|
||
for (const f of filters) {
|
||
if (!Array.isArray(f)) continue
|
||
const [col, op, val] = f
|
||
if (op !== '==' || val === undefined) continue
|
||
if (val !== null && measureSet.has(String(val))) continue
|
||
s[col] = val === null ? null : String(val)
|
||
}
|
||
return s
|
||
}
|
||
|
||
// The dimension filters for one cell, re-derived from the row path and the column
|
||
// name rather than taken from the click event.
|
||
//
|
||
// `getCellConfig` in the datagrid builds its filter list with
|
||
// `pivot_value ? [pivot, '==', pivot_value] : undefined`, so **the engine itself
|
||
// drops a blank group key** before the event is dispatched -- and a falsy one, so
|
||
// a dimension whose value is 0 goes the same way. The row path is the raw truth
|
||
// and carries the blank, so the slice is built from that instead.
|
||
//
|
||
// A blank on the *column* axis cannot be recovered at all: a column name is its
|
||
// values joined with '|', and a null joins as the empty string, so the two are
|
||
// the same text by the time it reaches us. Those cells are refused rather than
|
||
// widened -- `colBlank` says so -- since a slice missing a split_by level means
|
||
// every value of it.
|
||
function cellFilters(cfg, row, colKey) {
|
||
const groupBy = cfg.group_by || []
|
||
const splitBy = cfg.split_by || []
|
||
const path = row?.__ROW_PATH__ || []
|
||
const rowFilters = groupBy
|
||
.map((col, ix) => (ix < path.length && path[ix] !== undefined ? [col, '==', path[ix]] : null))
|
||
.filter(Boolean)
|
||
let colFilters = []
|
||
let colBlank = false
|
||
if (colKey != null) {
|
||
// A column name is dimension values joined by | with the measure last, and
|
||
// only as many values as the axis currently shows -- collapse the column
|
||
// hierarchy and the deeper levels are simply absent. Mapping split_by
|
||
// positionally over every segment read the measure as a value for the first
|
||
// collapsed dimension: a bucket subtotal came back as smon_e = 'sales_usd',
|
||
// which matches no row, so the operation silently had nothing to act on.
|
||
// Drop the measure, then map over what is left.
|
||
const segs = String(colKey).split('|')
|
||
segs.pop()
|
||
colFilters = splitBy
|
||
.map((col, ix) => {
|
||
const v = segs[ix]
|
||
// absent: the column hierarchy is collapsed above this level
|
||
if (v === undefined) return null
|
||
// the measure name read positionally as a dimension value
|
||
if (META_COL_RE.test(v)) return null
|
||
if (v === '') { colBlank = true; return null }
|
||
return [col, '==', v]
|
||
})
|
||
.filter(Boolean)
|
||
}
|
||
return { rowFilters, colFilters, colBlank }
|
||
}
|
||
|
||
// the user keys of a to_json row, in the order the grid lays its columns out
|
||
function userKeysOf(row) {
|
||
return Object.keys(row || {}).filter(k => !META_COL_RE.test(k))
|
||
}
|
||
|
||
// 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' }
|
||
|