pivotgrid-js 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +29 -0
- package/LICENSE.commercial +60 -0
- package/README.dev.md +247 -0
- package/README.md +253 -0
- package/config/config-editor.css +298 -0
- package/config/config-editor.html +202 -0
- package/config/config-editor.js +687 -0
- package/demo_data/demo-config.js +38 -0
- package/demo_data/demo-data.js +1 -0
- package/dist/pivotgrid.cjs.js +2867 -0
- package/dist/pivotgrid.css +1091 -0
- package/dist/pivotgrid.esm.js +2867 -0
- package/dist/pivotgrid.js +2865 -0
- package/dist/pivotgrid.min.js +18 -0
- package/engine/aggregator.js +193 -0
- package/engine/column-store.js +99 -0
- package/engine/dictionary-encoder.js +30 -0
- package/package.json +50 -0
- package/providers/array-provider.js +255 -0
- package/providers/rest-provider.js +296 -0
- package/server/.env +5 -0
- package/server/README.md +88 -0
- package/server/configs/main_config.json +112 -0
- package/server/connectors/__init__.py +0 -0
- package/server/connectors/__pycache__/postgresql.cpython-312.pyc +0 -0
- package/server/connectors/postgresql.py +34 -0
- package/server/server.py +328 -0
- package/src/field-zones.css +167 -0
- package/src/field-zones.js +344 -0
- package/src/filter-manager.js +290 -0
- package/src/pivot.css +252 -0
- package/src/pivot.js +919 -0
- package/widget/cache-manager.js +253 -0
- package/widget/i18n.js +179 -0
- package/widget/pivot-widget.js +572 -0
- package/widget/widget.css +672 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pivot-widget.js
|
|
3
|
+
*
|
|
4
|
+
* Top-level widget bootstrap. Reads container attributes, builds the HTML shell
|
|
5
|
+
* (unless data-standalone is set), wires up all components and kicks off init().
|
|
6
|
+
*
|
|
7
|
+
* Container attributes:
|
|
8
|
+
* data-config — config name to load from the server
|
|
9
|
+
* data-server — server base URL (default: http://localhost:8000)
|
|
10
|
+
* data-demo — "true" to run in demo mode (no server)
|
|
11
|
+
* data-lang — UI language: "ru" | "en" (default: "ru")
|
|
12
|
+
* data-standalone — if set, the HTML structure is already in the DOM
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ── Utilities ─────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** Formats a number with locale-aware thousand separators, no decimals. */
|
|
18
|
+
const fmt = (v) => new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(v);
|
|
19
|
+
|
|
20
|
+
// ── Container and settings ────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const pivotEl = document.getElementById('pivot-container')
|
|
23
|
+
|| document.querySelector('[data-config]')
|
|
24
|
+
|| document.querySelector('[data-demo]');
|
|
25
|
+
|
|
26
|
+
const IS_DEMO = pivotEl.dataset.demo === 'true';
|
|
27
|
+
const SERVER_URL = pivotEl.dataset.server || 'http://localhost:8000';
|
|
28
|
+
const CONFIG_NAME = pivotEl.dataset.config;
|
|
29
|
+
const IS_PREVIEW = new URLSearchParams(location.search).has('preview');
|
|
30
|
+
const LANG = pivotEl.dataset.lang || 'ru';
|
|
31
|
+
|
|
32
|
+
/** Translates a key using the active language from I18N. Supports {var} interpolation. */
|
|
33
|
+
const t = (key, vars = {}) => {
|
|
34
|
+
let str = (I18N[LANG] || I18N.ru)[key] || key;
|
|
35
|
+
for (const [k, v] of Object.entries(vars)) str = str.replace(`{${k}}`, v);
|
|
36
|
+
return str;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ── HTML structure ────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Builds and returns the full widget HTML string.
|
|
43
|
+
* All user-visible labels are passed through t() for i18n.
|
|
44
|
+
*/
|
|
45
|
+
function buildHTML() {
|
|
46
|
+
return `
|
|
47
|
+
<div class="demo-toggles">
|
|
48
|
+
<label><input type="checkbox" id="chk-cache" checked> ${t('cache')}</label>
|
|
49
|
+
<label><input type="checkbox" id="chk-fields" checked> ${t('constructor')}</label>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div class="cache-zone">
|
|
53
|
+
<div class="cache-zone-header">
|
|
54
|
+
<span class="cache-zone-label">${t('cache')}</span>
|
|
55
|
+
<div class="cache-meter-wrap">
|
|
56
|
+
<div class="cache-meter-bar">
|
|
57
|
+
<div class="cache-meter-fill ok" id="cache-meter-fill" style="width:0%"></div>
|
|
58
|
+
</div>
|
|
59
|
+
<span class="cache-meter-label" id="cache-meter-label">—</span>
|
|
60
|
+
</div>
|
|
61
|
+
<span class="cache-status empty" id="cache-status">${t('cacheEmpty')}</span>
|
|
62
|
+
<button class="toolbar-btn" id="btn-refresh-cache" disabled>${t('cacheRefresh')}</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="cache-zone-body" id="cache-chips"></div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="cache-toast" id="cache-toast"></div>
|
|
68
|
+
|
|
69
|
+
<div class="field-zones">
|
|
70
|
+
<div class="fz-zone fz-zone--filters">
|
|
71
|
+
<div class="fz-zone-label">${t('filters')}</div>
|
|
72
|
+
<div class="fz-zone-body" id="fz-chips-filters" data-fz-zone="filters"></div>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="fz-zone fz-zone--free">
|
|
75
|
+
<div class="fz-zone-label">${t('fields')}</div>
|
|
76
|
+
<div class="fz-zone-body" id="fz-chips-free" data-fz-zone="free"></div>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="fz-zone fz-zone--rows">
|
|
79
|
+
<div class="fz-zone-label">${t('rows')}</div>
|
|
80
|
+
<div class="fz-zone-body" id="fz-chips-rows" data-fz-zone="rows"></div>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="fz-zone fz-zone--columns">
|
|
83
|
+
<div class="fz-zone-label">${t('columns')}</div>
|
|
84
|
+
<div class="fz-zone-body" id="fz-chips-columns" data-fz-zone="columns"></div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div class="toolbar">
|
|
89
|
+
<span class="toolbar-label">${t('measure')}</span>
|
|
90
|
+
<select id="sel-measure"></select>
|
|
91
|
+
<div class="toolbar-sep"></div>
|
|
92
|
+
<span class="toolbar-label">${t('func')}</span>
|
|
93
|
+
<select id="sel-func"></select>
|
|
94
|
+
<div class="toolbar-sep"></div>
|
|
95
|
+
<button class="toolbar-btn" id="btn-expand">${t('expandRows')}</button>
|
|
96
|
+
<button class="toolbar-btn" id="btn-collapse">${t('collapseRows')}</button>
|
|
97
|
+
<div class="toolbar-sep"></div>
|
|
98
|
+
<button class="toolbar-btn" id="btn-expand-cols">${t('expandCols')}</button>
|
|
99
|
+
<button class="toolbar-btn" id="btn-collapse-cols">${t('collapseCols')}</button>
|
|
100
|
+
<div class="toolbar-sep"></div>
|
|
101
|
+
<button class="toolbar-btn toolbar-btn--toggle" id="btn-subtotals">${t('subtotals')}</button>
|
|
102
|
+
<button class="toolbar-btn" id="btn-export">${t('exportCsv')}</button>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div id="loading" style="
|
|
106
|
+
display: flex; align-items: center; justify-content: center;
|
|
107
|
+
height: 200px; font-size: 13px; color: #999;
|
|
108
|
+
">${t('loading')}</div>
|
|
109
|
+
|
|
110
|
+
<div id="error" style="
|
|
111
|
+
display: none; padding: 16px; background: #fff3f3;
|
|
112
|
+
border: 1px solid #fcc; border-radius: 8px;
|
|
113
|
+
font-size: 13px; color: #c00; margin-bottom: 12px;
|
|
114
|
+
"></div>
|
|
115
|
+
|
|
116
|
+
<div id="pivot-grid" class="pg-root" style="opacity:0; flex:1; min-height:200px; overflow:hidden;"></div>
|
|
117
|
+
|
|
118
|
+
<div class="dt-panel" id="dt-panel">
|
|
119
|
+
<div class="dt-header">
|
|
120
|
+
<span class="dt-header-label">${t('drillthrough')}</span>
|
|
121
|
+
<span class="dt-header-context" id="dt-context"></span>
|
|
122
|
+
<span class="dt-header-value" id="dt-value"></span>
|
|
123
|
+
<button class="dt-header-close" id="dt-close">×</button>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="dt-filters" id="dt-filters"></div>
|
|
126
|
+
<div class="dt-body">
|
|
127
|
+
<table class="dt-table">
|
|
128
|
+
<thead><tr id="dt-thead"></tr></thead>
|
|
129
|
+
<tbody id="dt-tbody"></tbody>
|
|
130
|
+
</table>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="dt-footer" id="dt-footer"></div>
|
|
133
|
+
</div>
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const isEmpty = !pivotEl.dataset.standalone;
|
|
138
|
+
if (isEmpty) {
|
|
139
|
+
pivotEl.style.cssText = `
|
|
140
|
+
display: flex; flex-direction: column;
|
|
141
|
+
height: 100dvh; overflow: hidden;
|
|
142
|
+
padding: 12px 12px 0 12px; gap: 8px;
|
|
143
|
+
font-family: -apple-system, 'Segoe UI', sans-serif;
|
|
144
|
+
background: #f4f5f7; color: #1a1a1a; box-sizing: border-box;
|
|
145
|
+
`;
|
|
146
|
+
pivotEl.innerHTML = buildHTML();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const gridEl = isEmpty
|
|
150
|
+
? document.getElementById('pivot-grid')
|
|
151
|
+
: pivotEl;
|
|
152
|
+
|
|
153
|
+
// ── UI utilities ──────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/** Shows or hides the initial full-page loading indicator. */
|
|
156
|
+
function setLoading(on) {
|
|
157
|
+
document.getElementById('loading').style.display = on ? 'flex' : 'none';
|
|
158
|
+
gridEl.style.opacity = on ? '0' : '1';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Shows or hides a semi-transparent overlay on top of the grid
|
|
163
|
+
* while a background data fetch is in progress.
|
|
164
|
+
* @param {boolean} on
|
|
165
|
+
*/
|
|
166
|
+
function setGridLoading(on) {
|
|
167
|
+
let overlay = document.getElementById('grid-loading-overlay');
|
|
168
|
+
if (on) {
|
|
169
|
+
if (!overlay) {
|
|
170
|
+
overlay = document.createElement('div');
|
|
171
|
+
overlay.id = 'grid-loading-overlay';
|
|
172
|
+
overlay.style.cssText = `
|
|
173
|
+
position: absolute; inset: 0;
|
|
174
|
+
background: rgba(255,255,255,0.7);
|
|
175
|
+
display: flex; align-items: center; justify-content: center;
|
|
176
|
+
font-size: 13px; color: #999; z-index: 100;
|
|
177
|
+
`;
|
|
178
|
+
overlay.textContent = t('loadingGrid');
|
|
179
|
+
gridEl.appendChild(overlay);
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
overlay?.remove();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Displays an error message in the error bar and hides the loading indicator.
|
|
188
|
+
* @param {Error|string} err
|
|
189
|
+
*/
|
|
190
|
+
function setError(err) {
|
|
191
|
+
setLoading(false);
|
|
192
|
+
const el = document.getElementById('error');
|
|
193
|
+
el.style.display = 'block';
|
|
194
|
+
el.textContent = t('errorPrefix') + (err.message || err);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
let CONFIG = {};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Loads the widget configuration from the appropriate source:
|
|
203
|
+
* - demo mode: uses DEMO_CONFIG
|
|
204
|
+
* - preview mode: reads from localStorage
|
|
205
|
+
* - normal mode: fetches from the server by CONFIG_NAME
|
|
206
|
+
*/
|
|
207
|
+
async function loadConfig() {
|
|
208
|
+
if (IS_DEMO) {
|
|
209
|
+
CONFIG = DEMO_CONFIG;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (IS_PREVIEW) {
|
|
214
|
+
const text = localStorage.getItem('pivot_config_preview');
|
|
215
|
+
if (text) {
|
|
216
|
+
const fn = new Function(text + '\nreturn CONFIG;');
|
|
217
|
+
CONFIG = fn();
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (CONFIG_NAME) {
|
|
223
|
+
const res = await fetch(`${SERVER_URL}/configs/${CONFIG_NAME}`);
|
|
224
|
+
if (!res.ok) throw new Error(`Config "${CONFIG_NAME}" not found`);
|
|
225
|
+
CONFIG = await res.json();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
throw new Error('data-config attribute is missing on the grid container');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── State ─────────────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
let provider; // RestProvider or ArrayProvider
|
|
235
|
+
let aggregator; // Aggregator instance
|
|
236
|
+
let grid; // PivotGrid instance
|
|
237
|
+
let currentRows = [];
|
|
238
|
+
let currentColumns = [];
|
|
239
|
+
let currentMeasure = '';
|
|
240
|
+
let currentFunc = '';
|
|
241
|
+
let currentFilters = {};
|
|
242
|
+
let _rebuilding = false; // guard against concurrent rebuilds
|
|
243
|
+
|
|
244
|
+
// ── Grid build ────────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fetches aggregated rows (from cache or server) and rebuilds the pivot grid.
|
|
248
|
+
* Creates the grid on first call, updates it on subsequent calls.
|
|
249
|
+
* Guarded by _rebuilding to prevent concurrent executions.
|
|
250
|
+
*/
|
|
251
|
+
async function rebuildGrid() {
|
|
252
|
+
if (_rebuilding) return;
|
|
253
|
+
_rebuilding = true;
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const required = [...new Set([...currentRows, ...currentColumns])];
|
|
257
|
+
let aggRows = provider.getBestRows(required, currentFilters);
|
|
258
|
+
|
|
259
|
+
if (!aggRows) {
|
|
260
|
+
setGridLoading(true);
|
|
261
|
+
const result = await provider.getRowsForDims(required, currentFilters);
|
|
262
|
+
setGridLoading(false);
|
|
263
|
+
aggRows = result.rows;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!aggRows) return;
|
|
267
|
+
|
|
268
|
+
const result = aggregator.build({
|
|
269
|
+
rows: currentRows,
|
|
270
|
+
columns: currentColumns,
|
|
271
|
+
measure: currentMeasure,
|
|
272
|
+
func: currentFunc,
|
|
273
|
+
aggRows,
|
|
274
|
+
fieldDefs: CONFIG.fields,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if (!grid) {
|
|
278
|
+
grid = new PivotGrid({
|
|
279
|
+
container: gridEl,
|
|
280
|
+
result,
|
|
281
|
+
rows: currentRows,
|
|
282
|
+
columns: currentColumns,
|
|
283
|
+
measure: currentMeasure,
|
|
284
|
+
fieldDefs: CONFIG.fields,
|
|
285
|
+
labels: {
|
|
286
|
+
total: t('total'),
|
|
287
|
+
confirmLargeExpand: t('confirmLargeExpand'),
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
grid.collapseAll();
|
|
291
|
+
grid.collapseAllCols();
|
|
292
|
+
} else {
|
|
293
|
+
grid.setResult(result, {
|
|
294
|
+
rows: currentRows,
|
|
295
|
+
columns: currentColumns,
|
|
296
|
+
measure: currentMeasure,
|
|
297
|
+
fieldDefs: CONFIG.fields,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
} finally {
|
|
301
|
+
_rebuilding = false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Rebuilds the grid and collapses all rows (used after dimension layout changes). */
|
|
306
|
+
function reconfig() { rebuildGrid().then(() => grid?.collapseAll()); }
|
|
307
|
+
|
|
308
|
+
/** Rebuilds the grid in place (used after measure/func/filter changes). */
|
|
309
|
+
function recalc() { rebuildGrid(); }
|
|
310
|
+
|
|
311
|
+
// ── Toolbar ───────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Populates the measure and function selects, binds toolbar button handlers,
|
|
315
|
+
* and wires the cache/fields visibility toggles.
|
|
316
|
+
*/
|
|
317
|
+
function initToolbar() {
|
|
318
|
+
const selMeasure = document.getElementById('sel-measure');
|
|
319
|
+
for (const m of CONFIG.measures) {
|
|
320
|
+
const def = CONFIG.fields[m] || {};
|
|
321
|
+
const opt = document.createElement('option');
|
|
322
|
+
opt.value = m;
|
|
323
|
+
opt.textContent = def.title || def.label || m;
|
|
324
|
+
selMeasure.appendChild(opt);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const selFunc = document.getElementById('sel-func');
|
|
328
|
+
for (const f of CONFIG.funcs) {
|
|
329
|
+
const opt = document.createElement('option');
|
|
330
|
+
opt.value = f;
|
|
331
|
+
opt.textContent = f;
|
|
332
|
+
opt.selected = f === CONFIG.func;
|
|
333
|
+
selFunc.appendChild(opt);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
selMeasure.addEventListener('change', (e) => { currentMeasure = e.target.value; recalc(); });
|
|
337
|
+
selFunc.addEventListener('change', (e) => { currentFunc = e.target.value; recalc(); });
|
|
338
|
+
|
|
339
|
+
document.getElementById('btn-expand').addEventListener('click', () => grid?.expandAll());
|
|
340
|
+
document.getElementById('btn-collapse').addEventListener('click', () => grid?.collapseAll());
|
|
341
|
+
document.getElementById('btn-expand-cols').addEventListener('click', () => grid?.expandAllCols());
|
|
342
|
+
document.getElementById('btn-collapse-cols').addEventListener('click', () => grid?.collapseAllCols());
|
|
343
|
+
|
|
344
|
+
let _subtotalsVisible = true;
|
|
345
|
+
document.getElementById('btn-subtotals').addEventListener('click', () => {
|
|
346
|
+
_subtotalsVisible = !_subtotalsVisible;
|
|
347
|
+
grid?.toggleSubtotals(_subtotalsVisible);
|
|
348
|
+
document.getElementById('btn-subtotals').classList.toggle('is-active', !_subtotalsVisible);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
document.getElementById('chk-cache').addEventListener('change', (e) => {
|
|
352
|
+
document.querySelector('.cache-zone').style.display = e.target.checked ? '' : 'none';
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
document.getElementById('chk-fields').addEventListener('change', (e) => {
|
|
356
|
+
document.querySelector('.field-zones').style.display = e.target.checked ? '' : 'none';
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── CSV export ────────────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Binds the CSV export button.
|
|
364
|
+
* Fetches current aggregated rows and downloads them as a UTF-8 BOM CSV file.
|
|
365
|
+
*/
|
|
366
|
+
function initExport() {
|
|
367
|
+
document.getElementById('btn-export').addEventListener('click', async () => {
|
|
368
|
+
const required = [...new Set([...currentRows, ...currentColumns])];
|
|
369
|
+
const { rows } = await provider.getRowsForDims(required, currentFilters);
|
|
370
|
+
|
|
371
|
+
/** Wraps a value in quotes if it contains commas, quotes, or newlines. */
|
|
372
|
+
const escape = (v) => {
|
|
373
|
+
const s = String(v ?? '');
|
|
374
|
+
return s.includes(',') || s.includes('"') || s.includes('\n')
|
|
375
|
+
? '"' + s.replace(/"/g, '""') + '"'
|
|
376
|
+
: s;
|
|
377
|
+
};
|
|
378
|
+
const title = (dim) => { const d = CONFIG.fields[dim] || {}; return d.title || d.label || dim; };
|
|
379
|
+
const dimCols = required.map(f => (CONFIG.fields[f] || {}).label || f);
|
|
380
|
+
const measCols = CONFIG.measures.map(m => `${m}_${currentFunc}`);
|
|
381
|
+
const header = [...required.map(title), ...CONFIG.measures.map(title)].map(escape).join(',');
|
|
382
|
+
const lines = [header];
|
|
383
|
+
|
|
384
|
+
for (const row of rows) {
|
|
385
|
+
lines.push([
|
|
386
|
+
...dimCols.map(col => escape(row[col])),
|
|
387
|
+
...measCols.map(col => escape(row[col] ?? '')),
|
|
388
|
+
].join(','));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const blob = new Blob(['\uFEFF' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
|
|
392
|
+
const a = document.createElement('a');
|
|
393
|
+
a.href = URL.createObjectURL(blob);
|
|
394
|
+
a.download = `pivot_${currentMeasure}_${currentFunc}.csv`;
|
|
395
|
+
a.click();
|
|
396
|
+
URL.revokeObjectURL(a.href);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── Drillthrough ──────────────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Binds the drillthrough panel.
|
|
404
|
+
* Listens for the custom 'drillthrough' event dispatched by PivotGrid,
|
|
405
|
+
* fetches detail rows from the provider, and renders them in the panel.
|
|
406
|
+
* If drillthroughUrl is configured, opens it in a new tab instead.
|
|
407
|
+
*/
|
|
408
|
+
function initDrillthrough() {
|
|
409
|
+
const dtPanel = document.getElementById('dt-panel');
|
|
410
|
+
const dtContext = document.getElementById('dt-context');
|
|
411
|
+
const dtValue = document.getElementById('dt-value');
|
|
412
|
+
const dtFilters = document.getElementById('dt-filters');
|
|
413
|
+
const dtTbody = document.getElementById('dt-tbody');
|
|
414
|
+
const dtFooter = document.getElementById('dt-footer');
|
|
415
|
+
|
|
416
|
+
document.addEventListener('drillthrough', async (e) => {
|
|
417
|
+
if (!CONFIG.drillthroughQuery && !CONFIG.drillthroughUrl) return;
|
|
418
|
+
|
|
419
|
+
const { context, value } = e.detail;
|
|
420
|
+
|
|
421
|
+
if (CONFIG.drillthroughUrl) {
|
|
422
|
+
window.open(`${CONFIG.drillthroughUrl}?${new URLSearchParams(context)}`, '_blank');
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const keys = Object.keys(context);
|
|
427
|
+
dtContext.textContent = keys.length ? keys.map(k => context[k]).join(' › ') : t('allData');
|
|
428
|
+
dtValue.textContent = fmt(value);
|
|
429
|
+
dtFilters.innerHTML = keys.map(k => `
|
|
430
|
+
<div class="dt-filter-tag"><span class="tag-key">${k}:</span> ${context[k]}</div>
|
|
431
|
+
`).join('');
|
|
432
|
+
|
|
433
|
+
dtTbody.innerHTML = `<tr><td colspan="7" style="padding:12px;color:#999">${t('loadingGrid')}</td></tr>`;
|
|
434
|
+
dtPanel.classList.add('visible');
|
|
435
|
+
gridEl.style.marginBottom = '300px';
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
const rows = await provider.drillthrough({ filters: context });
|
|
439
|
+
const cols = rows.length ? Object.keys(rows[0]) : [];
|
|
440
|
+
|
|
441
|
+
document.getElementById('dt-thead').innerHTML = cols.map(c => {
|
|
442
|
+
const isNum = rows.length && typeof rows[0][c] === 'number';
|
|
443
|
+
return `<th class="${isNum ? 'num' : ''}">${c}</th>`;
|
|
444
|
+
}).join('');
|
|
445
|
+
|
|
446
|
+
dtTbody.innerHTML = rows.length
|
|
447
|
+
? rows.map(r =>
|
|
448
|
+
'<tr>' + cols.map(c => {
|
|
449
|
+
const v = r[c]; const isNum = typeof v === 'number';
|
|
450
|
+
return `<td class="${isNum ? 'num' : ''}">${isNum ? fmt(v) : (v ?? '—')}</td>`;
|
|
451
|
+
}).join('') + '</tr>'
|
|
452
|
+
).join('')
|
|
453
|
+
: `<tr><td colspan="${cols.length || 1}" style="padding:12px;color:#999">${t('noData')}</td></tr>`;
|
|
454
|
+
|
|
455
|
+
const totals = CONFIG.measures.map(m => {
|
|
456
|
+
const total = rows.reduce((s, r) => s + Number(r[m] || 0), 0);
|
|
457
|
+
const def = CONFIG.fields[m] || {};
|
|
458
|
+
return `<span>${def.title || m}: <strong>${fmt(total)}</strong></span>`;
|
|
459
|
+
}).join('');
|
|
460
|
+
|
|
461
|
+
dtFooter.innerHTML = `
|
|
462
|
+
<span>${t('shown')}: <strong>${rows.length}</strong></span>
|
|
463
|
+
${totals}
|
|
464
|
+
${rows.length >= 200 ? `<span class="dt-warning">${t('firstN', { n: 200 })}</span>` : ''}
|
|
465
|
+
`;
|
|
466
|
+
} catch (err) {
|
|
467
|
+
dtTbody.innerHTML = `<tr><td colspan="5" style="padding:12px;color:#c00">${err.message}</td></tr>`;
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
document.getElementById('dt-close').addEventListener('click', () => {
|
|
472
|
+
dtPanel.classList.remove('visible');
|
|
473
|
+
gridEl.style.marginBottom = '';
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ── Initialization ────────────────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Main entry point. Loads config, creates provider/aggregator,
|
|
481
|
+
* wires FieldZones + FilterManager, initialises toolbar/export/drillthrough,
|
|
482
|
+
* prefetches the cache and renders the grid for the first time.
|
|
483
|
+
*/
|
|
484
|
+
async function init() {
|
|
485
|
+
try {
|
|
486
|
+
setLoading(true);
|
|
487
|
+
|
|
488
|
+
await loadConfig();
|
|
489
|
+
|
|
490
|
+
currentRows = CONFIG.rows || [];
|
|
491
|
+
currentColumns = CONFIG.columns || [];
|
|
492
|
+
currentMeasure = CONFIG.measure || '';
|
|
493
|
+
currentFunc = CONFIG.func || 'sum';
|
|
494
|
+
|
|
495
|
+
provider = IS_DEMO
|
|
496
|
+
? new ArrayProvider({
|
|
497
|
+
data: DEMO_DATA,
|
|
498
|
+
dimensions: CONFIG.dimensions,
|
|
499
|
+
measures: CONFIG.measures,
|
|
500
|
+
funcs: CONFIG.funcs,
|
|
501
|
+
fields: CONFIG.fields,
|
|
502
|
+
cachedDimensions: CONFIG.cachedDimensions,
|
|
503
|
+
maxCachedRows: CONFIG.maxCachedRows,
|
|
504
|
+
})
|
|
505
|
+
: new RestProvider({
|
|
506
|
+
url: `${SERVER_URL}/query`,
|
|
507
|
+
query: CONFIG.query,
|
|
508
|
+
dimensions: CONFIG.dimensions,
|
|
509
|
+
measures: CONFIG.measures,
|
|
510
|
+
funcs: CONFIG.funcs,
|
|
511
|
+
fields: CONFIG.fields,
|
|
512
|
+
cachedDimensions: CONFIG.cachedDimensions,
|
|
513
|
+
maxCachedRows: CONFIG.maxCachedRows,
|
|
514
|
+
drillthroughQuery: CONFIG.drillthroughQuery,
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
aggregator = new Aggregator();
|
|
518
|
+
|
|
519
|
+
const filterManager = new FilterManager({
|
|
520
|
+
provider, fields: CONFIG.fields, config: CONFIG,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const fieldZones = new FieldZones({
|
|
524
|
+
dimensions: CONFIG.dimensions,
|
|
525
|
+
fields: CONFIG.fields,
|
|
526
|
+
initialRows: CONFIG.rows,
|
|
527
|
+
initialColumns: CONFIG.columns,
|
|
528
|
+
initialFilters: [],
|
|
529
|
+
onChange({ rows, columns, filters }) {
|
|
530
|
+
const prevFilters = Object.keys(filterManager._state);
|
|
531
|
+
for (const dim of filters) {
|
|
532
|
+
if (!prevFilters.includes(dim)) filterManager.onDimAdded(dim);
|
|
533
|
+
}
|
|
534
|
+
for (const dim of prevFilters) {
|
|
535
|
+
if (!filters.includes(dim)) filterManager.onDimRemoved(dim);
|
|
536
|
+
}
|
|
537
|
+
currentRows = rows;
|
|
538
|
+
currentColumns = columns;
|
|
539
|
+
reconfig();
|
|
540
|
+
},
|
|
541
|
+
onFilterOpen(dim, chipEl) { filterManager.openFor(dim, chipEl); },
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
filterManager.onChange = () => {
|
|
545
|
+
fieldZones.setFilterHints(filterManager.getFilterHints());
|
|
546
|
+
currentFilters = filterManager.getActiveFilters();
|
|
547
|
+
rebuildGrid();
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
initToolbar();
|
|
551
|
+
initExport();
|
|
552
|
+
initDrillthrough();
|
|
553
|
+
|
|
554
|
+
await provider.prefetch();
|
|
555
|
+
await rebuildGrid();
|
|
556
|
+
setLoading(false);
|
|
557
|
+
|
|
558
|
+
new CacheManager({
|
|
559
|
+
provider,
|
|
560
|
+
dimensions: CONFIG.dimensions,
|
|
561
|
+
maxCachedRows: CONFIG.maxCachedRows,
|
|
562
|
+
initialCount: provider.cacheRows,
|
|
563
|
+
onRefresh: rebuildGrid,
|
|
564
|
+
lang: LANG,
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
} catch (err) {
|
|
568
|
+
setError(err);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
init();
|