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:
parent
1df37a5ff1
commit
368127e098
209
public/app.js
209
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 => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user