pivotgrid-js 0.1.2 → 0.1.3
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/dist/pivotgrid.cjs.js +939 -929
- package/dist/pivotgrid.esm.js +939 -929
- package/dist/pivotgrid.js +939 -929
- package/package.json +1 -1
- package/providers/rest-provider.js +20 -10
- package/server/README.md +25 -1
- package/server/configs/main_config.json +1 -1
- package/server/server.py +34 -3
- package/src/pivot.js +919 -919
package/src/pivot.js
CHANGED
|
@@ -1,919 +1,919 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PivotGrid — vanilla JS
|
|
3
|
-
* v0.3 — hierarchical columns, absolute-positioned headers
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
class PivotGrid {
|
|
7
|
-
|
|
8
|
-
static ROW_HEIGHT = 24;
|
|
9
|
-
static HEADER_HEIGHT = 32;
|
|
10
|
-
static COL_HEADER_W = 200;
|
|
11
|
-
static COL_W = 150;
|
|
12
|
-
static INDENT = 16;
|
|
13
|
-
static BUFFER = 5;
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* @param {object} options
|
|
17
|
-
* @param {Element} options.container — DOM element to render into
|
|
18
|
-
* @param {object} options.result — aggregation result from Aggregator.build()
|
|
19
|
-
* @param {string[]} options.rows — active row dimension names
|
|
20
|
-
* @param {string[]} options.columns — active column dimension names
|
|
21
|
-
* @param {string} options.measure — active measure name
|
|
22
|
-
* @param {object} [options.fieldDefs={}] — field definitions (label, title, sortKey)
|
|
23
|
-
* @param {object} [options.labels={}] — translated UI strings (total, confirmLargeExpand)
|
|
24
|
-
*/
|
|
25
|
-
constructor({ container, result, rows, columns, measure, fieldDefs = {}, labels = {} }) {
|
|
26
|
-
this.container = container;
|
|
27
|
-
this.rows = rows;
|
|
28
|
-
this.columns = columns;
|
|
29
|
-
this.measure = measure;
|
|
30
|
-
this.fieldDefs = fieldDefs;
|
|
31
|
-
this._labels = labels;
|
|
32
|
-
this._measureKey = measure + '_sum'; // updated via setMeasure()
|
|
33
|
-
this._colHeaderW = PivotGrid.COL_HEADER_W;
|
|
34
|
-
this._hideSubtotals = false;
|
|
35
|
-
|
|
36
|
-
this.collapsed = new Set();
|
|
37
|
-
this.collapsedCols = new Set();
|
|
38
|
-
this.rowPool = [];
|
|
39
|
-
this.rendered = new Map();
|
|
40
|
-
|
|
41
|
-
this._applyResult(result);
|
|
42
|
-
this._mount();
|
|
43
|
-
this._renderVisible();
|
|
44
|
-
this._bindScroll();
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// ── Apply Result ────────────────────────────────────────────────────
|
|
48
|
-
|
|
49
|
-
/** Applies an aggregation result object and rebuilds flat rows/cols. */
|
|
50
|
-
_applyResult(result) {
|
|
51
|
-
this.cells = result.cells;
|
|
52
|
-
this.colTree = result.colTree;
|
|
53
|
-
this.colKeys = result.colKeys;
|
|
54
|
-
this.tree = result.tree;
|
|
55
|
-
this.grandTotal = result.grandTotal;
|
|
56
|
-
if (result.measureKey) this._measureKey = result.measureKey;
|
|
57
|
-
this._buildFlatCols();
|
|
58
|
-
this._buildFlatRows();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ── Flat list of visible columns ────────────────────────────────────────
|
|
62
|
-
|
|
63
|
-
/** Builds this.flatCols — the ordered list of visible leaf/subtotal column entries. */
|
|
64
|
-
_buildFlatCols() {
|
|
65
|
-
if (!this.colTree || !this.colTree.length) {
|
|
66
|
-
this.flatCols = [];
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const result = [];
|
|
71
|
-
const multiLevel = this.columns && this.columns.length > 1;
|
|
72
|
-
|
|
73
|
-
const walk = (nodes) => {
|
|
74
|
-
for (const node of nodes) {
|
|
75
|
-
if (node.children) {
|
|
76
|
-
if (this.collapsedCols.has(node.code)) {
|
|
77
|
-
result.push({ code: node.code, label: node.value, isSubtotal: true, collapsed: true });
|
|
78
|
-
} else {
|
|
79
|
-
walk(node.children);
|
|
80
|
-
if (multiLevel && !this._hideSubtotals) {
|
|
81
|
-
result.push({ code: node.code, label: '∑', isSubtotal: true, collapsed: false });
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
} else {
|
|
85
|
-
result.push({ code: node.code, label: node.value, isSubtotal: false });
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
walk(this.colTree);
|
|
91
|
-
this.flatCols = result;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Number of flatCols occupied by a node (recursive, respects collapsed state).
|
|
96
|
-
*/
|
|
97
|
-
_getGroupSpan(node) {
|
|
98
|
-
if (!node.children || this.collapsedCols.has(node.code)) return 1;
|
|
99
|
-
const multiLevel = this.columns && this.columns.length > 1;
|
|
100
|
-
let span = (multiLevel && !this._hideSubtotals) ? 1 : 0;
|
|
101
|
-
for (const child of node.children) {
|
|
102
|
-
span += this._getGroupSpan(child);
|
|
103
|
-
}
|
|
104
|
-
return span;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Depth of the column tree, accounting for collapsed nodes.
|
|
109
|
-
*/
|
|
110
|
-
_colTreeDepth() {
|
|
111
|
-
if (!this.colTree || !this.colTree.length) return 1;
|
|
112
|
-
const walk = (nodes) => {
|
|
113
|
-
let max = 0;
|
|
114
|
-
for (const node of nodes) {
|
|
115
|
-
if (node.children && !this.collapsedCols.has(node.code)) {
|
|
116
|
-
max = Math.max(max, 1 + walk(node.children));
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return max;
|
|
120
|
-
};
|
|
121
|
-
return 1 + walk(this.colTree);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ── Flat list of strings ──────────────────────────────────────────────────
|
|
125
|
-
|
|
126
|
-
/** Builds this.flatRows — flat array of visible row nodes including grand total. */
|
|
127
|
-
_buildFlatRows() {
|
|
128
|
-
this.flatRows = [];
|
|
129
|
-
const walk = (nodes) => {
|
|
130
|
-
for (const node of nodes) {
|
|
131
|
-
this.flatRows.push(node);
|
|
132
|
-
if (node.children && !this.collapsed.has(node.code)) {
|
|
133
|
-
walk(node.children);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
if (this.tree) walk(this.tree);
|
|
138
|
-
this.flatRows.push({ isGrandTotal: true });
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Total header height in px (HEADER_HEIGHT × column tree depth). */
|
|
142
|
-
get _headerHeight() {
|
|
143
|
-
return PivotGrid.HEADER_HEIGHT * this._colTreeDepth();
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// ── Mounting ───────────────────────────────────────────────────────────
|
|
147
|
-
|
|
148
|
-
/** Clears the container and mounts the column header + scroll area. */
|
|
149
|
-
_mount() {
|
|
150
|
-
this.container.innerHTML = '';
|
|
151
|
-
this.container.classList.add('pg-root');
|
|
152
|
-
|
|
153
|
-
const cols = this.flatCols.length ? this.flatCols : this.colKeys;
|
|
154
|
-
this.totalWidth = this._colHeaderW + (cols.length + 1) * PivotGrid.COL_W;
|
|
155
|
-
|
|
156
|
-
this._mountColHeader();
|
|
157
|
-
this._mountScrollArea();
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Builds and appends the absolute-positioned column header element. */
|
|
161
|
-
_mountColHeader() {
|
|
162
|
-
const RH = PivotGrid.HEADER_HEIGHT;
|
|
163
|
-
const C = this._colHeaderW;
|
|
164
|
-
const W = PivotGrid.COL_W;
|
|
165
|
-
const totalDepth = this._colTreeDepth();
|
|
166
|
-
const H = RH * totalDepth;
|
|
167
|
-
|
|
168
|
-
this.headerEl = document.createElement('div');
|
|
169
|
-
this.headerEl.className = 'pg-col-header';
|
|
170
|
-
this.headerEl.style.cssText = `
|
|
171
|
-
position: absolute; top: 0; left: 0;
|
|
172
|
-
width: ${this.totalWidth}px; height: ${H}px;
|
|
173
|
-
background: #fafafa; border-bottom: 1px solid #d0d0d0; z-index: 10;
|
|
174
|
-
`;
|
|
175
|
-
|
|
176
|
-
// Row label — full height
|
|
177
|
-
const rowLabelCell = this._absCell({
|
|
178
|
-
x: 0, y: 0, w: C, h: H,
|
|
179
|
-
text: '',
|
|
180
|
-
cls: 'row-label',
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
this.rows.forEach((row, i) => {
|
|
184
|
-
const span = document.createElement('span');
|
|
185
|
-
const def = (this.fieldDefs || {})[row] || {};
|
|
186
|
-
span.textContent = def.title || def.label || row;
|
|
187
|
-
span.style.cssText = 'cursor:pointer; padding: 0 2px;';
|
|
188
|
-
span.title = `Expand to "${row}"`;
|
|
189
|
-
if (i < this.rows.length - 1) {
|
|
190
|
-
span.addEventListener('click', () => this.expandToDepth(i + 1));
|
|
191
|
-
} else {
|
|
192
|
-
span.style.cursor = 'default';
|
|
193
|
-
}
|
|
194
|
-
if (i > 0) {
|
|
195
|
-
const sep = document.createElement('span');
|
|
196
|
-
sep.textContent = ' › ';
|
|
197
|
-
sep.style.color = '#ccc';
|
|
198
|
-
rowLabelCell.appendChild(sep);
|
|
199
|
-
}
|
|
200
|
-
rowLabelCell.appendChild(span);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
// Resize handle for the first column
|
|
204
|
-
const resizeHandle = document.createElement('div');
|
|
205
|
-
resizeHandle.className = 'pg-col-resize-handle';
|
|
206
|
-
resizeHandle.style.cssText = `
|
|
207
|
-
position: absolute; top: 0; left: ${C - 4}px;
|
|
208
|
-
width: 8px; height: ${H}px;
|
|
209
|
-
cursor: col-resize; z-index: 20;
|
|
210
|
-
`;
|
|
211
|
-
this.headerEl.appendChild(resizeHandle);
|
|
212
|
-
this._bindResizeHandle(resizeHandle);
|
|
213
|
-
|
|
214
|
-
// Columns
|
|
215
|
-
if (this.colTree && this.colTree.length) {
|
|
216
|
-
let offset = 0;
|
|
217
|
-
for (const node of this.colTree) {
|
|
218
|
-
offset = this._renderColNode(node, 0, offset, totalDepth);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Total — full height
|
|
223
|
-
const cols = this.flatCols.length ? this.flatCols : this.colKeys;
|
|
224
|
-
this._absCell({
|
|
225
|
-
x: C + cols.length * W,
|
|
226
|
-
y: 0,
|
|
227
|
-
w: W,
|
|
228
|
-
h: H,
|
|
229
|
-
text: this._labels.total || 'Total',
|
|
230
|
-
cls: 'total-col',
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
this.container.appendChild(this.headerEl);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Recursively renders a column header cell with absolute positioning.
|
|
238
|
-
* Returns the new leafOffset.
|
|
239
|
-
*/
|
|
240
|
-
_renderColNode(node, level, leafOffset, totalDepth) {
|
|
241
|
-
const RH = PivotGrid.HEADER_HEIGHT;
|
|
242
|
-
const C = this._colHeaderW;
|
|
243
|
-
const W = PivotGrid.COL_W;
|
|
244
|
-
const collapsed = this.collapsedCols.has(node.code);
|
|
245
|
-
const isLeaf = !node.children;
|
|
246
|
-
const span = this._getGroupSpan(node);
|
|
247
|
-
|
|
248
|
-
// Листья и свёрнутые растягиваются до конца заголовка
|
|
249
|
-
const cellH = (isLeaf || collapsed)
|
|
250
|
-
? (totalDepth - level) * RH
|
|
251
|
-
: RH;
|
|
252
|
-
|
|
253
|
-
const cls = collapsed ? 'subtotal-col'
|
|
254
|
-
: isLeaf ? ''
|
|
255
|
-
: 'pg-col-header-group';
|
|
256
|
-
|
|
257
|
-
const cell = this._absCell({
|
|
258
|
-
x: C + leafOffset * W,
|
|
259
|
-
y: level * RH,
|
|
260
|
-
w: span * W,
|
|
261
|
-
h: cellH,
|
|
262
|
-
text: node.value,
|
|
263
|
-
cls,
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
// Collapse toggle button
|
|
267
|
-
if (node.children) {
|
|
268
|
-
const toggle = document.createElement('span');
|
|
269
|
-
toggle.className = 'pg-toggle' + (collapsed ? ' collapsed' : '');
|
|
270
|
-
toggle.textContent = '▾';
|
|
271
|
-
toggle.addEventListener('click', (e) => {
|
|
272
|
-
e.stopPropagation();
|
|
273
|
-
this._toggleColCollapse(node.code);
|
|
274
|
-
});
|
|
275
|
-
cell.insertBefore(toggle, cell.firstChild);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (!isLeaf && !collapsed) {
|
|
279
|
-
// Render children
|
|
280
|
-
let childOffset = leafOffset;
|
|
281
|
-
for (const child of node.children) {
|
|
282
|
-
childOffset = this._renderColNode(child, level + 1, childOffset, totalDepth);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// ∑ for group — starts one level down, stretches to the end
|
|
286
|
-
if (this.columns && this.columns.length > 1 && !this._hideSubtotals) {
|
|
287
|
-
const subtotalH = (totalDepth - level - 1) * RH;
|
|
288
|
-
if (subtotalH > 0) {
|
|
289
|
-
this._absCell({
|
|
290
|
-
x: C + (leafOffset + span - 1) * W,
|
|
291
|
-
y: (level + 1) * RH,
|
|
292
|
-
w: W,
|
|
293
|
-
h: subtotalH,
|
|
294
|
-
text: '∑',
|
|
295
|
-
cls: 'subtotal-col',
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
return leafOffset + span;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Creates and appends an absolutely positioned cell to headerEl.
|
|
306
|
-
*/
|
|
307
|
-
_absCell({ x, y, w, h, text, cls }) {
|
|
308
|
-
const cell = document.createElement('div');
|
|
309
|
-
cell.className = 'pg-col-header-cell' + (cls ? ' ' + cls : '');
|
|
310
|
-
cell.style.cssText = `
|
|
311
|
-
position: absolute;
|
|
312
|
-
left: ${x}px; top: ${y}px;
|
|
313
|
-
width: ${w}px; height: ${h}px;
|
|
314
|
-
box-sizing: border-box;
|
|
315
|
-
`;
|
|
316
|
-
cell.textContent = text;
|
|
317
|
-
this.headerEl.appendChild(cell);
|
|
318
|
-
return cell;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/** Creates the scroll area div and the virtual space div inside it. */
|
|
322
|
-
_mountScrollArea() {
|
|
323
|
-
const H = this._headerHeight;
|
|
324
|
-
|
|
325
|
-
this.scrollArea = document.createElement('div');
|
|
326
|
-
this.scrollArea.className = 'pg-scroll';
|
|
327
|
-
this.scrollArea.style.top = H + 'px';
|
|
328
|
-
this.container.appendChild(this.scrollArea);
|
|
329
|
-
|
|
330
|
-
this.virtualSpace = document.createElement('div');
|
|
331
|
-
this.virtualSpace.style.cssText = `
|
|
332
|
-
position: relative;
|
|
333
|
-
width: ${this.totalWidth}px;
|
|
334
|
-
height: ${this.flatRows.length * PivotGrid.ROW_HEIGHT}px;
|
|
335
|
-
`;
|
|
336
|
-
this.scrollArea.appendChild(this.virtualSpace);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// ── Virtualization ──────────────────────────────────────────────────────────
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Renders only the rows currently in the viewport (+ BUFFER rows above/below).
|
|
343
|
-
* Recycles rows that have scrolled out of view back into the pool.
|
|
344
|
-
*/
|
|
345
|
-
_renderVisible() {
|
|
346
|
-
const viewH = this.scrollArea.clientHeight;
|
|
347
|
-
const scrollTop = this.scrollArea.scrollTop;
|
|
348
|
-
const RH = PivotGrid.ROW_HEIGHT;
|
|
349
|
-
const BUF = PivotGrid.BUFFER;
|
|
350
|
-
|
|
351
|
-
const first = Math.max(0, Math.floor(scrollTop / RH) - BUF);
|
|
352
|
-
const last = Math.min(
|
|
353
|
-
this.flatRows.length - 1,
|
|
354
|
-
Math.ceil((scrollTop + viewH) / RH) + BUF
|
|
355
|
-
);
|
|
356
|
-
|
|
357
|
-
for (const [idx, el] of this.rendered) {
|
|
358
|
-
if (idx < first || idx > last) {
|
|
359
|
-
this.virtualSpace.removeChild(el);
|
|
360
|
-
this._recycleRow(el);
|
|
361
|
-
this.rendered.delete(idx);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
for (let i = first; i <= last; i++) {
|
|
366
|
-
if (this.rendered.has(i)) continue;
|
|
367
|
-
const el = this._acquireRow();
|
|
368
|
-
this._fillRow(el, this.flatRows[i], i);
|
|
369
|
-
this.virtualSpace.appendChild(el);
|
|
370
|
-
this.rendered.set(i, el);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/** Returns a recycled or newly created row element. */
|
|
375
|
-
_acquireRow() {
|
|
376
|
-
if (this.rowPool.length) {
|
|
377
|
-
const el = this.rowPool.pop();
|
|
378
|
-
el.className = 'pg-row';
|
|
379
|
-
el.removeAttribute('style');
|
|
380
|
-
el.innerHTML = '';
|
|
381
|
-
return el;
|
|
382
|
-
}
|
|
383
|
-
const el = document.createElement('div');
|
|
384
|
-
el.className = 'pg-row';
|
|
385
|
-
return el;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** Returns a row element to the pool for reuse. */
|
|
389
|
-
_recycleRow(el) {
|
|
390
|
-
this.rowPool.push(el);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// ── Filling the Line ──────────────────────────────────────────────────────
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Fills a row element with header cell and value cells for the given node.
|
|
397
|
-
* @param {Element} el — row element from the pool
|
|
398
|
-
* @param {object} node — flat row node (or { isGrandTotal: true })
|
|
399
|
-
* @param {number} idx — row index in flatRows
|
|
400
|
-
*/
|
|
401
|
-
_fillRow(el, node, idx) {
|
|
402
|
-
const RH = PivotGrid.ROW_HEIGHT;
|
|
403
|
-
el.style.top = idx * RH + 'px';
|
|
404
|
-
el.style.width = this.totalWidth + 'px';
|
|
405
|
-
el.style.height = RH + 'px';
|
|
406
|
-
|
|
407
|
-
if (node.isGrandTotal) {
|
|
408
|
-
el.classList.add('grand-total');
|
|
409
|
-
this._fillGrandTotalRow(el);
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
el.style.background = idx % 2 === 0 ? '#ffffff' : '#fcfcfc';
|
|
414
|
-
this._fillHeaderCell(el, node);
|
|
415
|
-
this._fillValueCells(el, node);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* Appends the sticky left header cell (label + expand/collapse toggle) to a row.
|
|
420
|
-
* @param {Element} el — row element
|
|
421
|
-
* @param {object} node — row tree node
|
|
422
|
-
*/
|
|
423
|
-
_fillHeaderCell(el, node) {
|
|
424
|
-
const RH = PivotGrid.ROW_HEIGHT;
|
|
425
|
-
const C = this._colHeaderW;
|
|
426
|
-
const I = PivotGrid.INDENT;
|
|
427
|
-
|
|
428
|
-
const cell = document.createElement('div');
|
|
429
|
-
cell.className = 'pg-cell-header';
|
|
430
|
-
cell.style.cssText = `width:${C}px;height:${RH}px;padding-left:${8 + node.depth * I}px`;
|
|
431
|
-
|
|
432
|
-
if (node.children) {
|
|
433
|
-
const toggle = document.createElement('span');
|
|
434
|
-
toggle.className = 'pg-toggle' + (this.collapsed.has(node.code) ? ' collapsed' : '');
|
|
435
|
-
toggle.textContent = '▾';
|
|
436
|
-
toggle.addEventListener('click', (e) => {
|
|
437
|
-
e.stopPropagation();
|
|
438
|
-
this._toggleCollapse(node.code);
|
|
439
|
-
});
|
|
440
|
-
cell.appendChild(toggle);
|
|
441
|
-
} else {
|
|
442
|
-
const spacer = document.createElement('span');
|
|
443
|
-
spacer.className = 'pg-toggle-spacer';
|
|
444
|
-
cell.appendChild(spacer);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
const label = document.createElement('span');
|
|
448
|
-
label.className = `pg-label depth-${Math.min(node.depth, 2)}`;
|
|
449
|
-
label.textContent = node.value;
|
|
450
|
-
cell.appendChild(label);
|
|
451
|
-
|
|
452
|
-
el.appendChild(cell);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* Appends all value cells (one per column + one total) to a row.
|
|
457
|
-
* Each cell fires a drillthrough event on click.
|
|
458
|
-
* @param {Element} el — row element
|
|
459
|
-
* @param {object} node — row tree node
|
|
460
|
-
*/
|
|
461
|
-
_fillValueCells(el, node) {
|
|
462
|
-
const RH = PivotGrid.ROW_HEIGHT;
|
|
463
|
-
const W = PivotGrid.COL_W;
|
|
464
|
-
const cols = this.flatCols.length ? this.flatCols : this.colKeys;
|
|
465
|
-
|
|
466
|
-
for (const col of cols) {
|
|
467
|
-
const key = node.code + '||' + col.code;
|
|
468
|
-
const val = this.cells.get(key);
|
|
469
|
-
const cell = document.createElement('div');
|
|
470
|
-
cell.className = 'pg-cell'
|
|
471
|
-
+ (val == null ? ' empty' : '')
|
|
472
|
-
+ (col.isSubtotal ? ' subtotal' : '');
|
|
473
|
-
cell.style.cssText = `width:${W}px;height:${RH}px`;
|
|
474
|
-
cell.textContent = val != null ? this._fmt(val) : '—';
|
|
475
|
-
if (val != null) {
|
|
476
|
-
cell.addEventListener('click', () => this._emitDrillthrough(node, col.code, val));
|
|
477
|
-
}
|
|
478
|
-
el.appendChild(cell);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
const totalKey = node.code + '||__total__';
|
|
482
|
-
const totalVal = this.cells.get(totalKey) || 0;
|
|
483
|
-
const totalCell = document.createElement('div');
|
|
484
|
-
totalCell.className = 'pg-cell total';
|
|
485
|
-
totalCell.style.cssText = `width:${W}px;height:${RH}px`;
|
|
486
|
-
totalCell.textContent = this._fmt(totalVal);
|
|
487
|
-
totalCell.addEventListener('click', () => this._emitDrillthrough(node, '__total__', totalVal));
|
|
488
|
-
el.appendChild(totalCell);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/**
|
|
492
|
-
* Fills the grand total row: header label + column totals + overall grand total.
|
|
493
|
-
* @param {Element} el — row element
|
|
494
|
-
*/
|
|
495
|
-
_fillGrandTotalRow(el) {
|
|
496
|
-
const RH = PivotGrid.ROW_HEIGHT;
|
|
497
|
-
const C = this._colHeaderW;
|
|
498
|
-
const W = PivotGrid.COL_W;
|
|
499
|
-
const cols = this.flatCols.length ? this.flatCols : this.colKeys;
|
|
500
|
-
|
|
501
|
-
const headerCell = document.createElement('div');
|
|
502
|
-
headerCell.className = 'pg-cell-header';
|
|
503
|
-
headerCell.style.cssText = `width:${C}px;height:${RH}px;padding-left:8px`;
|
|
504
|
-
|
|
505
|
-
const spacer = document.createElement('span');
|
|
506
|
-
spacer.className = 'pg-toggle-spacer';
|
|
507
|
-
headerCell.appendChild(spacer);
|
|
508
|
-
|
|
509
|
-
const label = document.createElement('span');
|
|
510
|
-
label.className = 'pg-label depth-0';
|
|
511
|
-
label.textContent = this._labels.total || 'Total';
|
|
512
|
-
headerCell.appendChild(label);
|
|
513
|
-
el.appendChild(headerCell);
|
|
514
|
-
|
|
515
|
-
for (const col of cols) {
|
|
516
|
-
const key = '__grand__||' + col.code;
|
|
517
|
-
const val = this.cells.get(key) || 0;
|
|
518
|
-
const cell = document.createElement('div');
|
|
519
|
-
cell.className = 'pg-cell total' + (col.isSubtotal ? ' subtotal' : '');
|
|
520
|
-
cell.style.cssText = `width:${W}px;height:${RH}px`;
|
|
521
|
-
cell.textContent = this._fmt(val);
|
|
522
|
-
cell.addEventListener('click', () =>
|
|
523
|
-
this._emitDrillthrough({ isGrandTotal: true }, col.code, val)
|
|
524
|
-
);
|
|
525
|
-
el.appendChild(cell);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
const grandCell = document.createElement('div');
|
|
529
|
-
grandCell.className = 'pg-cell total grand-total-val';
|
|
530
|
-
grandCell.style.cssText = `width:${W}px;height:${RH}px`;
|
|
531
|
-
grandCell.textContent = this._fmt(this.grandTotal || 0);
|
|
532
|
-
grandCell.addEventListener('click', () =>
|
|
533
|
-
this._emitDrillthrough({ isGrandTotal: true }, '__total__', this.grandTotal)
|
|
534
|
-
);
|
|
535
|
-
el.appendChild(grandCell);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// ── Collapse columns ───────────────────────────────────────────────────────
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Toggles collapse state of a column group.
|
|
542
|
-
* When expanding, collapses direct children to avoid overloading the view.
|
|
543
|
-
* @param {string} code — column node code
|
|
544
|
-
*/
|
|
545
|
-
_toggleColCollapse(code) {
|
|
546
|
-
if (this.collapsedCols.has(code)) {
|
|
547
|
-
this.collapsedCols.delete(code);
|
|
548
|
-
// Collapse direct children
|
|
549
|
-
const node = this._findColNode(code);
|
|
550
|
-
if (node?.children) {
|
|
551
|
-
for (const child of node.children) {
|
|
552
|
-
if (child.children) this.collapsedCols.add(child.code);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
} else {
|
|
556
|
-
this.collapsedCols.add(code);
|
|
557
|
-
}
|
|
558
|
-
this._rebuildCols();
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/**
|
|
562
|
-
* Finds a column tree node by its code (recursive).
|
|
563
|
-
* @param {string} code
|
|
564
|
-
* @param {object[]} [nodes=this.colTree]
|
|
565
|
-
* @returns {object|null}
|
|
566
|
-
*/
|
|
567
|
-
_findColNode(code, nodes = this.colTree) {
|
|
568
|
-
if (!nodes) return null;
|
|
569
|
-
for (const node of nodes) {
|
|
570
|
-
if (node.code === code) return node;
|
|
571
|
-
const found = this._findColNode(code, node.children);
|
|
572
|
-
if (found) return found;
|
|
573
|
-
}
|
|
574
|
-
return null;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
/**
|
|
578
|
-
* Shows or hides subtotal columns in multi-level column mode.
|
|
579
|
-
* @param {boolean} show
|
|
580
|
-
*/
|
|
581
|
-
toggleSubtotals(show) {
|
|
582
|
-
this._hideSubtotals = !show;
|
|
583
|
-
this._rebuildCols();
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
/** Rebuilds flat columns and re-renders the column header and grid. */
|
|
587
|
-
_rebuildCols() {
|
|
588
|
-
this._buildFlatCols();
|
|
589
|
-
const cols = this.flatCols.length ? this.flatCols : this.colKeys;
|
|
590
|
-
this.totalWidth = this._colHeaderW + (cols.length + 1) * PivotGrid.COL_W;
|
|
591
|
-
this.virtualSpace.style.width = this.totalWidth + 'px';
|
|
592
|
-
this.scrollArea.style.top = this._headerHeight + 'px';
|
|
593
|
-
this.headerEl.remove();
|
|
594
|
-
this._mountColHeader();
|
|
595
|
-
this.headerEl.style.transform = `translateX(-${this.scrollArea.scrollLeft}px)`;
|
|
596
|
-
this._redraw();
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// ── Redraw ─────────────────────────────────────────────────────────────────
|
|
600
|
-
|
|
601
|
-
/** Clears all rendered rows and re-renders the visible viewport. */
|
|
602
|
-
_redraw() {
|
|
603
|
-
this.virtualSpace.style.height =
|
|
604
|
-
this.flatRows.length * PivotGrid.ROW_HEIGHT + 'px';
|
|
605
|
-
|
|
606
|
-
for (const [, el] of this.rendered) {
|
|
607
|
-
this.virtualSpace.removeChild(el);
|
|
608
|
-
this._recycleRow(el);
|
|
609
|
-
}
|
|
610
|
-
this.rendered.clear();
|
|
611
|
-
this._renderVisible();
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// ── Scroll ─────────────────────────────────────────────────────────────────
|
|
615
|
-
|
|
616
|
-
/** Binds the scroll event — syncs header position and triggers virtual render. */
|
|
617
|
-
_bindScroll() {
|
|
618
|
-
let ticking = false;
|
|
619
|
-
this.scrollArea.addEventListener('scroll', () => {
|
|
620
|
-
this.headerEl.style.transform =
|
|
621
|
-
`translateX(-${this.scrollArea.scrollLeft}px)`;
|
|
622
|
-
|
|
623
|
-
if (!ticking) {
|
|
624
|
-
requestAnimationFrame(() => {
|
|
625
|
-
this._renderVisible();
|
|
626
|
-
ticking = false;
|
|
627
|
-
});
|
|
628
|
-
ticking = true;
|
|
629
|
-
}
|
|
630
|
-
});
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
// ── Drillthrough ───────────────────────────────────────────────────────────
|
|
634
|
-
|
|
635
|
-
/**
|
|
636
|
-
* Builds a context object from the clicked cell and dispatches a
|
|
637
|
-
* custom "drillthrough" event on the container.
|
|
638
|
-
* @param {object} node — row node (or { isGrandTotal: true })
|
|
639
|
-
* @param {string} colCode — column code or "__total__"
|
|
640
|
-
* @param {number} value — aggregated cell value
|
|
641
|
-
*/
|
|
642
|
-
_emitDrillthrough(node, colCode, value) {
|
|
643
|
-
const context = {};
|
|
644
|
-
|
|
645
|
-
if (!node.isGrandTotal) {
|
|
646
|
-
const chain = this._getNodeChain(node);
|
|
647
|
-
for (let i = 0; i < chain.length; i++) {
|
|
648
|
-
context[this.rows[i]] = chain[i].value;
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
if (colCode !== '__total__') {
|
|
653
|
-
const parts = colCode.split('→');
|
|
654
|
-
for (let i = 0; i < parts.length; i++) {
|
|
655
|
-
if (this.columns[i]) context[this.columns[i]] = parts[i];
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// context holds logical field names — provider handles the mapping
|
|
660
|
-
this.container.dispatchEvent(new CustomEvent('drillthrough', {
|
|
661
|
-
bubbles: true,
|
|
662
|
-
detail: { context, value },
|
|
663
|
-
}));
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/**
|
|
667
|
-
* Walks flatRows upward to build the ancestor chain for a given node.
|
|
668
|
-
* Used to construct the drillthrough context.
|
|
669
|
-
* @param {object} node
|
|
670
|
-
* @returns {object[]}
|
|
671
|
-
*/
|
|
672
|
-
_getNodeChain(node) {
|
|
673
|
-
const chain = [node];
|
|
674
|
-
if (node.depth === 0) return chain;
|
|
675
|
-
|
|
676
|
-
const idx = this.flatRows.indexOf(node);
|
|
677
|
-
for (let i = idx - 1; i >= 0; i--) {
|
|
678
|
-
const n = this.flatRows[i];
|
|
679
|
-
if (n.isGrandTotal) continue;
|
|
680
|
-
if (n.depth === node.depth - 1) {
|
|
681
|
-
chain.unshift(n);
|
|
682
|
-
if (n.depth === 0) break;
|
|
683
|
-
node = n;
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
return chain;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
690
|
-
|
|
691
|
-
/** Formats a numeric value with locale-aware thousand separators. */
|
|
692
|
-
_fmt(val) {
|
|
693
|
-
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(val);
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// ── Public API ──────────────────────────────────────────────────────────
|
|
697
|
-
|
|
698
|
-
/**
|
|
699
|
-
* Binds mousedown drag on the resize handle to adjust the row-label column width.
|
|
700
|
-
* @param {Element} handle
|
|
701
|
-
*/
|
|
702
|
-
_bindResizeHandle(handle) {
|
|
703
|
-
handle.addEventListener('mousedown', (e) => {
|
|
704
|
-
e.preventDefault();
|
|
705
|
-
const startX = e.clientX;
|
|
706
|
-
const startW = this._colHeaderW;
|
|
707
|
-
|
|
708
|
-
const onMove = (mv) => {
|
|
709
|
-
//const newW = Math.max(80, startW + mv.clientX - startX);
|
|
710
|
-
const newW = Math.max(PivotGrid.COL_HEADER_W, startW + mv.clientX - startX);
|
|
711
|
-
this._colHeaderW = newW;
|
|
712
|
-
this._rebuild();
|
|
713
|
-
};
|
|
714
|
-
|
|
715
|
-
const onUp = () => {
|
|
716
|
-
document.removeEventListener('mousemove', onMove);
|
|
717
|
-
document.removeEventListener('mouseup', onUp);
|
|
718
|
-
};
|
|
719
|
-
|
|
720
|
-
document.addEventListener('mousemove', onMove);
|
|
721
|
-
document.addEventListener('mouseup', onUp);
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/** Full rebuild after column width change: remounts header and re-renders rows. */
|
|
726
|
-
_rebuild() {
|
|
727
|
-
this.headerEl?.remove();
|
|
728
|
-
this.headerEl = null;
|
|
729
|
-
this._buildFlatCols();
|
|
730
|
-
this._mountColHeader();
|
|
731
|
-
for (const [, el] of this.rendered) this._recycleRow(el);
|
|
732
|
-
this.rendered.clear();
|
|
733
|
-
this._renderVisible();
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/** Instant measure/function change — no aggregate recalculation. */
|
|
737
|
-
// setMeasure(measure, func) {
|
|
738
|
-
// this._measureKey = measure + '_' + func;
|
|
739
|
-
// for (const [, el] of this.rendered) this._recycleRow(el);
|
|
740
|
-
// this.rendered.clear();
|
|
741
|
-
// this._renderVisible();
|
|
742
|
-
// }
|
|
743
|
-
|
|
744
|
-
/**
|
|
745
|
-
* Replaces the current aggregation result and re-renders the grid.
|
|
746
|
-
* Top-level column groups are collapsed automatically.
|
|
747
|
-
* @param {object} result
|
|
748
|
-
* @param {object} [options]
|
|
749
|
-
* @param {string[]} [options.rows]
|
|
750
|
-
* @param {string[]} [options.columns]
|
|
751
|
-
* @param {string} [options.measure]
|
|
752
|
-
* @param {object} [options.fieldDefs]
|
|
753
|
-
*/
|
|
754
|
-
setResult(result, { rows, columns, measure, fieldDefs } = {}) {
|
|
755
|
-
if (rows) this.rows = rows;
|
|
756
|
-
if (columns) this.columns = columns;
|
|
757
|
-
if (measure) this.measure = measure;
|
|
758
|
-
if (fieldDefs) this.fieldDefs = fieldDefs;
|
|
759
|
-
this.collapsedCols.clear();
|
|
760
|
-
this._applyResult(result);
|
|
761
|
-
|
|
762
|
-
// Collapse all top-level column groups
|
|
763
|
-
if (this.colTree) {
|
|
764
|
-
for (const node of this.colTree) {
|
|
765
|
-
if (node.children) this.collapsedCols.add(node.code);
|
|
766
|
-
}
|
|
767
|
-
this._buildFlatCols();
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
const cols = this.flatCols.length ? this.flatCols : this.colKeys;
|
|
771
|
-
this.totalWidth = this._colHeaderW + (cols.length + 1) * PivotGrid.COL_W;
|
|
772
|
-
this.virtualSpace.style.width = this.totalWidth + 'px';
|
|
773
|
-
this.scrollArea.style.top = this._headerHeight + 'px';
|
|
774
|
-
|
|
775
|
-
this.headerEl.remove();
|
|
776
|
-
this._mountColHeader();
|
|
777
|
-
this._redraw();
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
/** Collapses all row nodes and redraws. */
|
|
781
|
-
collapseAll() {
|
|
782
|
-
const walk = (nodes) => {
|
|
783
|
-
if (!nodes) return;
|
|
784
|
-
for (const node of nodes) {
|
|
785
|
-
if (node.children) {
|
|
786
|
-
this.collapsed.add(node.code);
|
|
787
|
-
walk(node.children);
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
};
|
|
791
|
-
walk(this.tree);
|
|
792
|
-
this._buildFlatRows();
|
|
793
|
-
this._redraw();
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
/**
|
|
797
|
-
* Detects the maximum scrollable height supported by the current browser.
|
|
798
|
-
* Used to cap MAX_FLAT_ROWS and prevent invisible rows.
|
|
799
|
-
* @returns {number}
|
|
800
|
-
*/
|
|
801
|
-
static _detectMaxHeight() {
|
|
802
|
-
const el = document.createElement('div');
|
|
803
|
-
el.style.cssText = 'position:fixed;visibility:hidden;';
|
|
804
|
-
document.body.appendChild(el);
|
|
805
|
-
let h = 1_000_000;
|
|
806
|
-
while (h < 100_000_000) {
|
|
807
|
-
el.style.height = h + 'px';
|
|
808
|
-
if (el.offsetHeight < h) break;
|
|
809
|
-
h *= 2;
|
|
810
|
-
}
|
|
811
|
-
el.remove();
|
|
812
|
-
return h / 2;
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
static MAX_FLAT_ROWS = Math.floor(PivotGrid._detectMaxHeight() / PivotGrid.ROW_HEIGHT);
|
|
816
|
-
|
|
817
|
-
/**
|
|
818
|
-
* Shows a confirm dialog when the expanded row count exceeds MAX_FLAT_ROWS.
|
|
819
|
-
* @param {number} count — total rows after expand
|
|
820
|
-
* @param {Function} onConfirm — called if user confirms
|
|
821
|
-
* @param {Function} [onCancel] — called if user cancels
|
|
822
|
-
*/
|
|
823
|
-
_confirmLargeExpand(count, onConfirm, onCancel) {
|
|
824
|
-
const millions = (count / 1_000_000).toFixed(1);
|
|
825
|
-
const msg = (this._labels.confirmLargeExpand || 'Too many rows (~{millions}M). Click OK to expand anyway.').replace('{millions}', millions);
|
|
826
|
-
if (window.confirm(msg)) onConfirm();
|
|
827
|
-
else onCancel?.();
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
/**
|
|
831
|
-
* Toggles a row node's collapsed state and redraws.
|
|
832
|
-
* Prompts confirmation if the resulting row count exceeds MAX_FLAT_ROWS.
|
|
833
|
-
* @param {string} code — row node code
|
|
834
|
-
*/
|
|
835
|
-
_toggleCollapse(code) {
|
|
836
|
-
const wasCollapsed = this.collapsed.has(code);
|
|
837
|
-
if (wasCollapsed) this.collapsed.delete(code);
|
|
838
|
-
else this.collapsed.add(code);
|
|
839
|
-
|
|
840
|
-
this._buildFlatRows();
|
|
841
|
-
|
|
842
|
-
if (wasCollapsed && this.flatRows.length > PivotGrid.MAX_FLAT_ROWS) {
|
|
843
|
-
this._confirmLargeExpand(this.flatRows.length,
|
|
844
|
-
() => this._redraw(),
|
|
845
|
-
() => {
|
|
846
|
-
this.collapsed.add(code);
|
|
847
|
-
this._buildFlatRows();
|
|
848
|
-
}
|
|
849
|
-
);
|
|
850
|
-
return;
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
this._redraw();
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
/**
|
|
857
|
-
* Expands rows up to the given depth. Clicking a depth level again collapses it.
|
|
858
|
-
* @param {number} depth — 1-based depth level
|
|
859
|
-
*/
|
|
860
|
-
expandToDepth(depth) {
|
|
861
|
-
const nodesAtDepth = [];
|
|
862
|
-
const walk = (nodes) => {
|
|
863
|
-
for (const node of nodes) {
|
|
864
|
-
if (!node.children) continue;
|
|
865
|
-
if (node.depth < depth - 1) {
|
|
866
|
-
this.collapsed.delete(node.code);
|
|
867
|
-
walk(node.children);
|
|
868
|
-
} else if (node.depth === depth - 1) {
|
|
869
|
-
nodesAtDepth.push(node);
|
|
870
|
-
// leave children untouched
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
};
|
|
874
|
-
walk(this.tree);
|
|
875
|
-
|
|
876
|
-
const anyExpanded = nodesAtDepth.some(n => !this.collapsed.has(n.code));
|
|
877
|
-
for (const n of nodesAtDepth) {
|
|
878
|
-
if (anyExpanded) this.collapsed.add(n.code);
|
|
879
|
-
else this.collapsed.delete(n.code);
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
this._buildFlatRows();
|
|
883
|
-
this._redraw();
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
/** Expands all row nodes. Prompts confirmation if row count exceeds MAX_FLAT_ROWS. */
|
|
887
|
-
expandAll() {
|
|
888
|
-
this.collapsed.clear();
|
|
889
|
-
this._buildFlatRows();
|
|
890
|
-
|
|
891
|
-
if (this.flatRows.length > PivotGrid.MAX_FLAT_ROWS) {
|
|
892
|
-
this._confirmLargeExpand(this.flatRows.length, () => this._redraw());
|
|
893
|
-
return;
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
this._redraw();
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
/** Expands all column groups. */
|
|
900
|
-
expandAllCols() {
|
|
901
|
-
this.collapsedCols.clear();
|
|
902
|
-
this._rebuildCols();
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
/** Collapses all column groups. */
|
|
906
|
-
collapseAllCols() {
|
|
907
|
-
const walk = (nodes) => {
|
|
908
|
-
if (!nodes) return;
|
|
909
|
-
for (const node of nodes) {
|
|
910
|
-
if (node.children) {
|
|
911
|
-
this.collapsedCols.add(node.code);
|
|
912
|
-
walk(node.children);
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
};
|
|
916
|
-
walk(this.colTree);
|
|
917
|
-
this._rebuildCols();
|
|
918
|
-
}
|
|
919
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* PivotGrid — vanilla JS
|
|
3
|
+
* v0.3 — hierarchical columns, absolute-positioned headers
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class PivotGrid {
|
|
7
|
+
|
|
8
|
+
static ROW_HEIGHT = 24;
|
|
9
|
+
static HEADER_HEIGHT = 32;
|
|
10
|
+
static COL_HEADER_W = 200;
|
|
11
|
+
static COL_W = 150;
|
|
12
|
+
static INDENT = 16;
|
|
13
|
+
static BUFFER = 5;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} options
|
|
17
|
+
* @param {Element} options.container — DOM element to render into
|
|
18
|
+
* @param {object} options.result — aggregation result from Aggregator.build()
|
|
19
|
+
* @param {string[]} options.rows — active row dimension names
|
|
20
|
+
* @param {string[]} options.columns — active column dimension names
|
|
21
|
+
* @param {string} options.measure — active measure name
|
|
22
|
+
* @param {object} [options.fieldDefs={}] — field definitions (label, title, sortKey)
|
|
23
|
+
* @param {object} [options.labels={}] — translated UI strings (total, confirmLargeExpand)
|
|
24
|
+
*/
|
|
25
|
+
constructor({ container, result, rows, columns, measure, fieldDefs = {}, labels = {} }) {
|
|
26
|
+
this.container = container;
|
|
27
|
+
this.rows = rows;
|
|
28
|
+
this.columns = columns;
|
|
29
|
+
this.measure = measure;
|
|
30
|
+
this.fieldDefs = fieldDefs;
|
|
31
|
+
this._labels = labels;
|
|
32
|
+
this._measureKey = measure + '_sum'; // updated via setMeasure()
|
|
33
|
+
this._colHeaderW = PivotGrid.COL_HEADER_W;
|
|
34
|
+
this._hideSubtotals = false;
|
|
35
|
+
|
|
36
|
+
this.collapsed = new Set();
|
|
37
|
+
this.collapsedCols = new Set();
|
|
38
|
+
this.rowPool = [];
|
|
39
|
+
this.rendered = new Map();
|
|
40
|
+
|
|
41
|
+
this._applyResult(result);
|
|
42
|
+
this._mount();
|
|
43
|
+
this._renderVisible();
|
|
44
|
+
this._bindScroll();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Apply Result ────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** Applies an aggregation result object and rebuilds flat rows/cols. */
|
|
50
|
+
_applyResult(result) {
|
|
51
|
+
this.cells = result.cells;
|
|
52
|
+
this.colTree = result.colTree;
|
|
53
|
+
this.colKeys = result.colKeys;
|
|
54
|
+
this.tree = result.tree;
|
|
55
|
+
this.grandTotal = result.grandTotal;
|
|
56
|
+
if (result.measureKey) this._measureKey = result.measureKey;
|
|
57
|
+
this._buildFlatCols();
|
|
58
|
+
this._buildFlatRows();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Flat list of visible columns ────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** Builds this.flatCols — the ordered list of visible leaf/subtotal column entries. */
|
|
64
|
+
_buildFlatCols() {
|
|
65
|
+
if (!this.colTree || !this.colTree.length) {
|
|
66
|
+
this.flatCols = [];
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const result = [];
|
|
71
|
+
const multiLevel = this.columns && this.columns.length > 1;
|
|
72
|
+
|
|
73
|
+
const walk = (nodes) => {
|
|
74
|
+
for (const node of nodes) {
|
|
75
|
+
if (node.children) {
|
|
76
|
+
if (this.collapsedCols.has(node.code)) {
|
|
77
|
+
result.push({ code: node.code, label: node.value, isSubtotal: true, collapsed: true });
|
|
78
|
+
} else {
|
|
79
|
+
walk(node.children);
|
|
80
|
+
if (multiLevel && !this._hideSubtotals) {
|
|
81
|
+
result.push({ code: node.code, label: '∑', isSubtotal: true, collapsed: false });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
result.push({ code: node.code, label: node.value, isSubtotal: false });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
walk(this.colTree);
|
|
91
|
+
this.flatCols = result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Number of flatCols occupied by a node (recursive, respects collapsed state).
|
|
96
|
+
*/
|
|
97
|
+
_getGroupSpan(node) {
|
|
98
|
+
if (!node.children || this.collapsedCols.has(node.code)) return 1;
|
|
99
|
+
const multiLevel = this.columns && this.columns.length > 1;
|
|
100
|
+
let span = (multiLevel && !this._hideSubtotals) ? 1 : 0;
|
|
101
|
+
for (const child of node.children) {
|
|
102
|
+
span += this._getGroupSpan(child);
|
|
103
|
+
}
|
|
104
|
+
return span;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Depth of the column tree, accounting for collapsed nodes.
|
|
109
|
+
*/
|
|
110
|
+
_colTreeDepth() {
|
|
111
|
+
if (!this.colTree || !this.colTree.length) return 1;
|
|
112
|
+
const walk = (nodes) => {
|
|
113
|
+
let max = 0;
|
|
114
|
+
for (const node of nodes) {
|
|
115
|
+
if (node.children && !this.collapsedCols.has(node.code)) {
|
|
116
|
+
max = Math.max(max, 1 + walk(node.children));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return max;
|
|
120
|
+
};
|
|
121
|
+
return 1 + walk(this.colTree);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Flat list of strings ──────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/** Builds this.flatRows — flat array of visible row nodes including grand total. */
|
|
127
|
+
_buildFlatRows() {
|
|
128
|
+
this.flatRows = [];
|
|
129
|
+
const walk = (nodes) => {
|
|
130
|
+
for (const node of nodes) {
|
|
131
|
+
this.flatRows.push(node);
|
|
132
|
+
if (node.children && !this.collapsed.has(node.code)) {
|
|
133
|
+
walk(node.children);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
if (this.tree) walk(this.tree);
|
|
138
|
+
this.flatRows.push({ isGrandTotal: true });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Total header height in px (HEADER_HEIGHT × column tree depth). */
|
|
142
|
+
get _headerHeight() {
|
|
143
|
+
return PivotGrid.HEADER_HEIGHT * this._colTreeDepth();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Mounting ───────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/** Clears the container and mounts the column header + scroll area. */
|
|
149
|
+
_mount() {
|
|
150
|
+
this.container.innerHTML = '';
|
|
151
|
+
this.container.classList.add('pg-root');
|
|
152
|
+
|
|
153
|
+
const cols = this.flatCols.length ? this.flatCols : this.colKeys;
|
|
154
|
+
this.totalWidth = this._colHeaderW + (cols.length + 1) * PivotGrid.COL_W;
|
|
155
|
+
|
|
156
|
+
this._mountColHeader();
|
|
157
|
+
this._mountScrollArea();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Builds and appends the absolute-positioned column header element. */
|
|
161
|
+
_mountColHeader() {
|
|
162
|
+
const RH = PivotGrid.HEADER_HEIGHT;
|
|
163
|
+
const C = this._colHeaderW;
|
|
164
|
+
const W = PivotGrid.COL_W;
|
|
165
|
+
const totalDepth = this._colTreeDepth();
|
|
166
|
+
const H = RH * totalDepth;
|
|
167
|
+
|
|
168
|
+
this.headerEl = document.createElement('div');
|
|
169
|
+
this.headerEl.className = 'pg-col-header';
|
|
170
|
+
this.headerEl.style.cssText = `
|
|
171
|
+
position: absolute; top: 0; left: 0;
|
|
172
|
+
width: ${this.totalWidth}px; height: ${H}px;
|
|
173
|
+
background: #fafafa; border-bottom: 1px solid #d0d0d0; z-index: 10;
|
|
174
|
+
`;
|
|
175
|
+
|
|
176
|
+
// Row label — full height
|
|
177
|
+
const rowLabelCell = this._absCell({
|
|
178
|
+
x: 0, y: 0, w: C, h: H,
|
|
179
|
+
text: '',
|
|
180
|
+
cls: 'row-label',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
this.rows.forEach((row, i) => {
|
|
184
|
+
const span = document.createElement('span');
|
|
185
|
+
const def = (this.fieldDefs || {})[row] || {};
|
|
186
|
+
span.textContent = def.title || def.label || row;
|
|
187
|
+
span.style.cssText = 'cursor:pointer; padding: 0 2px;';
|
|
188
|
+
span.title = `Expand to "${row}"`;
|
|
189
|
+
if (i < this.rows.length - 1) {
|
|
190
|
+
span.addEventListener('click', () => this.expandToDepth(i + 1));
|
|
191
|
+
} else {
|
|
192
|
+
span.style.cursor = 'default';
|
|
193
|
+
}
|
|
194
|
+
if (i > 0) {
|
|
195
|
+
const sep = document.createElement('span');
|
|
196
|
+
sep.textContent = ' › ';
|
|
197
|
+
sep.style.color = '#ccc';
|
|
198
|
+
rowLabelCell.appendChild(sep);
|
|
199
|
+
}
|
|
200
|
+
rowLabelCell.appendChild(span);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Resize handle for the first column
|
|
204
|
+
const resizeHandle = document.createElement('div');
|
|
205
|
+
resizeHandle.className = 'pg-col-resize-handle';
|
|
206
|
+
resizeHandle.style.cssText = `
|
|
207
|
+
position: absolute; top: 0; left: ${C - 4}px;
|
|
208
|
+
width: 8px; height: ${H}px;
|
|
209
|
+
cursor: col-resize; z-index: 20;
|
|
210
|
+
`;
|
|
211
|
+
this.headerEl.appendChild(resizeHandle);
|
|
212
|
+
this._bindResizeHandle(resizeHandle);
|
|
213
|
+
|
|
214
|
+
// Columns
|
|
215
|
+
if (this.colTree && this.colTree.length) {
|
|
216
|
+
let offset = 0;
|
|
217
|
+
for (const node of this.colTree) {
|
|
218
|
+
offset = this._renderColNode(node, 0, offset, totalDepth);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Total — full height
|
|
223
|
+
const cols = this.flatCols.length ? this.flatCols : this.colKeys;
|
|
224
|
+
this._absCell({
|
|
225
|
+
x: C + cols.length * W,
|
|
226
|
+
y: 0,
|
|
227
|
+
w: W,
|
|
228
|
+
h: H,
|
|
229
|
+
text: this._labels.total || 'Total',
|
|
230
|
+
cls: 'total-col',
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
this.container.appendChild(this.headerEl);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Recursively renders a column header cell with absolute positioning.
|
|
238
|
+
* Returns the new leafOffset.
|
|
239
|
+
*/
|
|
240
|
+
_renderColNode(node, level, leafOffset, totalDepth) {
|
|
241
|
+
const RH = PivotGrid.HEADER_HEIGHT;
|
|
242
|
+
const C = this._colHeaderW;
|
|
243
|
+
const W = PivotGrid.COL_W;
|
|
244
|
+
const collapsed = this.collapsedCols.has(node.code);
|
|
245
|
+
const isLeaf = !node.children;
|
|
246
|
+
const span = this._getGroupSpan(node);
|
|
247
|
+
|
|
248
|
+
// Листья и свёрнутые растягиваются до конца заголовка
|
|
249
|
+
const cellH = (isLeaf || collapsed)
|
|
250
|
+
? (totalDepth - level) * RH
|
|
251
|
+
: RH;
|
|
252
|
+
|
|
253
|
+
const cls = collapsed ? 'subtotal-col'
|
|
254
|
+
: isLeaf ? ''
|
|
255
|
+
: 'pg-col-header-group';
|
|
256
|
+
|
|
257
|
+
const cell = this._absCell({
|
|
258
|
+
x: C + leafOffset * W,
|
|
259
|
+
y: level * RH,
|
|
260
|
+
w: span * W,
|
|
261
|
+
h: cellH,
|
|
262
|
+
text: node.value,
|
|
263
|
+
cls,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Collapse toggle button
|
|
267
|
+
if (node.children) {
|
|
268
|
+
const toggle = document.createElement('span');
|
|
269
|
+
toggle.className = 'pg-toggle' + (collapsed ? ' collapsed' : '');
|
|
270
|
+
toggle.textContent = '▾';
|
|
271
|
+
toggle.addEventListener('click', (e) => {
|
|
272
|
+
e.stopPropagation();
|
|
273
|
+
this._toggleColCollapse(node.code);
|
|
274
|
+
});
|
|
275
|
+
cell.insertBefore(toggle, cell.firstChild);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!isLeaf && !collapsed) {
|
|
279
|
+
// Render children
|
|
280
|
+
let childOffset = leafOffset;
|
|
281
|
+
for (const child of node.children) {
|
|
282
|
+
childOffset = this._renderColNode(child, level + 1, childOffset, totalDepth);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ∑ for group — starts one level down, stretches to the end
|
|
286
|
+
if (this.columns && this.columns.length > 1 && !this._hideSubtotals) {
|
|
287
|
+
const subtotalH = (totalDepth - level - 1) * RH;
|
|
288
|
+
if (subtotalH > 0) {
|
|
289
|
+
this._absCell({
|
|
290
|
+
x: C + (leafOffset + span - 1) * W,
|
|
291
|
+
y: (level + 1) * RH,
|
|
292
|
+
w: W,
|
|
293
|
+
h: subtotalH,
|
|
294
|
+
text: '∑',
|
|
295
|
+
cls: 'subtotal-col',
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return leafOffset + span;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Creates and appends an absolutely positioned cell to headerEl.
|
|
306
|
+
*/
|
|
307
|
+
_absCell({ x, y, w, h, text, cls }) {
|
|
308
|
+
const cell = document.createElement('div');
|
|
309
|
+
cell.className = 'pg-col-header-cell' + (cls ? ' ' + cls : '');
|
|
310
|
+
cell.style.cssText = `
|
|
311
|
+
position: absolute;
|
|
312
|
+
left: ${x}px; top: ${y}px;
|
|
313
|
+
width: ${w}px; height: ${h}px;
|
|
314
|
+
box-sizing: border-box;
|
|
315
|
+
`;
|
|
316
|
+
cell.textContent = text;
|
|
317
|
+
this.headerEl.appendChild(cell);
|
|
318
|
+
return cell;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Creates the scroll area div and the virtual space div inside it. */
|
|
322
|
+
_mountScrollArea() {
|
|
323
|
+
const H = this._headerHeight;
|
|
324
|
+
|
|
325
|
+
this.scrollArea = document.createElement('div');
|
|
326
|
+
this.scrollArea.className = 'pg-scroll';
|
|
327
|
+
this.scrollArea.style.top = H + 'px';
|
|
328
|
+
this.container.appendChild(this.scrollArea);
|
|
329
|
+
|
|
330
|
+
this.virtualSpace = document.createElement('div');
|
|
331
|
+
this.virtualSpace.style.cssText = `
|
|
332
|
+
position: relative;
|
|
333
|
+
width: ${this.totalWidth}px;
|
|
334
|
+
height: ${this.flatRows.length * PivotGrid.ROW_HEIGHT}px;
|
|
335
|
+
`;
|
|
336
|
+
this.scrollArea.appendChild(this.virtualSpace);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Virtualization ──────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Renders only the rows currently in the viewport (+ BUFFER rows above/below).
|
|
343
|
+
* Recycles rows that have scrolled out of view back into the pool.
|
|
344
|
+
*/
|
|
345
|
+
_renderVisible() {
|
|
346
|
+
const viewH = this.scrollArea.clientHeight;
|
|
347
|
+
const scrollTop = this.scrollArea.scrollTop;
|
|
348
|
+
const RH = PivotGrid.ROW_HEIGHT;
|
|
349
|
+
const BUF = PivotGrid.BUFFER;
|
|
350
|
+
|
|
351
|
+
const first = Math.max(0, Math.floor(scrollTop / RH) - BUF);
|
|
352
|
+
const last = Math.min(
|
|
353
|
+
this.flatRows.length - 1,
|
|
354
|
+
Math.ceil((scrollTop + viewH) / RH) + BUF
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
for (const [idx, el] of this.rendered) {
|
|
358
|
+
if (idx < first || idx > last) {
|
|
359
|
+
this.virtualSpace.removeChild(el);
|
|
360
|
+
this._recycleRow(el);
|
|
361
|
+
this.rendered.delete(idx);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
for (let i = first; i <= last; i++) {
|
|
366
|
+
if (this.rendered.has(i)) continue;
|
|
367
|
+
const el = this._acquireRow();
|
|
368
|
+
this._fillRow(el, this.flatRows[i], i);
|
|
369
|
+
this.virtualSpace.appendChild(el);
|
|
370
|
+
this.rendered.set(i, el);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Returns a recycled or newly created row element. */
|
|
375
|
+
_acquireRow() {
|
|
376
|
+
if (this.rowPool.length) {
|
|
377
|
+
const el = this.rowPool.pop();
|
|
378
|
+
el.className = 'pg-row';
|
|
379
|
+
el.removeAttribute('style');
|
|
380
|
+
el.innerHTML = '';
|
|
381
|
+
return el;
|
|
382
|
+
}
|
|
383
|
+
const el = document.createElement('div');
|
|
384
|
+
el.className = 'pg-row';
|
|
385
|
+
return el;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Returns a row element to the pool for reuse. */
|
|
389
|
+
_recycleRow(el) {
|
|
390
|
+
this.rowPool.push(el);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── Filling the Line ──────────────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Fills a row element with header cell and value cells for the given node.
|
|
397
|
+
* @param {Element} el — row element from the pool
|
|
398
|
+
* @param {object} node — flat row node (or { isGrandTotal: true })
|
|
399
|
+
* @param {number} idx — row index in flatRows
|
|
400
|
+
*/
|
|
401
|
+
_fillRow(el, node, idx) {
|
|
402
|
+
const RH = PivotGrid.ROW_HEIGHT;
|
|
403
|
+
el.style.top = idx * RH + 'px';
|
|
404
|
+
el.style.width = this.totalWidth + 'px';
|
|
405
|
+
el.style.height = RH + 'px';
|
|
406
|
+
|
|
407
|
+
if (node.isGrandTotal) {
|
|
408
|
+
el.classList.add('grand-total');
|
|
409
|
+
this._fillGrandTotalRow(el);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
el.style.background = idx % 2 === 0 ? '#ffffff' : '#fcfcfc';
|
|
414
|
+
this._fillHeaderCell(el, node);
|
|
415
|
+
this._fillValueCells(el, node);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Appends the sticky left header cell (label + expand/collapse toggle) to a row.
|
|
420
|
+
* @param {Element} el — row element
|
|
421
|
+
* @param {object} node — row tree node
|
|
422
|
+
*/
|
|
423
|
+
_fillHeaderCell(el, node) {
|
|
424
|
+
const RH = PivotGrid.ROW_HEIGHT;
|
|
425
|
+
const C = this._colHeaderW;
|
|
426
|
+
const I = PivotGrid.INDENT;
|
|
427
|
+
|
|
428
|
+
const cell = document.createElement('div');
|
|
429
|
+
cell.className = 'pg-cell-header';
|
|
430
|
+
cell.style.cssText = `width:${C}px;height:${RH}px;padding-left:${8 + node.depth * I}px`;
|
|
431
|
+
|
|
432
|
+
if (node.children) {
|
|
433
|
+
const toggle = document.createElement('span');
|
|
434
|
+
toggle.className = 'pg-toggle' + (this.collapsed.has(node.code) ? ' collapsed' : '');
|
|
435
|
+
toggle.textContent = '▾';
|
|
436
|
+
toggle.addEventListener('click', (e) => {
|
|
437
|
+
e.stopPropagation();
|
|
438
|
+
this._toggleCollapse(node.code);
|
|
439
|
+
});
|
|
440
|
+
cell.appendChild(toggle);
|
|
441
|
+
} else {
|
|
442
|
+
const spacer = document.createElement('span');
|
|
443
|
+
spacer.className = 'pg-toggle-spacer';
|
|
444
|
+
cell.appendChild(spacer);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const label = document.createElement('span');
|
|
448
|
+
label.className = `pg-label depth-${Math.min(node.depth, 2)}`;
|
|
449
|
+
label.textContent = node.value;
|
|
450
|
+
cell.appendChild(label);
|
|
451
|
+
|
|
452
|
+
el.appendChild(cell);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Appends all value cells (one per column + one total) to a row.
|
|
457
|
+
* Each cell fires a drillthrough event on click.
|
|
458
|
+
* @param {Element} el — row element
|
|
459
|
+
* @param {object} node — row tree node
|
|
460
|
+
*/
|
|
461
|
+
_fillValueCells(el, node) {
|
|
462
|
+
const RH = PivotGrid.ROW_HEIGHT;
|
|
463
|
+
const W = PivotGrid.COL_W;
|
|
464
|
+
const cols = this.flatCols.length ? this.flatCols : this.colKeys;
|
|
465
|
+
|
|
466
|
+
for (const col of cols) {
|
|
467
|
+
const key = node.code + '||' + col.code;
|
|
468
|
+
const val = this.cells.get(key);
|
|
469
|
+
const cell = document.createElement('div');
|
|
470
|
+
cell.className = 'pg-cell'
|
|
471
|
+
+ (val == null ? ' empty' : '')
|
|
472
|
+
+ (col.isSubtotal ? ' subtotal' : '');
|
|
473
|
+
cell.style.cssText = `width:${W}px;height:${RH}px`;
|
|
474
|
+
cell.textContent = val != null ? this._fmt(val) : '—';
|
|
475
|
+
if (val != null) {
|
|
476
|
+
cell.addEventListener('click', () => this._emitDrillthrough(node, col.code, val));
|
|
477
|
+
}
|
|
478
|
+
el.appendChild(cell);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const totalKey = node.code + '||__total__';
|
|
482
|
+
const totalVal = this.cells.get(totalKey) || 0;
|
|
483
|
+
const totalCell = document.createElement('div');
|
|
484
|
+
totalCell.className = 'pg-cell total';
|
|
485
|
+
totalCell.style.cssText = `width:${W}px;height:${RH}px`;
|
|
486
|
+
totalCell.textContent = this._fmt(totalVal);
|
|
487
|
+
totalCell.addEventListener('click', () => this._emitDrillthrough(node, '__total__', totalVal));
|
|
488
|
+
el.appendChild(totalCell);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Fills the grand total row: header label + column totals + overall grand total.
|
|
493
|
+
* @param {Element} el — row element
|
|
494
|
+
*/
|
|
495
|
+
_fillGrandTotalRow(el) {
|
|
496
|
+
const RH = PivotGrid.ROW_HEIGHT;
|
|
497
|
+
const C = this._colHeaderW;
|
|
498
|
+
const W = PivotGrid.COL_W;
|
|
499
|
+
const cols = this.flatCols.length ? this.flatCols : this.colKeys;
|
|
500
|
+
|
|
501
|
+
const headerCell = document.createElement('div');
|
|
502
|
+
headerCell.className = 'pg-cell-header';
|
|
503
|
+
headerCell.style.cssText = `width:${C}px;height:${RH}px;padding-left:8px`;
|
|
504
|
+
|
|
505
|
+
const spacer = document.createElement('span');
|
|
506
|
+
spacer.className = 'pg-toggle-spacer';
|
|
507
|
+
headerCell.appendChild(spacer);
|
|
508
|
+
|
|
509
|
+
const label = document.createElement('span');
|
|
510
|
+
label.className = 'pg-label depth-0';
|
|
511
|
+
label.textContent = this._labels.total || 'Total';
|
|
512
|
+
headerCell.appendChild(label);
|
|
513
|
+
el.appendChild(headerCell);
|
|
514
|
+
|
|
515
|
+
for (const col of cols) {
|
|
516
|
+
const key = '__grand__||' + col.code;
|
|
517
|
+
const val = this.cells.get(key) || 0;
|
|
518
|
+
const cell = document.createElement('div');
|
|
519
|
+
cell.className = 'pg-cell total' + (col.isSubtotal ? ' subtotal' : '');
|
|
520
|
+
cell.style.cssText = `width:${W}px;height:${RH}px`;
|
|
521
|
+
cell.textContent = this._fmt(val);
|
|
522
|
+
cell.addEventListener('click', () =>
|
|
523
|
+
this._emitDrillthrough({ isGrandTotal: true }, col.code, val)
|
|
524
|
+
);
|
|
525
|
+
el.appendChild(cell);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const grandCell = document.createElement('div');
|
|
529
|
+
grandCell.className = 'pg-cell total grand-total-val';
|
|
530
|
+
grandCell.style.cssText = `width:${W}px;height:${RH}px`;
|
|
531
|
+
grandCell.textContent = this._fmt(this.grandTotal || 0);
|
|
532
|
+
grandCell.addEventListener('click', () =>
|
|
533
|
+
this._emitDrillthrough({ isGrandTotal: true }, '__total__', this.grandTotal)
|
|
534
|
+
);
|
|
535
|
+
el.appendChild(grandCell);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ── Collapse columns ───────────────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Toggles collapse state of a column group.
|
|
542
|
+
* When expanding, collapses direct children to avoid overloading the view.
|
|
543
|
+
* @param {string} code — column node code
|
|
544
|
+
*/
|
|
545
|
+
_toggleColCollapse(code) {
|
|
546
|
+
if (this.collapsedCols.has(code)) {
|
|
547
|
+
this.collapsedCols.delete(code);
|
|
548
|
+
// Collapse direct children
|
|
549
|
+
const node = this._findColNode(code);
|
|
550
|
+
if (node?.children) {
|
|
551
|
+
for (const child of node.children) {
|
|
552
|
+
if (child.children) this.collapsedCols.add(child.code);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
} else {
|
|
556
|
+
this.collapsedCols.add(code);
|
|
557
|
+
}
|
|
558
|
+
this._rebuildCols();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Finds a column tree node by its code (recursive).
|
|
563
|
+
* @param {string} code
|
|
564
|
+
* @param {object[]} [nodes=this.colTree]
|
|
565
|
+
* @returns {object|null}
|
|
566
|
+
*/
|
|
567
|
+
_findColNode(code, nodes = this.colTree) {
|
|
568
|
+
if (!nodes) return null;
|
|
569
|
+
for (const node of nodes) {
|
|
570
|
+
if (node.code === code) return node;
|
|
571
|
+
const found = this._findColNode(code, node.children);
|
|
572
|
+
if (found) return found;
|
|
573
|
+
}
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Shows or hides subtotal columns in multi-level column mode.
|
|
579
|
+
* @param {boolean} show
|
|
580
|
+
*/
|
|
581
|
+
toggleSubtotals(show) {
|
|
582
|
+
this._hideSubtotals = !show;
|
|
583
|
+
this._rebuildCols();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Rebuilds flat columns and re-renders the column header and grid. */
|
|
587
|
+
_rebuildCols() {
|
|
588
|
+
this._buildFlatCols();
|
|
589
|
+
const cols = this.flatCols.length ? this.flatCols : this.colKeys;
|
|
590
|
+
this.totalWidth = this._colHeaderW + (cols.length + 1) * PivotGrid.COL_W;
|
|
591
|
+
this.virtualSpace.style.width = this.totalWidth + 'px';
|
|
592
|
+
this.scrollArea.style.top = this._headerHeight + 'px';
|
|
593
|
+
this.headerEl.remove();
|
|
594
|
+
this._mountColHeader();
|
|
595
|
+
this.headerEl.style.transform = `translateX(-${this.scrollArea.scrollLeft}px)`;
|
|
596
|
+
this._redraw();
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ── Redraw ─────────────────────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
/** Clears all rendered rows and re-renders the visible viewport. */
|
|
602
|
+
_redraw() {
|
|
603
|
+
this.virtualSpace.style.height =
|
|
604
|
+
this.flatRows.length * PivotGrid.ROW_HEIGHT + 'px';
|
|
605
|
+
|
|
606
|
+
for (const [, el] of this.rendered) {
|
|
607
|
+
this.virtualSpace.removeChild(el);
|
|
608
|
+
this._recycleRow(el);
|
|
609
|
+
}
|
|
610
|
+
this.rendered.clear();
|
|
611
|
+
this._renderVisible();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ── Scroll ─────────────────────────────────────────────────────────────────
|
|
615
|
+
|
|
616
|
+
/** Binds the scroll event — syncs header position and triggers virtual render. */
|
|
617
|
+
_bindScroll() {
|
|
618
|
+
let ticking = false;
|
|
619
|
+
this.scrollArea.addEventListener('scroll', () => {
|
|
620
|
+
this.headerEl.style.transform =
|
|
621
|
+
`translateX(-${this.scrollArea.scrollLeft}px)`;
|
|
622
|
+
|
|
623
|
+
if (!ticking) {
|
|
624
|
+
requestAnimationFrame(() => {
|
|
625
|
+
this._renderVisible();
|
|
626
|
+
ticking = false;
|
|
627
|
+
});
|
|
628
|
+
ticking = true;
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ── Drillthrough ───────────────────────────────────────────────────────────
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Builds a context object from the clicked cell and dispatches a
|
|
637
|
+
* custom "drillthrough" event on the container.
|
|
638
|
+
* @param {object} node — row node (or { isGrandTotal: true })
|
|
639
|
+
* @param {string} colCode — column code or "__total__"
|
|
640
|
+
* @param {number} value — aggregated cell value
|
|
641
|
+
*/
|
|
642
|
+
_emitDrillthrough(node, colCode, value) {
|
|
643
|
+
const context = {};
|
|
644
|
+
|
|
645
|
+
if (!node.isGrandTotal) {
|
|
646
|
+
const chain = this._getNodeChain(node);
|
|
647
|
+
for (let i = 0; i < chain.length; i++) {
|
|
648
|
+
context[this.rows[i]] = chain[i].value;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (colCode !== '__total__') {
|
|
653
|
+
const parts = colCode.split('→');
|
|
654
|
+
for (let i = 0; i < parts.length; i++) {
|
|
655
|
+
if (this.columns[i]) context[this.columns[i]] = parts[i];
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// context holds logical field names — provider handles the mapping
|
|
660
|
+
this.container.dispatchEvent(new CustomEvent('drillthrough', {
|
|
661
|
+
bubbles: true,
|
|
662
|
+
detail: { context, value },
|
|
663
|
+
}));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Walks flatRows upward to build the ancestor chain for a given node.
|
|
668
|
+
* Used to construct the drillthrough context.
|
|
669
|
+
* @param {object} node
|
|
670
|
+
* @returns {object[]}
|
|
671
|
+
*/
|
|
672
|
+
_getNodeChain(node) {
|
|
673
|
+
const chain = [node];
|
|
674
|
+
if (node.depth === 0) return chain;
|
|
675
|
+
|
|
676
|
+
const idx = this.flatRows.indexOf(node);
|
|
677
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
678
|
+
const n = this.flatRows[i];
|
|
679
|
+
if (n.isGrandTotal) continue;
|
|
680
|
+
if (n.depth === node.depth - 1) {
|
|
681
|
+
chain.unshift(n);
|
|
682
|
+
if (n.depth === 0) break;
|
|
683
|
+
node = n;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return chain;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
690
|
+
|
|
691
|
+
/** Formats a numeric value with locale-aware thousand separators. */
|
|
692
|
+
_fmt(val) {
|
|
693
|
+
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(val);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Binds mousedown drag on the resize handle to adjust the row-label column width.
|
|
700
|
+
* @param {Element} handle
|
|
701
|
+
*/
|
|
702
|
+
_bindResizeHandle(handle) {
|
|
703
|
+
handle.addEventListener('mousedown', (e) => {
|
|
704
|
+
e.preventDefault();
|
|
705
|
+
const startX = e.clientX;
|
|
706
|
+
const startW = this._colHeaderW;
|
|
707
|
+
|
|
708
|
+
const onMove = (mv) => {
|
|
709
|
+
//const newW = Math.max(80, startW + mv.clientX - startX);
|
|
710
|
+
const newW = Math.max(PivotGrid.COL_HEADER_W, startW + mv.clientX - startX);
|
|
711
|
+
this._colHeaderW = newW;
|
|
712
|
+
this._rebuild();
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
const onUp = () => {
|
|
716
|
+
document.removeEventListener('mousemove', onMove);
|
|
717
|
+
document.removeEventListener('mouseup', onUp);
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
document.addEventListener('mousemove', onMove);
|
|
721
|
+
document.addEventListener('mouseup', onUp);
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Full rebuild after column width change: remounts header and re-renders rows. */
|
|
726
|
+
_rebuild() {
|
|
727
|
+
this.headerEl?.remove();
|
|
728
|
+
this.headerEl = null;
|
|
729
|
+
this._buildFlatCols();
|
|
730
|
+
this._mountColHeader();
|
|
731
|
+
for (const [, el] of this.rendered) this._recycleRow(el);
|
|
732
|
+
this.rendered.clear();
|
|
733
|
+
this._renderVisible();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Instant measure/function change — no aggregate recalculation. */
|
|
737
|
+
// setMeasure(measure, func) {
|
|
738
|
+
// this._measureKey = measure + '_' + func;
|
|
739
|
+
// for (const [, el] of this.rendered) this._recycleRow(el);
|
|
740
|
+
// this.rendered.clear();
|
|
741
|
+
// this._renderVisible();
|
|
742
|
+
// }
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Replaces the current aggregation result and re-renders the grid.
|
|
746
|
+
* Top-level column groups are collapsed automatically.
|
|
747
|
+
* @param {object} result
|
|
748
|
+
* @param {object} [options]
|
|
749
|
+
* @param {string[]} [options.rows]
|
|
750
|
+
* @param {string[]} [options.columns]
|
|
751
|
+
* @param {string} [options.measure]
|
|
752
|
+
* @param {object} [options.fieldDefs]
|
|
753
|
+
*/
|
|
754
|
+
setResult(result, { rows, columns, measure, fieldDefs } = {}) {
|
|
755
|
+
if (rows) this.rows = rows;
|
|
756
|
+
if (columns) this.columns = columns;
|
|
757
|
+
if (measure) this.measure = measure;
|
|
758
|
+
if (fieldDefs) this.fieldDefs = fieldDefs;
|
|
759
|
+
this.collapsedCols.clear();
|
|
760
|
+
this._applyResult(result);
|
|
761
|
+
|
|
762
|
+
// Collapse all top-level column groups
|
|
763
|
+
if (this.colTree) {
|
|
764
|
+
for (const node of this.colTree) {
|
|
765
|
+
if (node.children) this.collapsedCols.add(node.code);
|
|
766
|
+
}
|
|
767
|
+
this._buildFlatCols();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const cols = this.flatCols.length ? this.flatCols : this.colKeys;
|
|
771
|
+
this.totalWidth = this._colHeaderW + (cols.length + 1) * PivotGrid.COL_W;
|
|
772
|
+
this.virtualSpace.style.width = this.totalWidth + 'px';
|
|
773
|
+
this.scrollArea.style.top = this._headerHeight + 'px';
|
|
774
|
+
|
|
775
|
+
this.headerEl.remove();
|
|
776
|
+
this._mountColHeader();
|
|
777
|
+
this._redraw();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Collapses all row nodes and redraws. */
|
|
781
|
+
collapseAll() {
|
|
782
|
+
const walk = (nodes) => {
|
|
783
|
+
if (!nodes) return;
|
|
784
|
+
for (const node of nodes) {
|
|
785
|
+
if (node.children) {
|
|
786
|
+
this.collapsed.add(node.code);
|
|
787
|
+
walk(node.children);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
walk(this.tree);
|
|
792
|
+
this._buildFlatRows();
|
|
793
|
+
this._redraw();
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Detects the maximum scrollable height supported by the current browser.
|
|
798
|
+
* Used to cap MAX_FLAT_ROWS and prevent invisible rows.
|
|
799
|
+
* @returns {number}
|
|
800
|
+
*/
|
|
801
|
+
static _detectMaxHeight() {
|
|
802
|
+
const el = document.createElement('div');
|
|
803
|
+
el.style.cssText = 'position:fixed;visibility:hidden;';
|
|
804
|
+
document.body.appendChild(el);
|
|
805
|
+
let h = 1_000_000;
|
|
806
|
+
while (h < 100_000_000) {
|
|
807
|
+
el.style.height = h + 'px';
|
|
808
|
+
if (el.offsetHeight < h) break;
|
|
809
|
+
h *= 2;
|
|
810
|
+
}
|
|
811
|
+
el.remove();
|
|
812
|
+
return h / 2;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
static MAX_FLAT_ROWS = Math.floor(PivotGrid._detectMaxHeight() / PivotGrid.ROW_HEIGHT);
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Shows a confirm dialog when the expanded row count exceeds MAX_FLAT_ROWS.
|
|
819
|
+
* @param {number} count — total rows after expand
|
|
820
|
+
* @param {Function} onConfirm — called if user confirms
|
|
821
|
+
* @param {Function} [onCancel] — called if user cancels
|
|
822
|
+
*/
|
|
823
|
+
_confirmLargeExpand(count, onConfirm, onCancel) {
|
|
824
|
+
const millions = (count / 1_000_000).toFixed(1);
|
|
825
|
+
const msg = (this._labels.confirmLargeExpand || 'Too many rows (~{millions}M). Click OK to expand anyway.').replace('{millions}', millions);
|
|
826
|
+
if (window.confirm(msg)) onConfirm();
|
|
827
|
+
else onCancel?.();
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Toggles a row node's collapsed state and redraws.
|
|
832
|
+
* Prompts confirmation if the resulting row count exceeds MAX_FLAT_ROWS.
|
|
833
|
+
* @param {string} code — row node code
|
|
834
|
+
*/
|
|
835
|
+
_toggleCollapse(code) {
|
|
836
|
+
const wasCollapsed = this.collapsed.has(code);
|
|
837
|
+
if (wasCollapsed) this.collapsed.delete(code);
|
|
838
|
+
else this.collapsed.add(code);
|
|
839
|
+
|
|
840
|
+
this._buildFlatRows();
|
|
841
|
+
|
|
842
|
+
if (wasCollapsed && this.flatRows.length > PivotGrid.MAX_FLAT_ROWS) {
|
|
843
|
+
this._confirmLargeExpand(this.flatRows.length,
|
|
844
|
+
() => this._redraw(),
|
|
845
|
+
() => {
|
|
846
|
+
this.collapsed.add(code);
|
|
847
|
+
this._buildFlatRows();
|
|
848
|
+
}
|
|
849
|
+
);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
this._redraw();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Expands rows up to the given depth. Clicking a depth level again collapses it.
|
|
858
|
+
* @param {number} depth — 1-based depth level
|
|
859
|
+
*/
|
|
860
|
+
expandToDepth(depth) {
|
|
861
|
+
const nodesAtDepth = [];
|
|
862
|
+
const walk = (nodes) => {
|
|
863
|
+
for (const node of nodes) {
|
|
864
|
+
if (!node.children) continue;
|
|
865
|
+
if (node.depth < depth - 1) {
|
|
866
|
+
this.collapsed.delete(node.code);
|
|
867
|
+
walk(node.children);
|
|
868
|
+
} else if (node.depth === depth - 1) {
|
|
869
|
+
nodesAtDepth.push(node);
|
|
870
|
+
// leave children untouched
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
walk(this.tree);
|
|
875
|
+
|
|
876
|
+
const anyExpanded = nodesAtDepth.some(n => !this.collapsed.has(n.code));
|
|
877
|
+
for (const n of nodesAtDepth) {
|
|
878
|
+
if (anyExpanded) this.collapsed.add(n.code);
|
|
879
|
+
else this.collapsed.delete(n.code);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
this._buildFlatRows();
|
|
883
|
+
this._redraw();
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Expands all row nodes. Prompts confirmation if row count exceeds MAX_FLAT_ROWS. */
|
|
887
|
+
expandAll() {
|
|
888
|
+
this.collapsed.clear();
|
|
889
|
+
this._buildFlatRows();
|
|
890
|
+
|
|
891
|
+
if (this.flatRows.length > PivotGrid.MAX_FLAT_ROWS) {
|
|
892
|
+
this._confirmLargeExpand(this.flatRows.length, () => this._redraw());
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
this._redraw();
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** Expands all column groups. */
|
|
900
|
+
expandAllCols() {
|
|
901
|
+
this.collapsedCols.clear();
|
|
902
|
+
this._rebuildCols();
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** Collapses all column groups. */
|
|
906
|
+
collapseAllCols() {
|
|
907
|
+
const walk = (nodes) => {
|
|
908
|
+
if (!nodes) return;
|
|
909
|
+
for (const node of nodes) {
|
|
910
|
+
if (node.children) {
|
|
911
|
+
this.collapsedCols.add(node.code);
|
|
912
|
+
walk(node.children);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
walk(this.colTree);
|
|
917
|
+
this._rebuildCols();
|
|
918
|
+
}
|
|
919
|
+
}
|