pivotgrid-js 0.1.1 → 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/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
+ }