Replace AG Grid pivot with Perspective viewer in Forecast view

- Swap AG Grid CSS/JS for Perspective CDN imports (4.4.0)
- Replace #pivot-grid div with <perspective-viewer> 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 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-14 23:11:56 -04:00
parent 1df37a5ff1
commit 368127e098
3 changed files with 104 additions and 117 deletions

View File

@ -16,9 +16,10 @@ const state = {
sources: null, sources: null,
colMeta: null, colMeta: null,
versions: null, versions: null,
pivot: null,
log: null log: null
} },
pspWorker: null, // Perspective worker
pspTable: null, // Perspective table
}; };
/* ============================================================ /* ============================================================
@ -713,7 +714,7 @@ async function loadForecastData() {
showStatus('Loading forecast data...', 'info'); showStatus('Loading forecast data...', 'info');
const rawData = await api('GET', `/versions/${state.version.id}/data`); const rawData = await api('GET', `/versions/${state.version.id}/data`);
const data = parseNumericRows(rawData); const data = parseNumericRows(rawData);
initPivotGrid(data); await initPerspectiveViewer(data);
showStatus(`Loaded ${data.length.toLocaleString()} rows`, 'success'); showStatus(`Loaded ${data.length.toLocaleString()} rows`, 'success');
} catch (err) { } catch (err) {
showStatus(err.message, 'error'); showStatus(err.message, 'error');
@ -721,125 +722,100 @@ async function loadForecastData() {
} }
/* ============================================================ /* ============================================================
FORECAST VIEW pivot grid FORECAST VIEW Perspective pivot
============================================================ */ ============================================================ */
function buildPivotColDefs() { let _perspectivePromise = null;
const defs = []; function loadPerspective() {
if (_perspectivePromise) return _perspectivePromise;
state.colMeta.forEach((c) => { _perspectivePromise = (async () => {
if (c.role === 'ignore') return; const [{ default: perspective }] = await Promise.all([
const needsGetter = /\W/.test(c.cname); import('https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.4.0/dist/cdn/perspective.js'),
const def = { import('https://cdn.jsdelivr.net/npm/@perspective-dev/viewer@4.4.0/dist/cdn/perspective-viewer.js'),
field: c.cname, import('https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-datagrid@4.4.0/dist/cdn/perspective-viewer-datagrid.js'),
headerName: c.label || c.cname, import('https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-d3fc@4.4.0/dist/cdn/perspective-viewer-d3fc.js'),
resizable: true, ]);
sortable: true, return perspective;
...(needsGetter ? { valueGetter: p => p.data ? p.data[c.cname] : undefined } : {}) })();
}; return _perspectivePromise;
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;
} }
function initPivotGrid(data) { function pspLayoutKey() {
const el = document.getElementById('pivot-grid'); return `psp_layout_pf_${state.version?.id}`;
}
if (state.grids.pivot) { function buildDefaultLayout() {
state.grids.pivot.setGridOption('rowData', data); const dims = state.colMeta.filter(c => c.role === 'dimension').map(c => c.cname);
return; 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, { const perspective = await loadPerspective();
columnDefs: buildPivotColDefs(), const worker = await perspective.worker();
rowData: data, state.pspWorker = worker;
rowSelection: 'single',
groupDisplayType: 'singleColumn', const table = await worker.table(data, { name: `pf_${state.version.id}` });
rowGroupPanelShow: 'always', state.pspTable = table;
groupDefaultExpanded: 1,
suppressAggFuncInHeader: true, // remove old listener to avoid stacking on refresh
animateRows: true, const fresh = viewer.cloneNode(false);
sideBar: { viewer.parentNode.replaceChild(fresh, viewer);
toolPanels: [{ const v = document.getElementById('pivot-viewer');
id: 'columns',
labelDefault: 'Columns', v.addEventListener('perspective-click', async (e) => {
labelKey: 'columns', const detail = e.detail || {};
iconKey: 'columns', const eventFilters = (detail.config || {}).filter || [];
toolPanel: 'agColumnsToolPanel', const config = await v.save();
toolPanelParams: {} extractSliceFromPerspective(eventFilters, config);
}],
defaultToolPanel: 'columns'
},
defaultColDef: { resizable: true, sortable: true },
autoGroupColumnDef: {
headerName: 'Group',
minWidth: 200,
cellRendererParams: { suppressCount: false }
},
headerHeight: 32,
rowHeight: 28,
onRowClicked: onPivotRowClicked
}); });
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 FORECAST VIEW slice selection
============================================================ */ ============================================================ */
function onPivotRowClicked(event) { function extractSliceFromPerspective(eventFilters, config) {
const node = event.node; const dimFields = new Set(state.colMeta.filter(c => c.role === 'dimension').map(c => c.cname));
state.slice = extractSliceFromNode(node); 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(); renderSliceDisplay();
// populate recode and clone fields whenever slice changes
renderDimFields('recode'); renderDimFields('recode');
renderDimFields('clone'); 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() { function renderSliceDisplay() {
const display = document.getElementById('slice-display'); const display = document.getElementById('slice-display');
const hasSlice = Object.keys(state.slice).length > 0; const hasSlice = Object.keys(state.slice).length > 0;
@ -938,7 +914,7 @@ async function submitScale() {
document.getElementById('scale-value-incr').value = ''; document.getElementById('scale-value-incr').value = '';
document.getElementById('scale-units-incr').value = ''; document.getElementById('scale-units-incr').value = '';
document.getElementById('scale-note').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) { } catch (err) {
showStatus(err.message, 'error'); showStatus(err.message, 'error');
} }
@ -964,7 +940,7 @@ async function submitRecode() {
}); });
showStatus(`Recode applied — ${result.rows_affected} rows inserted`, 'success'); 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 = ''; }); 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) { } catch (err) {
showStatus(err.message, 'error'); showStatus(err.message, 'error');
} }
@ -993,7 +969,7 @@ async function submitClone() {
showStatus(`Clone applied — ${result.rows_affected} rows inserted`, 'success'); 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.querySelectorAll('#clone-fields input[data-col], #clone-fields select[data-col]').forEach(i => { i.value = ''; });
document.getElementById('clone-scale').value = '1'; 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) { } catch (err) {
showStatus(err.message, 'error'); showStatus(err.message, 'error');
} }
@ -1134,8 +1110,21 @@ document.addEventListener('DOMContentLoaded', () => {
// forecast view buttons // forecast view buttons
document.getElementById('btn-forecast-refresh').addEventListener('click', loadForecastData); document.getElementById('btn-forecast-refresh').addEventListener('click', loadForecastData);
document.getElementById('btn-expand-all').addEventListener('click', () => state.grids.pivot?.expandAll()); document.getElementById('btn-save-layout').addEventListener('click', async () => {
document.getElementById('btn-collapse-all').addEventListener('click', () => state.grids.pivot?.collapseAll()); 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.getElementById('btn-clear-slice').addEventListener('click', clearSlice);
document.querySelectorAll('.op-tab').forEach(tab => { document.querySelectorAll('.op-tab').forEach(tab => {

View File

@ -4,8 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pivot Forecast</title> <title>Pivot Forecast</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.0/styles/ag-grid.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@perspective-dev/viewer/dist/css/themes.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.0/styles/ag-theme-alpine.css">
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
</head> </head>
<body> <body>
@ -163,12 +162,12 @@
<div class="forecast-toolbar"> <div class="forecast-toolbar">
<span id="forecast-label">No version selected</span> <span id="forecast-label">No version selected</span>
<button id="btn-forecast-refresh" class="btn">Refresh</button> <button id="btn-forecast-refresh" class="btn">Refresh</button>
<button id="btn-expand-all" class="btn">Expand All</button> <button id="btn-save-layout" class="btn">Save layout</button>
<button id="btn-collapse-all" class="btn">Collapse All</button> <button id="btn-reset-layout" class="btn hidden">Reset layout</button>
</div> </div>
<div class="forecast-layout"> <div class="forecast-layout">
<div id="pivot-panel"> <div id="pivot-panel">
<div id="pivot-grid" class="ag-theme-alpine"></div> <perspective-viewer id="pivot-viewer" theme="Pro Light"></perspective-viewer>
</div> </div>
<div id="operation-panel"> <div id="operation-panel">
<div class="op-section"> <div class="op-section">
@ -283,7 +282,6 @@
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/ag-grid-enterprise@31.0.0/dist/ag-grid-enterprise.min.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

View File

@ -199,7 +199,7 @@ body {
box-shadow: 0 1px 3px rgba(0,0,0,.08); box-shadow: 0 1px 3px rgba(0,0,0,.08);
min-width: 0; min-width: 0;
} }
#pivot-grid { width: 100%; height: 100%; } #pivot-viewer { width: 100%; height: 100%; }
#operation-panel { #operation-panel {
width: 270px; width: 270px;