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,
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 = [];
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;
}
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 } : {})
function pspLayoutKey() {
return `psp_layout_pf_${state.version?.id}`;
}
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' }
};
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')}`;
}
});
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;
}
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);
});
// 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() : '' });
await v.load(worker);
return defs;
const saved = localStorage.getItem(pspLayoutKey());
if (saved) {
await v.restore(JSON.parse(saved));
} else {
await v.restore(buildDefaultLayout());
}
function initPivotGrid(data) {
const el = document.getElementById('pivot-grid');
if (state.grids.pivot) {
state.grids.pivot.setGridOption('rowData', data);
return;
}
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
});
// 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 => {

View File

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

View File

@ -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;