From 368127e098f9f61ee11249f375c065cede6ec198 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Tue, 14 Apr 2026 23:11:56 -0400 Subject: [PATCH] Replace AG Grid pivot with Perspective viewer in Forecast view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Swap AG Grid CSS/JS for Perspective CDN imports (4.4.0) - Replace #pivot-grid div with web component - Add loadPerspective() singleton, initPerspectiveViewer(data) - Build default layout from col_meta (dims → group_by, date → split_by) - Extract slice from perspective-click event.detail.config.filter (only == filters on role=dimension columns feed the operation panel) - table.update() appends operation result rows without full reload - Save/reset layout buttons per version in localStorage - Remove expand/collapse buttons (Perspective handles natively) Co-Authored-By: Claude Sonnet 4.6 --- public/app.js | 209 ++++++++++++++++++++++------------------------ public/index.html | 10 +-- public/styles.css | 2 +- 3 files changed, 104 insertions(+), 117 deletions(-) diff --git a/public/app.js b/public/app.js index 3852c7a..3d1df78 100644 --- a/public/app.js +++ b/public/app.js @@ -16,9 +16,10 @@ const state = { sources: null, colMeta: null, versions: null, - pivot: null, log: null - } + }, + pspWorker: null, // Perspective worker + pspTable: null, // Perspective table }; /* ============================================================ @@ -713,7 +714,7 @@ async function loadForecastData() { showStatus('Loading forecast data...', 'info'); const rawData = await api('GET', `/versions/${state.version.id}/data`); const data = parseNumericRows(rawData); - initPivotGrid(data); + await initPerspectiveViewer(data); showStatus(`Loaded ${data.length.toLocaleString()} rows`, 'success'); } catch (err) { showStatus(err.message, 'error'); @@ -721,125 +722,100 @@ async function loadForecastData() { } /* ============================================================ - FORECAST VIEW — pivot grid + FORECAST VIEW — Perspective pivot ============================================================ */ -function buildPivotColDefs() { - const defs = []; - - state.colMeta.forEach((c) => { - if (c.role === 'ignore') return; - const needsGetter = /\W/.test(c.cname); - const def = { - field: c.cname, - headerName: c.label || c.cname, - resizable: true, - sortable: true, - ...(needsGetter ? { valueGetter: p => p.data ? p.data[c.cname] : undefined } : {}) - }; - if (c.role === 'dimension' || c.role === 'date') { - def.enableRowGroup = true; - def.enablePivot = true; - } - if (c.role === 'value' || c.role === 'units') { - def.enableValue = true; - def.aggFunc = 'sum'; - def.type = 'numericColumn'; - def.valueFormatter = p => p.value != null - ? Number(p.value).toLocaleString(undefined, { maximumFractionDigits: 2 }) - : ''; - } - defs.push(def); - - if (c.role === 'date') { - defs.push({ - colId: c.cname + '__month', - headerName: (c.label || c.cname) + ' (Month)', - enableRowGroup: true, - enablePivot: true, - valueGetter: p => { - const v = p.data ? p.data[c.cname] : undefined; - if (!v) return undefined; - const d = new Date(v); - return isNaN(d) ? undefined : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; - } - }); - } - }); - - // always include pf_iter for grouping context - defs.push({ field: 'pf_iter', headerName: 'Iter', enableRowGroup: true, enablePivot: true, width: 90 }); - defs.push({ field: 'pf_user', headerName: 'User', width: 90, hide: true }); - defs.push({ field: 'pf_created_at', headerName: 'Created', width: 130, hide: true, - valueFormatter: p => p.value ? new Date(p.value).toLocaleDateString() : '' }); - - return defs; +let _perspectivePromise = null; +function loadPerspective() { + if (_perspectivePromise) return _perspectivePromise; + _perspectivePromise = (async () => { + const [{ default: perspective }] = await Promise.all([ + import('https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.4.0/dist/cdn/perspective.js'), + import('https://cdn.jsdelivr.net/npm/@perspective-dev/viewer@4.4.0/dist/cdn/perspective-viewer.js'), + import('https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-datagrid@4.4.0/dist/cdn/perspective-viewer-datagrid.js'), + import('https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-d3fc@4.4.0/dist/cdn/perspective-viewer-d3fc.js'), + ]); + return perspective; + })(); + return _perspectivePromise; } -function initPivotGrid(data) { - const el = document.getElementById('pivot-grid'); +function pspLayoutKey() { + return `psp_layout_pf_${state.version?.id}`; +} - if (state.grids.pivot) { - state.grids.pivot.setGridOption('rowData', data); - return; +function buildDefaultLayout() { + const dims = state.colMeta.filter(c => c.role === 'dimension').map(c => c.cname); + const values = state.colMeta.filter(c => c.role === 'value' || c.role === 'units').map(c => c.cname); + const date = state.colMeta.find(c => c.role === 'date')?.cname; + return { + group_by: dims.slice(0, 2), // first two dimensions as row groups + split_by: date ? [date] : [], // date as column pivot + columns: values, + settings: true, + plugin_config: { edit_mode: 'SELECT_ROW' } + }; +} + +async function initPerspectiveViewer(data) { + const viewer = document.getElementById('pivot-viewer'); + + // terminate old worker if reloading + if (state.pspWorker) { + try { state.pspWorker.terminate(); } catch (_) {} + state.pspWorker = null; + state.pspTable = null; } - state.grids.pivot = agGrid.createGrid(el, { - columnDefs: buildPivotColDefs(), - rowData: data, - rowSelection: 'single', - groupDisplayType: 'singleColumn', - rowGroupPanelShow: 'always', - groupDefaultExpanded: 1, - suppressAggFuncInHeader: true, - animateRows: true, - sideBar: { - toolPanels: [{ - id: 'columns', - labelDefault: 'Columns', - labelKey: 'columns', - iconKey: 'columns', - toolPanel: 'agColumnsToolPanel', - toolPanelParams: {} - }], - defaultToolPanel: 'columns' - }, - defaultColDef: { resizable: true, sortable: true }, - autoGroupColumnDef: { - headerName: 'Group', - minWidth: 200, - cellRendererParams: { suppressCount: false } - }, - headerHeight: 32, - rowHeight: 28, - onRowClicked: onPivotRowClicked + const perspective = await loadPerspective(); + const worker = await perspective.worker(); + state.pspWorker = worker; + + const table = await worker.table(data, { name: `pf_${state.version.id}` }); + state.pspTable = table; + + // remove old listener to avoid stacking on refresh + const fresh = viewer.cloneNode(false); + viewer.parentNode.replaceChild(fresh, viewer); + const v = document.getElementById('pivot-viewer'); + + v.addEventListener('perspective-click', async (e) => { + const detail = e.detail || {}; + const eventFilters = (detail.config || {}).filter || []; + const config = await v.save(); + extractSliceFromPerspective(eventFilters, config); }); + + await v.load(worker); + + const saved = localStorage.getItem(pspLayoutKey()); + if (saved) { + await v.restore(JSON.parse(saved)); + } else { + await v.restore(buildDefaultLayout()); + } + + // update reset button visibility + document.getElementById('btn-reset-layout') + .classList.toggle('hidden', !localStorage.getItem(pspLayoutKey())); } /* ============================================================ FORECAST VIEW — slice selection ============================================================ */ -function onPivotRowClicked(event) { - const node = event.node; - state.slice = extractSliceFromNode(node); +function extractSliceFromPerspective(eventFilters, config) { + const dimFields = new Set(state.colMeta.filter(c => c.role === 'dimension').map(c => c.cname)); + const slice = {}; + for (const [field, op, value] of eventFilters) { + if (op === '==' && dimFields.has(field) && value != null && value !== '') { + slice[field] = String(value); + } + } + state.slice = slice; renderSliceDisplay(); - - // populate recode and clone fields whenever slice changes renderDimFields('recode'); renderDimFields('clone'); } -function extractSliceFromNode(node) { - const slice = {}; - let current = node; - while (current) { - if (current.field && current.key != null && current.key !== '') { - slice[current.field] = current.key; - } - current = current.parent; - } - return slice; -} - function renderSliceDisplay() { const display = document.getElementById('slice-display'); const hasSlice = Object.keys(state.slice).length > 0; @@ -938,7 +914,7 @@ async function submitScale() { document.getElementById('scale-value-incr').value = ''; document.getElementById('scale-units-incr').value = ''; document.getElementById('scale-note').value = ''; - state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) }); + if (state.pspTable) await state.pspTable.update(parseNumericRows(result.rows)); } catch (err) { showStatus(err.message, 'error'); } @@ -964,7 +940,7 @@ async function submitRecode() { }); showStatus(`Recode applied — ${result.rows_affected} rows inserted`, 'success'); document.querySelectorAll('#recode-fields input[data-col], #recode-fields select[data-col]').forEach(i => { i.value = ''; }); - state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) }); + if (state.pspTable) await state.pspTable.update(parseNumericRows(result.rows)); } catch (err) { showStatus(err.message, 'error'); } @@ -993,7 +969,7 @@ async function submitClone() { showStatus(`Clone applied — ${result.rows_affected} rows inserted`, 'success'); document.querySelectorAll('#clone-fields input[data-col], #clone-fields select[data-col]').forEach(i => { i.value = ''; }); document.getElementById('clone-scale').value = '1'; - state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) }); + if (state.pspTable) await state.pspTable.update(parseNumericRows(result.rows)); } catch (err) { showStatus(err.message, 'error'); } @@ -1134,8 +1110,21 @@ document.addEventListener('DOMContentLoaded', () => { // forecast view buttons document.getElementById('btn-forecast-refresh').addEventListener('click', loadForecastData); - document.getElementById('btn-expand-all').addEventListener('click', () => state.grids.pivot?.expandAll()); - document.getElementById('btn-collapse-all').addEventListener('click', () => state.grids.pivot?.collapseAll()); + document.getElementById('btn-save-layout').addEventListener('click', async () => { + const v = document.getElementById('pivot-viewer'); + if (!v) return; + const layout = await v.save(); + localStorage.setItem(pspLayoutKey(), JSON.stringify(layout)); + document.getElementById('btn-reset-layout').classList.remove('hidden'); + showStatus('Layout saved', 'success'); + }); + document.getElementById('btn-reset-layout').addEventListener('click', async () => { + const v = document.getElementById('pivot-viewer'); + if (!v) return; + localStorage.removeItem(pspLayoutKey()); + document.getElementById('btn-reset-layout').classList.add('hidden'); + await v.restore(buildDefaultLayout()); + }); document.getElementById('btn-clear-slice').addEventListener('click', clearSlice); document.querySelectorAll('.op-tab').forEach(tab => { diff --git a/public/index.html b/public/index.html index 763833e..fbb7cb5 100644 --- a/public/index.html +++ b/public/index.html @@ -4,8 +4,7 @@ Pivot Forecast - - + @@ -163,12 +162,12 @@
No version selected - - + +
-
+
@@ -283,7 +282,6 @@
- diff --git a/public/styles.css b/public/styles.css index 0e7b577..a84754d 100644 --- a/public/styles.css +++ b/public/styles.css @@ -199,7 +199,7 @@ body { box-shadow: 0 1px 3px rgba(0,0,0,.08); min-width: 0; } -#pivot-grid { width: 100%; height: 100%; } +#pivot-viewer { width: 100%; height: 100%; } #operation-panel { width: 270px;