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
201
public/app.js
201
public/app.js
@ -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;
|
||||||
|
_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) => {
|
function pspLayoutKey() {
|
||||||
if (c.role === 'ignore') return;
|
return `psp_layout_pf_${state.version?.id}`;
|
||||||
const needsGetter = /\W/.test(c.cname);
|
}
|
||||||
const def = {
|
|
||||||
field: c.cname,
|
function buildDefaultLayout() {
|
||||||
headerName: c.label || c.cname,
|
const dims = state.colMeta.filter(c => c.role === 'dimension').map(c => c.cname);
|
||||||
resizable: true,
|
const values = state.colMeta.filter(c => c.role === 'value' || c.role === 'units').map(c => c.cname);
|
||||||
sortable: true,
|
const date = state.colMeta.find(c => c.role === 'date')?.cname;
|
||||||
...(needsGetter ? { valueGetter: p => p.data ? p.data[c.cname] : undefined } : {})
|
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') {
|
async function initPerspectiveViewer(data) {
|
||||||
defs.push({
|
const viewer = document.getElementById('pivot-viewer');
|
||||||
colId: c.cname + '__month',
|
|
||||||
headerName: (c.label || c.cname) + ' (Month)',
|
// terminate old worker if reloading
|
||||||
enableRowGroup: true,
|
if (state.pspWorker) {
|
||||||
enablePivot: true,
|
try { state.pspWorker.terminate(); } catch (_) {}
|
||||||
valueGetter: p => {
|
state.pspWorker = null;
|
||||||
const v = p.data ? p.data[c.cname] : undefined;
|
state.pspTable = null;
|
||||||
if (!v) return undefined;
|
|
||||||
const d = new Date(v);
|
|
||||||
return isNaN(d) ? undefined : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
await v.load(worker);
|
||||||
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;
|
const saved = localStorage.getItem(pspLayoutKey());
|
||||||
|
if (saved) {
|
||||||
|
await v.restore(JSON.parse(saved));
|
||||||
|
} else {
|
||||||
|
await v.restore(buildDefaultLayout());
|
||||||
}
|
}
|
||||||
|
|
||||||
function initPivotGrid(data) {
|
// update reset button visibility
|
||||||
const el = document.getElementById('pivot-grid');
|
document.getElementById('btn-reset-layout')
|
||||||
|
.classList.toggle('hidden', !localStorage.getItem(pspLayoutKey()));
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
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 => {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user