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.
@@ -850,19 +850,29 @@ class RestProvider {
850
850
  // ── HTTP ───────────────────────────────────────────────────────────────────
851
851
 
852
852
  async _execute(query) {
853
- const res = await fetch(this.url, {
854
- method: 'POST',
855
- headers: { 'Content-Type': 'application/json' },
856
- body: JSON.stringify({ query }),
857
- });
853
+ let page = 0;
854
+ let allRows = [];
855
+ let hasMore = true;
856
+
857
+ while (hasMore) {
858
+ const res = await fetch(this.url, {
859
+ method: 'POST',
860
+ headers: { 'Content-Type': 'application/json' },
861
+ body: JSON.stringify({ query, page }),
862
+ });
858
863
 
859
- if (!res.ok) {
860
- const err = await res.json().catch(() => ({}));
861
- throw new Error(`Server error ${res.status}: ${err.error || ''}`);
864
+ if (!res.ok) {
865
+ const err = await res.json().catch(() => ({}));
866
+ throw new Error(`Server error ${res.status}: ${err.error || ''}`);
867
+ }
868
+
869
+ const data = await res.json();
870
+ allRows = allRows.concat(data.rows);
871
+ hasMore = data.hasMore;
872
+ page++;
862
873
  }
863
874
 
864
- const rows = await res.json();
865
- return rows.map(row => {
875
+ return allRows.map(row => {
866
876
  const out = {};
867
877
  for (const k of Object.keys(row)) out[k.toLowerCase()] = row[k];
868
878
  return out;
@@ -874,925 +884,925 @@ class RestProvider {
874
884
  }
875
885
  }
876
886
 
877
- /**
878
- * PivotGrid — vanilla JS
879
- * v0.3 — hierarchical columns, absolute-positioned headers
880
- */
881
-
882
- class PivotGrid {
883
-
884
- static ROW_HEIGHT = 24;
885
- static HEADER_HEIGHT = 32;
886
- static COL_HEADER_W = 200;
887
- static COL_W = 150;
888
- static INDENT = 16;
889
- static BUFFER = 5;
890
-
891
- /**
892
- * @param {object} options
893
- * @param {Element} options.container — DOM element to render into
894
- * @param {object} options.result — aggregation result from Aggregator.build()
895
- * @param {string[]} options.rows — active row dimension names
896
- * @param {string[]} options.columns — active column dimension names
897
- * @param {string} options.measure — active measure name
898
- * @param {object} [options.fieldDefs={}] — field definitions (label, title, sortKey)
899
- * @param {object} [options.labels={}] — translated UI strings (total, confirmLargeExpand)
900
- */
901
- constructor({ container, result, rows, columns, measure, fieldDefs = {}, labels = {} }) {
902
- this.container = container;
903
- this.rows = rows;
904
- this.columns = columns;
905
- this.measure = measure;
906
- this.fieldDefs = fieldDefs;
907
- this._labels = labels;
908
- this._measureKey = measure + '_sum'; // updated via setMeasure()
909
- this._colHeaderW = PivotGrid.COL_HEADER_W;
910
- this._hideSubtotals = false;
911
-
912
- this.collapsed = new Set();
913
- this.collapsedCols = new Set();
914
- this.rowPool = [];
915
- this.rendered = new Map();
916
-
917
- this._applyResult(result);
918
- this._mount();
919
- this._renderVisible();
920
- this._bindScroll();
921
- }
922
-
923
- // ── Apply Result ────────────────────────────────────────────────────
924
-
925
- /** Applies an aggregation result object and rebuilds flat rows/cols. */
926
- _applyResult(result) {
927
- this.cells = result.cells;
928
- this.colTree = result.colTree;
929
- this.colKeys = result.colKeys;
930
- this.tree = result.tree;
931
- this.grandTotal = result.grandTotal;
932
- if (result.measureKey) this._measureKey = result.measureKey;
933
- this._buildFlatCols();
934
- this._buildFlatRows();
935
- }
936
-
937
- // ── Flat list of visible columns ────────────────────────────────────────
938
-
939
- /** Builds this.flatCols — the ordered list of visible leaf/subtotal column entries. */
940
- _buildFlatCols() {
941
- if (!this.colTree || !this.colTree.length) {
942
- this.flatCols = [];
943
- return;
944
- }
945
-
946
- const result = [];
947
- const multiLevel = this.columns && this.columns.length > 1;
948
-
949
- const walk = (nodes) => {
950
- for (const node of nodes) {
951
- if (node.children) {
952
- if (this.collapsedCols.has(node.code)) {
953
- result.push({ code: node.code, label: node.value, isSubtotal: true, collapsed: true });
954
- } else {
955
- walk(node.children);
956
- if (multiLevel && !this._hideSubtotals) {
957
- result.push({ code: node.code, label: '∑', isSubtotal: true, collapsed: false });
958
- }
959
- }
960
- } else {
961
- result.push({ code: node.code, label: node.value, isSubtotal: false });
962
- }
963
- }
964
- };
965
-
966
- walk(this.colTree);
967
- this.flatCols = result;
968
- }
969
-
970
- /**
971
- * Number of flatCols occupied by a node (recursive, respects collapsed state).
972
- */
973
- _getGroupSpan(node) {
974
- if (!node.children || this.collapsedCols.has(node.code)) return 1;
975
- const multiLevel = this.columns && this.columns.length > 1;
976
- let span = (multiLevel && !this._hideSubtotals) ? 1 : 0;
977
- for (const child of node.children) {
978
- span += this._getGroupSpan(child);
979
- }
980
- return span;
981
- }
982
-
983
- /**
984
- * Depth of the column tree, accounting for collapsed nodes.
985
- */
986
- _colTreeDepth() {
987
- if (!this.colTree || !this.colTree.length) return 1;
988
- const walk = (nodes) => {
989
- let max = 0;
990
- for (const node of nodes) {
991
- if (node.children && !this.collapsedCols.has(node.code)) {
992
- max = Math.max(max, 1 + walk(node.children));
993
- }
994
- }
995
- return max;
996
- };
997
- return 1 + walk(this.colTree);
998
- }
999
-
1000
- // ── Flat list of strings ──────────────────────────────────────────────────
1001
-
1002
- /** Builds this.flatRows — flat array of visible row nodes including grand total. */
1003
- _buildFlatRows() {
1004
- this.flatRows = [];
1005
- const walk = (nodes) => {
1006
- for (const node of nodes) {
1007
- this.flatRows.push(node);
1008
- if (node.children && !this.collapsed.has(node.code)) {
1009
- walk(node.children);
1010
- }
1011
- }
1012
- };
1013
- if (this.tree) walk(this.tree);
1014
- this.flatRows.push({ isGrandTotal: true });
1015
- }
1016
-
1017
- /** Total header height in px (HEADER_HEIGHT × column tree depth). */
1018
- get _headerHeight() {
1019
- return PivotGrid.HEADER_HEIGHT * this._colTreeDepth();
1020
- }
1021
-
1022
- // ── Mounting ───────────────────────────────────────────────────────────
1023
-
1024
- /** Clears the container and mounts the column header + scroll area. */
1025
- _mount() {
1026
- this.container.innerHTML = '';
1027
- this.container.classList.add('pg-root');
1028
-
1029
- const cols = this.flatCols.length ? this.flatCols : this.colKeys;
1030
- this.totalWidth = this._colHeaderW + (cols.length + 1) * PivotGrid.COL_W;
1031
-
1032
- this._mountColHeader();
1033
- this._mountScrollArea();
1034
- }
1035
-
1036
- /** Builds and appends the absolute-positioned column header element. */
1037
- _mountColHeader() {
1038
- const RH = PivotGrid.HEADER_HEIGHT;
1039
- const C = this._colHeaderW;
1040
- const W = PivotGrid.COL_W;
1041
- const totalDepth = this._colTreeDepth();
1042
- const H = RH * totalDepth;
1043
-
1044
- this.headerEl = document.createElement('div');
1045
- this.headerEl.className = 'pg-col-header';
1046
- this.headerEl.style.cssText = `
1047
- position: absolute; top: 0; left: 0;
1048
- width: ${this.totalWidth}px; height: ${H}px;
1049
- background: #fafafa; border-bottom: 1px solid #d0d0d0; z-index: 10;
1050
- `;
1051
-
1052
- // Row label — full height
1053
- const rowLabelCell = this._absCell({
1054
- x: 0, y: 0, w: C, h: H,
1055
- text: '',
1056
- cls: 'row-label',
1057
- });
1058
-
1059
- this.rows.forEach((row, i) => {
1060
- const span = document.createElement('span');
1061
- const def = (this.fieldDefs || {})[row] || {};
1062
- span.textContent = def.title || def.label || row;
1063
- span.style.cssText = 'cursor:pointer; padding: 0 2px;';
1064
- span.title = `Expand to "${row}"`;
1065
- if (i < this.rows.length - 1) {
1066
- span.addEventListener('click', () => this.expandToDepth(i + 1));
1067
- } else {
1068
- span.style.cursor = 'default';
1069
- }
1070
- if (i > 0) {
1071
- const sep = document.createElement('span');
1072
- sep.textContent = ' › ';
1073
- sep.style.color = '#ccc';
1074
- rowLabelCell.appendChild(sep);
1075
- }
1076
- rowLabelCell.appendChild(span);
1077
- });
1078
-
1079
- // Resize handle for the first column
1080
- const resizeHandle = document.createElement('div');
1081
- resizeHandle.className = 'pg-col-resize-handle';
1082
- resizeHandle.style.cssText = `
1083
- position: absolute; top: 0; left: ${C - 4}px;
1084
- width: 8px; height: ${H}px;
1085
- cursor: col-resize; z-index: 20;
1086
- `;
1087
- this.headerEl.appendChild(resizeHandle);
1088
- this._bindResizeHandle(resizeHandle);
1089
-
1090
- // Columns
1091
- if (this.colTree && this.colTree.length) {
1092
- let offset = 0;
1093
- for (const node of this.colTree) {
1094
- offset = this._renderColNode(node, 0, offset, totalDepth);
1095
- }
1096
- }
1097
-
1098
- // Total — full height
1099
- const cols = this.flatCols.length ? this.flatCols : this.colKeys;
1100
- this._absCell({
1101
- x: C + cols.length * W,
1102
- y: 0,
1103
- w: W,
1104
- h: H,
1105
- text: this._labels.total || 'Total',
1106
- cls: 'total-col',
1107
- });
1108
-
1109
- this.container.appendChild(this.headerEl);
1110
- }
1111
-
1112
- /**
1113
- * Recursively renders a column header cell with absolute positioning.
1114
- * Returns the new leafOffset.
1115
- */
1116
- _renderColNode(node, level, leafOffset, totalDepth) {
1117
- const RH = PivotGrid.HEADER_HEIGHT;
1118
- const C = this._colHeaderW;
1119
- const W = PivotGrid.COL_W;
1120
- const collapsed = this.collapsedCols.has(node.code);
1121
- const isLeaf = !node.children;
1122
- const span = this._getGroupSpan(node);
1123
-
1124
- // Листья и свёрнутые растягиваются до конца заголовка
1125
- const cellH = (isLeaf || collapsed)
1126
- ? (totalDepth - level) * RH
1127
- : RH;
1128
-
1129
- const cls = collapsed ? 'subtotal-col'
1130
- : isLeaf ? ''
1131
- : 'pg-col-header-group';
1132
-
1133
- const cell = this._absCell({
1134
- x: C + leafOffset * W,
1135
- y: level * RH,
1136
- w: span * W,
1137
- h: cellH,
1138
- text: node.value,
1139
- cls,
1140
- });
1141
-
1142
- // Collapse toggle button
1143
- if (node.children) {
1144
- const toggle = document.createElement('span');
1145
- toggle.className = 'pg-toggle' + (collapsed ? ' collapsed' : '');
1146
- toggle.textContent = '▾';
1147
- toggle.addEventListener('click', (e) => {
1148
- e.stopPropagation();
1149
- this._toggleColCollapse(node.code);
1150
- });
1151
- cell.insertBefore(toggle, cell.firstChild);
1152
- }
1153
-
1154
- if (!isLeaf && !collapsed) {
1155
- // Render children
1156
- let childOffset = leafOffset;
1157
- for (const child of node.children) {
1158
- childOffset = this._renderColNode(child, level + 1, childOffset, totalDepth);
1159
- }
1160
-
1161
- // ∑ for group — starts one level down, stretches to the end
1162
- if (this.columns && this.columns.length > 1 && !this._hideSubtotals) {
1163
- const subtotalH = (totalDepth - level - 1) * RH;
1164
- if (subtotalH > 0) {
1165
- this._absCell({
1166
- x: C + (leafOffset + span - 1) * W,
1167
- y: (level + 1) * RH,
1168
- w: W,
1169
- h: subtotalH,
1170
- text: '∑',
1171
- cls: 'subtotal-col',
1172
- });
1173
- }
1174
- }
1175
- }
1176
-
1177
- return leafOffset + span;
1178
- }
1179
-
1180
- /**
1181
- * Creates and appends an absolutely positioned cell to headerEl.
1182
- */
1183
- _absCell({ x, y, w, h, text, cls }) {
1184
- const cell = document.createElement('div');
1185
- cell.className = 'pg-col-header-cell' + (cls ? ' ' + cls : '');
1186
- cell.style.cssText = `
1187
- position: absolute;
1188
- left: ${x}px; top: ${y}px;
1189
- width: ${w}px; height: ${h}px;
1190
- box-sizing: border-box;
1191
- `;
1192
- cell.textContent = text;
1193
- this.headerEl.appendChild(cell);
1194
- return cell;
1195
- }
1196
-
1197
- /** Creates the scroll area div and the virtual space div inside it. */
1198
- _mountScrollArea() {
1199
- const H = this._headerHeight;
1200
-
1201
- this.scrollArea = document.createElement('div');
1202
- this.scrollArea.className = 'pg-scroll';
1203
- this.scrollArea.style.top = H + 'px';
1204
- this.container.appendChild(this.scrollArea);
1205
-
1206
- this.virtualSpace = document.createElement('div');
1207
- this.virtualSpace.style.cssText = `
1208
- position: relative;
1209
- width: ${this.totalWidth}px;
1210
- height: ${this.flatRows.length * PivotGrid.ROW_HEIGHT}px;
1211
- `;
1212
- this.scrollArea.appendChild(this.virtualSpace);
1213
- }
1214
-
1215
- // ── Virtualization ──────────────────────────────────────────────────────────
1216
-
1217
- /**
1218
- * Renders only the rows currently in the viewport (+ BUFFER rows above/below).
1219
- * Recycles rows that have scrolled out of view back into the pool.
1220
- */
1221
- _renderVisible() {
1222
- const viewH = this.scrollArea.clientHeight;
1223
- const scrollTop = this.scrollArea.scrollTop;
1224
- const RH = PivotGrid.ROW_HEIGHT;
1225
- const BUF = PivotGrid.BUFFER;
1226
-
1227
- const first = Math.max(0, Math.floor(scrollTop / RH) - BUF);
1228
- const last = Math.min(
1229
- this.flatRows.length - 1,
1230
- Math.ceil((scrollTop + viewH) / RH) + BUF
1231
- );
1232
-
1233
- for (const [idx, el] of this.rendered) {
1234
- if (idx < first || idx > last) {
1235
- this.virtualSpace.removeChild(el);
1236
- this._recycleRow(el);
1237
- this.rendered.delete(idx);
1238
- }
1239
- }
1240
-
1241
- for (let i = first; i <= last; i++) {
1242
- if (this.rendered.has(i)) continue;
1243
- const el = this._acquireRow();
1244
- this._fillRow(el, this.flatRows[i], i);
1245
- this.virtualSpace.appendChild(el);
1246
- this.rendered.set(i, el);
1247
- }
1248
- }
1249
-
1250
- /** Returns a recycled or newly created row element. */
1251
- _acquireRow() {
1252
- if (this.rowPool.length) {
1253
- const el = this.rowPool.pop();
1254
- el.className = 'pg-row';
1255
- el.removeAttribute('style');
1256
- el.innerHTML = '';
1257
- return el;
1258
- }
1259
- const el = document.createElement('div');
1260
- el.className = 'pg-row';
1261
- return el;
1262
- }
1263
-
1264
- /** Returns a row element to the pool for reuse. */
1265
- _recycleRow(el) {
1266
- this.rowPool.push(el);
1267
- }
1268
-
1269
- // ── Filling the Line ──────────────────────────────────────────────────────
1270
-
1271
- /**
1272
- * Fills a row element with header cell and value cells for the given node.
1273
- * @param {Element} el — row element from the pool
1274
- * @param {object} node — flat row node (or { isGrandTotal: true })
1275
- * @param {number} idx — row index in flatRows
1276
- */
1277
- _fillRow(el, node, idx) {
1278
- const RH = PivotGrid.ROW_HEIGHT;
1279
- el.style.top = idx * RH + 'px';
1280
- el.style.width = this.totalWidth + 'px';
1281
- el.style.height = RH + 'px';
1282
-
1283
- if (node.isGrandTotal) {
1284
- el.classList.add('grand-total');
1285
- this._fillGrandTotalRow(el);
1286
- return;
1287
- }
1288
-
1289
- el.style.background = idx % 2 === 0 ? '#ffffff' : '#fcfcfc';
1290
- this._fillHeaderCell(el, node);
1291
- this._fillValueCells(el, node);
1292
- }
1293
-
1294
- /**
1295
- * Appends the sticky left header cell (label + expand/collapse toggle) to a row.
1296
- * @param {Element} el — row element
1297
- * @param {object} node — row tree node
1298
- */
1299
- _fillHeaderCell(el, node) {
1300
- const RH = PivotGrid.ROW_HEIGHT;
1301
- const C = this._colHeaderW;
1302
- const I = PivotGrid.INDENT;
1303
-
1304
- const cell = document.createElement('div');
1305
- cell.className = 'pg-cell-header';
1306
- cell.style.cssText = `width:${C}px;height:${RH}px;padding-left:${8 + node.depth * I}px`;
1307
-
1308
- if (node.children) {
1309
- const toggle = document.createElement('span');
1310
- toggle.className = 'pg-toggle' + (this.collapsed.has(node.code) ? ' collapsed' : '');
1311
- toggle.textContent = '▾';
1312
- toggle.addEventListener('click', (e) => {
1313
- e.stopPropagation();
1314
- this._toggleCollapse(node.code);
1315
- });
1316
- cell.appendChild(toggle);
1317
- } else {
1318
- const spacer = document.createElement('span');
1319
- spacer.className = 'pg-toggle-spacer';
1320
- cell.appendChild(spacer);
1321
- }
1322
-
1323
- const label = document.createElement('span');
1324
- label.className = `pg-label depth-${Math.min(node.depth, 2)}`;
1325
- label.textContent = node.value;
1326
- cell.appendChild(label);
1327
-
1328
- el.appendChild(cell);
1329
- }
1330
-
1331
- /**
1332
- * Appends all value cells (one per column + one total) to a row.
1333
- * Each cell fires a drillthrough event on click.
1334
- * @param {Element} el — row element
1335
- * @param {object} node — row tree node
1336
- */
1337
- _fillValueCells(el, node) {
1338
- const RH = PivotGrid.ROW_HEIGHT;
1339
- const W = PivotGrid.COL_W;
1340
- const cols = this.flatCols.length ? this.flatCols : this.colKeys;
1341
-
1342
- for (const col of cols) {
1343
- const key = node.code + '||' + col.code;
1344
- const val = this.cells.get(key);
1345
- const cell = document.createElement('div');
1346
- cell.className = 'pg-cell'
1347
- + (val == null ? ' empty' : '')
1348
- + (col.isSubtotal ? ' subtotal' : '');
1349
- cell.style.cssText = `width:${W}px;height:${RH}px`;
1350
- cell.textContent = val != null ? this._fmt(val) : '—';
1351
- if (val != null) {
1352
- cell.addEventListener('click', () => this._emitDrillthrough(node, col.code, val));
1353
- }
1354
- el.appendChild(cell);
1355
- }
1356
-
1357
- const totalKey = node.code + '||__total__';
1358
- const totalVal = this.cells.get(totalKey) || 0;
1359
- const totalCell = document.createElement('div');
1360
- totalCell.className = 'pg-cell total';
1361
- totalCell.style.cssText = `width:${W}px;height:${RH}px`;
1362
- totalCell.textContent = this._fmt(totalVal);
1363
- totalCell.addEventListener('click', () => this._emitDrillthrough(node, '__total__', totalVal));
1364
- el.appendChild(totalCell);
1365
- }
1366
-
1367
- /**
1368
- * Fills the grand total row: header label + column totals + overall grand total.
1369
- * @param {Element} el — row element
1370
- */
1371
- _fillGrandTotalRow(el) {
1372
- const RH = PivotGrid.ROW_HEIGHT;
1373
- const C = this._colHeaderW;
1374
- const W = PivotGrid.COL_W;
1375
- const cols = this.flatCols.length ? this.flatCols : this.colKeys;
1376
-
1377
- const headerCell = document.createElement('div');
1378
- headerCell.className = 'pg-cell-header';
1379
- headerCell.style.cssText = `width:${C}px;height:${RH}px;padding-left:8px`;
1380
-
1381
- const spacer = document.createElement('span');
1382
- spacer.className = 'pg-toggle-spacer';
1383
- headerCell.appendChild(spacer);
1384
-
1385
- const label = document.createElement('span');
1386
- label.className = 'pg-label depth-0';
1387
- label.textContent = this._labels.total || 'Total';
1388
- headerCell.appendChild(label);
1389
- el.appendChild(headerCell);
1390
-
1391
- for (const col of cols) {
1392
- const key = '__grand__||' + col.code;
1393
- const val = this.cells.get(key) || 0;
1394
- const cell = document.createElement('div');
1395
- cell.className = 'pg-cell total' + (col.isSubtotal ? ' subtotal' : '');
1396
- cell.style.cssText = `width:${W}px;height:${RH}px`;
1397
- cell.textContent = this._fmt(val);
1398
- cell.addEventListener('click', () =>
1399
- this._emitDrillthrough({ isGrandTotal: true }, col.code, val)
1400
- );
1401
- el.appendChild(cell);
1402
- }
1403
-
1404
- const grandCell = document.createElement('div');
1405
- grandCell.className = 'pg-cell total grand-total-val';
1406
- grandCell.style.cssText = `width:${W}px;height:${RH}px`;
1407
- grandCell.textContent = this._fmt(this.grandTotal || 0);
1408
- grandCell.addEventListener('click', () =>
1409
- this._emitDrillthrough({ isGrandTotal: true }, '__total__', this.grandTotal)
1410
- );
1411
- el.appendChild(grandCell);
1412
- }
1413
-
1414
- // ── Collapse columns ───────────────────────────────────────────────────────
1415
-
1416
- /**
1417
- * Toggles collapse state of a column group.
1418
- * When expanding, collapses direct children to avoid overloading the view.
1419
- * @param {string} code — column node code
1420
- */
1421
- _toggleColCollapse(code) {
1422
- if (this.collapsedCols.has(code)) {
1423
- this.collapsedCols.delete(code);
1424
- // Collapse direct children
1425
- const node = this._findColNode(code);
1426
- if (node?.children) {
1427
- for (const child of node.children) {
1428
- if (child.children) this.collapsedCols.add(child.code);
1429
- }
1430
- }
1431
- } else {
1432
- this.collapsedCols.add(code);
1433
- }
1434
- this._rebuildCols();
1435
- }
1436
-
1437
- /**
1438
- * Finds a column tree node by its code (recursive).
1439
- * @param {string} code
1440
- * @param {object[]} [nodes=this.colTree]
1441
- * @returns {object|null}
1442
- */
1443
- _findColNode(code, nodes = this.colTree) {
1444
- if (!nodes) return null;
1445
- for (const node of nodes) {
1446
- if (node.code === code) return node;
1447
- const found = this._findColNode(code, node.children);
1448
- if (found) return found;
1449
- }
1450
- return null;
1451
- }
1452
-
1453
- /**
1454
- * Shows or hides subtotal columns in multi-level column mode.
1455
- * @param {boolean} show
1456
- */
1457
- toggleSubtotals(show) {
1458
- this._hideSubtotals = !show;
1459
- this._rebuildCols();
1460
- }
1461
-
1462
- /** Rebuilds flat columns and re-renders the column header and grid. */
1463
- _rebuildCols() {
1464
- this._buildFlatCols();
1465
- const cols = this.flatCols.length ? this.flatCols : this.colKeys;
1466
- this.totalWidth = this._colHeaderW + (cols.length + 1) * PivotGrid.COL_W;
1467
- this.virtualSpace.style.width = this.totalWidth + 'px';
1468
- this.scrollArea.style.top = this._headerHeight + 'px';
1469
- this.headerEl.remove();
1470
- this._mountColHeader();
1471
- this.headerEl.style.transform = `translateX(-${this.scrollArea.scrollLeft}px)`;
1472
- this._redraw();
1473
- }
1474
-
1475
- // ── Redraw ─────────────────────────────────────────────────────────────────
1476
-
1477
- /** Clears all rendered rows and re-renders the visible viewport. */
1478
- _redraw() {
1479
- this.virtualSpace.style.height =
1480
- this.flatRows.length * PivotGrid.ROW_HEIGHT + 'px';
1481
-
1482
- for (const [, el] of this.rendered) {
1483
- this.virtualSpace.removeChild(el);
1484
- this._recycleRow(el);
1485
- }
1486
- this.rendered.clear();
1487
- this._renderVisible();
1488
- }
1489
-
1490
- // ── Scroll ─────────────────────────────────────────────────────────────────
1491
-
1492
- /** Binds the scroll event — syncs header position and triggers virtual render. */
1493
- _bindScroll() {
1494
- let ticking = false;
1495
- this.scrollArea.addEventListener('scroll', () => {
1496
- this.headerEl.style.transform =
1497
- `translateX(-${this.scrollArea.scrollLeft}px)`;
1498
-
1499
- if (!ticking) {
1500
- requestAnimationFrame(() => {
1501
- this._renderVisible();
1502
- ticking = false;
1503
- });
1504
- ticking = true;
1505
- }
1506
- });
1507
- }
1508
-
1509
- // ── Drillthrough ───────────────────────────────────────────────────────────
1510
-
1511
- /**
1512
- * Builds a context object from the clicked cell and dispatches a
1513
- * custom "drillthrough" event on the container.
1514
- * @param {object} node — row node (or { isGrandTotal: true })
1515
- * @param {string} colCode — column code or "__total__"
1516
- * @param {number} value — aggregated cell value
1517
- */
1518
- _emitDrillthrough(node, colCode, value) {
1519
- const context = {};
1520
-
1521
- if (!node.isGrandTotal) {
1522
- const chain = this._getNodeChain(node);
1523
- for (let i = 0; i < chain.length; i++) {
1524
- context[this.rows[i]] = chain[i].value;
1525
- }
1526
- }
1527
-
1528
- if (colCode !== '__total__') {
1529
- const parts = colCode.split('→');
1530
- for (let i = 0; i < parts.length; i++) {
1531
- if (this.columns[i]) context[this.columns[i]] = parts[i];
1532
- }
1533
- }
1534
-
1535
- // context holds logical field names — provider handles the mapping
1536
- this.container.dispatchEvent(new CustomEvent('drillthrough', {
1537
- bubbles: true,
1538
- detail: { context, value },
1539
- }));
1540
- }
1541
-
1542
- /**
1543
- * Walks flatRows upward to build the ancestor chain for a given node.
1544
- * Used to construct the drillthrough context.
1545
- * @param {object} node
1546
- * @returns {object[]}
1547
- */
1548
- _getNodeChain(node) {
1549
- const chain = [node];
1550
- if (node.depth === 0) return chain;
1551
-
1552
- const idx = this.flatRows.indexOf(node);
1553
- for (let i = idx - 1; i >= 0; i--) {
1554
- const n = this.flatRows[i];
1555
- if (n.isGrandTotal) continue;
1556
- if (n.depth === node.depth - 1) {
1557
- chain.unshift(n);
1558
- if (n.depth === 0) break;
1559
- node = n;
1560
- }
1561
- }
1562
- return chain;
1563
- }
1564
-
1565
- // ── Utilities ────────────────────────────────────────────────────────────────
1566
-
1567
- /** Formats a numeric value with locale-aware thousand separators. */
1568
- _fmt(val) {
1569
- return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(val);
1570
- }
1571
-
1572
- // ── Public API ──────────────────────────────────────────────────────────
1573
-
1574
- /**
1575
- * Binds mousedown drag on the resize handle to adjust the row-label column width.
1576
- * @param {Element} handle
1577
- */
1578
- _bindResizeHandle(handle) {
1579
- handle.addEventListener('mousedown', (e) => {
1580
- e.preventDefault();
1581
- const startX = e.clientX;
1582
- const startW = this._colHeaderW;
1583
-
1584
- const onMove = (mv) => {
1585
- //const newW = Math.max(80, startW + mv.clientX - startX);
1586
- const newW = Math.max(PivotGrid.COL_HEADER_W, startW + mv.clientX - startX);
1587
- this._colHeaderW = newW;
1588
- this._rebuild();
1589
- };
1590
-
1591
- const onUp = () => {
1592
- document.removeEventListener('mousemove', onMove);
1593
- document.removeEventListener('mouseup', onUp);
1594
- };
1595
-
1596
- document.addEventListener('mousemove', onMove);
1597
- document.addEventListener('mouseup', onUp);
1598
- });
1599
- }
1600
-
1601
- /** Full rebuild after column width change: remounts header and re-renders rows. */
1602
- _rebuild() {
1603
- this.headerEl?.remove();
1604
- this.headerEl = null;
1605
- this._buildFlatCols();
1606
- this._mountColHeader();
1607
- for (const [, el] of this.rendered) this._recycleRow(el);
1608
- this.rendered.clear();
1609
- this._renderVisible();
1610
- }
1611
-
1612
- /** Instant measure/function change — no aggregate recalculation. */
1613
- // setMeasure(measure, func) {
1614
- // this._measureKey = measure + '_' + func;
1615
- // for (const [, el] of this.rendered) this._recycleRow(el);
1616
- // this.rendered.clear();
1617
- // this._renderVisible();
1618
- // }
1619
-
1620
- /**
1621
- * Replaces the current aggregation result and re-renders the grid.
1622
- * Top-level column groups are collapsed automatically.
1623
- * @param {object} result
1624
- * @param {object} [options]
1625
- * @param {string[]} [options.rows]
1626
- * @param {string[]} [options.columns]
1627
- * @param {string} [options.measure]
1628
- * @param {object} [options.fieldDefs]
1629
- */
1630
- setResult(result, { rows, columns, measure, fieldDefs } = {}) {
1631
- if (rows) this.rows = rows;
1632
- if (columns) this.columns = columns;
1633
- if (measure) this.measure = measure;
1634
- if (fieldDefs) this.fieldDefs = fieldDefs;
1635
- this.collapsedCols.clear();
1636
- this._applyResult(result);
1637
-
1638
- // Collapse all top-level column groups
1639
- if (this.colTree) {
1640
- for (const node of this.colTree) {
1641
- if (node.children) this.collapsedCols.add(node.code);
1642
- }
1643
- this._buildFlatCols();
1644
- }
1645
-
1646
- const cols = this.flatCols.length ? this.flatCols : this.colKeys;
1647
- this.totalWidth = this._colHeaderW + (cols.length + 1) * PivotGrid.COL_W;
1648
- this.virtualSpace.style.width = this.totalWidth + 'px';
1649
- this.scrollArea.style.top = this._headerHeight + 'px';
1650
-
1651
- this.headerEl.remove();
1652
- this._mountColHeader();
1653
- this._redraw();
1654
- }
1655
-
1656
- /** Collapses all row nodes and redraws. */
1657
- collapseAll() {
1658
- const walk = (nodes) => {
1659
- if (!nodes) return;
1660
- for (const node of nodes) {
1661
- if (node.children) {
1662
- this.collapsed.add(node.code);
1663
- walk(node.children);
1664
- }
1665
- }
1666
- };
1667
- walk(this.tree);
1668
- this._buildFlatRows();
1669
- this._redraw();
1670
- }
1671
-
1672
- /**
1673
- * Detects the maximum scrollable height supported by the current browser.
1674
- * Used to cap MAX_FLAT_ROWS and prevent invisible rows.
1675
- * @returns {number}
1676
- */
1677
- static _detectMaxHeight() {
1678
- const el = document.createElement('div');
1679
- el.style.cssText = 'position:fixed;visibility:hidden;';
1680
- document.body.appendChild(el);
1681
- let h = 1_000_000;
1682
- while (h < 100_000_000) {
1683
- el.style.height = h + 'px';
1684
- if (el.offsetHeight < h) break;
1685
- h *= 2;
1686
- }
1687
- el.remove();
1688
- return h / 2;
1689
- }
1690
-
1691
- static MAX_FLAT_ROWS = Math.floor(PivotGrid._detectMaxHeight() / PivotGrid.ROW_HEIGHT);
1692
-
1693
- /**
1694
- * Shows a confirm dialog when the expanded row count exceeds MAX_FLAT_ROWS.
1695
- * @param {number} count — total rows after expand
1696
- * @param {Function} onConfirm — called if user confirms
1697
- * @param {Function} [onCancel] — called if user cancels
1698
- */
1699
- _confirmLargeExpand(count, onConfirm, onCancel) {
1700
- const millions = (count / 1_000_000).toFixed(1);
1701
- const msg = (this._labels.confirmLargeExpand || 'Too many rows (~{millions}M). Click OK to expand anyway.').replace('{millions}', millions);
1702
- if (window.confirm(msg)) onConfirm();
1703
- else onCancel?.();
1704
- }
1705
-
1706
- /**
1707
- * Toggles a row node's collapsed state and redraws.
1708
- * Prompts confirmation if the resulting row count exceeds MAX_FLAT_ROWS.
1709
- * @param {string} code — row node code
1710
- */
1711
- _toggleCollapse(code) {
1712
- const wasCollapsed = this.collapsed.has(code);
1713
- if (wasCollapsed) this.collapsed.delete(code);
1714
- else this.collapsed.add(code);
1715
-
1716
- this._buildFlatRows();
1717
-
1718
- if (wasCollapsed && this.flatRows.length > PivotGrid.MAX_FLAT_ROWS) {
1719
- this._confirmLargeExpand(this.flatRows.length,
1720
- () => this._redraw(),
1721
- () => {
1722
- this.collapsed.add(code);
1723
- this._buildFlatRows();
1724
- }
1725
- );
1726
- return;
1727
- }
1728
-
1729
- this._redraw();
1730
- }
1731
-
1732
- /**
1733
- * Expands rows up to the given depth. Clicking a depth level again collapses it.
1734
- * @param {number} depth — 1-based depth level
1735
- */
1736
- expandToDepth(depth) {
1737
- const nodesAtDepth = [];
1738
- const walk = (nodes) => {
1739
- for (const node of nodes) {
1740
- if (!node.children) continue;
1741
- if (node.depth < depth - 1) {
1742
- this.collapsed.delete(node.code);
1743
- walk(node.children);
1744
- } else if (node.depth === depth - 1) {
1745
- nodesAtDepth.push(node);
1746
- // leave children untouched
1747
- }
1748
- }
1749
- };
1750
- walk(this.tree);
1751
-
1752
- const anyExpanded = nodesAtDepth.some(n => !this.collapsed.has(n.code));
1753
- for (const n of nodesAtDepth) {
1754
- if (anyExpanded) this.collapsed.add(n.code);
1755
- else this.collapsed.delete(n.code);
1756
- }
1757
-
1758
- this._buildFlatRows();
1759
- this._redraw();
1760
- }
1761
-
1762
- /** Expands all row nodes. Prompts confirmation if row count exceeds MAX_FLAT_ROWS. */
1763
- expandAll() {
1764
- this.collapsed.clear();
1765
- this._buildFlatRows();
1766
-
1767
- if (this.flatRows.length > PivotGrid.MAX_FLAT_ROWS) {
1768
- this._confirmLargeExpand(this.flatRows.length, () => this._redraw());
1769
- return;
1770
- }
1771
-
1772
- this._redraw();
1773
- }
1774
-
1775
- /** Expands all column groups. */
1776
- expandAllCols() {
1777
- this.collapsedCols.clear();
1778
- this._rebuildCols();
1779
- }
1780
-
1781
- /** Collapses all column groups. */
1782
- collapseAllCols() {
1783
- const walk = (nodes) => {
1784
- if (!nodes) return;
1785
- for (const node of nodes) {
1786
- if (node.children) {
1787
- this.collapsedCols.add(node.code);
1788
- walk(node.children);
1789
- }
1790
- }
1791
- };
1792
- walk(this.colTree);
1793
- this._rebuildCols();
1794
- }
1795
- }
887
+ /**
888
+ * PivotGrid — vanilla JS
889
+ * v0.3 — hierarchical columns, absolute-positioned headers
890
+ */
891
+
892
+ class PivotGrid {
893
+
894
+ static ROW_HEIGHT = 24;
895
+ static HEADER_HEIGHT = 32;
896
+ static COL_HEADER_W = 200;
897
+ static COL_W = 150;
898
+ static INDENT = 16;
899
+ static BUFFER = 5;
900
+
901
+ /**
902
+ * @param {object} options
903
+ * @param {Element} options.container — DOM element to render into
904
+ * @param {object} options.result — aggregation result from Aggregator.build()
905
+ * @param {string[]} options.rows — active row dimension names
906
+ * @param {string[]} options.columns — active column dimension names
907
+ * @param {string} options.measure — active measure name
908
+ * @param {object} [options.fieldDefs={}] — field definitions (label, title, sortKey)
909
+ * @param {object} [options.labels={}] — translated UI strings (total, confirmLargeExpand)
910
+ */
911
+ constructor({ container, result, rows, columns, measure, fieldDefs = {}, labels = {} }) {
912
+ this.container = container;
913
+ this.rows = rows;
914
+ this.columns = columns;
915
+ this.measure = measure;
916
+ this.fieldDefs = fieldDefs;
917
+ this._labels = labels;
918
+ this._measureKey = measure + '_sum'; // updated via setMeasure()
919
+ this._colHeaderW = PivotGrid.COL_HEADER_W;
920
+ this._hideSubtotals = false;
921
+
922
+ this.collapsed = new Set();
923
+ this.collapsedCols = new Set();
924
+ this.rowPool = [];
925
+ this.rendered = new Map();
926
+
927
+ this._applyResult(result);
928
+ this._mount();
929
+ this._renderVisible();
930
+ this._bindScroll();
931
+ }
932
+
933
+ // ── Apply Result ────────────────────────────────────────────────────
934
+
935
+ /** Applies an aggregation result object and rebuilds flat rows/cols. */
936
+ _applyResult(result) {
937
+ this.cells = result.cells;
938
+ this.colTree = result.colTree;
939
+ this.colKeys = result.colKeys;
940
+ this.tree = result.tree;
941
+ this.grandTotal = result.grandTotal;
942
+ if (result.measureKey) this._measureKey = result.measureKey;
943
+ this._buildFlatCols();
944
+ this._buildFlatRows();
945
+ }
946
+
947
+ // ── Flat list of visible columns ────────────────────────────────────────
948
+
949
+ /** Builds this.flatCols — the ordered list of visible leaf/subtotal column entries. */
950
+ _buildFlatCols() {
951
+ if (!this.colTree || !this.colTree.length) {
952
+ this.flatCols = [];
953
+ return;
954
+ }
955
+
956
+ const result = [];
957
+ const multiLevel = this.columns && this.columns.length > 1;
958
+
959
+ const walk = (nodes) => {
960
+ for (const node of nodes) {
961
+ if (node.children) {
962
+ if (this.collapsedCols.has(node.code)) {
963
+ result.push({ code: node.code, label: node.value, isSubtotal: true, collapsed: true });
964
+ } else {
965
+ walk(node.children);
966
+ if (multiLevel && !this._hideSubtotals) {
967
+ result.push({ code: node.code, label: '∑', isSubtotal: true, collapsed: false });
968
+ }
969
+ }
970
+ } else {
971
+ result.push({ code: node.code, label: node.value, isSubtotal: false });
972
+ }
973
+ }
974
+ };
975
+
976
+ walk(this.colTree);
977
+ this.flatCols = result;
978
+ }
979
+
980
+ /**
981
+ * Number of flatCols occupied by a node (recursive, respects collapsed state).
982
+ */
983
+ _getGroupSpan(node) {
984
+ if (!node.children || this.collapsedCols.has(node.code)) return 1;
985
+ const multiLevel = this.columns && this.columns.length > 1;
986
+ let span = (multiLevel && !this._hideSubtotals) ? 1 : 0;
987
+ for (const child of node.children) {
988
+ span += this._getGroupSpan(child);
989
+ }
990
+ return span;
991
+ }
992
+
993
+ /**
994
+ * Depth of the column tree, accounting for collapsed nodes.
995
+ */
996
+ _colTreeDepth() {
997
+ if (!this.colTree || !this.colTree.length) return 1;
998
+ const walk = (nodes) => {
999
+ let max = 0;
1000
+ for (const node of nodes) {
1001
+ if (node.children && !this.collapsedCols.has(node.code)) {
1002
+ max = Math.max(max, 1 + walk(node.children));
1003
+ }
1004
+ }
1005
+ return max;
1006
+ };
1007
+ return 1 + walk(this.colTree);
1008
+ }
1009
+
1010
+ // ── Flat list of strings ──────────────────────────────────────────────────
1011
+
1012
+ /** Builds this.flatRows — flat array of visible row nodes including grand total. */
1013
+ _buildFlatRows() {
1014
+ this.flatRows = [];
1015
+ const walk = (nodes) => {
1016
+ for (const node of nodes) {
1017
+ this.flatRows.push(node);
1018
+ if (node.children && !this.collapsed.has(node.code)) {
1019
+ walk(node.children);
1020
+ }
1021
+ }
1022
+ };
1023
+ if (this.tree) walk(this.tree);
1024
+ this.flatRows.push({ isGrandTotal: true });
1025
+ }
1026
+
1027
+ /** Total header height in px (HEADER_HEIGHT × column tree depth). */
1028
+ get _headerHeight() {
1029
+ return PivotGrid.HEADER_HEIGHT * this._colTreeDepth();
1030
+ }
1031
+
1032
+ // ── Mounting ───────────────────────────────────────────────────────────
1033
+
1034
+ /** Clears the container and mounts the column header + scroll area. */
1035
+ _mount() {
1036
+ this.container.innerHTML = '';
1037
+ this.container.classList.add('pg-root');
1038
+
1039
+ const cols = this.flatCols.length ? this.flatCols : this.colKeys;
1040
+ this.totalWidth = this._colHeaderW + (cols.length + 1) * PivotGrid.COL_W;
1041
+
1042
+ this._mountColHeader();
1043
+ this._mountScrollArea();
1044
+ }
1045
+
1046
+ /** Builds and appends the absolute-positioned column header element. */
1047
+ _mountColHeader() {
1048
+ const RH = PivotGrid.HEADER_HEIGHT;
1049
+ const C = this._colHeaderW;
1050
+ const W = PivotGrid.COL_W;
1051
+ const totalDepth = this._colTreeDepth();
1052
+ const H = RH * totalDepth;
1053
+
1054
+ this.headerEl = document.createElement('div');
1055
+ this.headerEl.className = 'pg-col-header';
1056
+ this.headerEl.style.cssText = `
1057
+ position: absolute; top: 0; left: 0;
1058
+ width: ${this.totalWidth}px; height: ${H}px;
1059
+ background: #fafafa; border-bottom: 1px solid #d0d0d0; z-index: 10;
1060
+ `;
1061
+
1062
+ // Row label — full height
1063
+ const rowLabelCell = this._absCell({
1064
+ x: 0, y: 0, w: C, h: H,
1065
+ text: '',
1066
+ cls: 'row-label',
1067
+ });
1068
+
1069
+ this.rows.forEach((row, i) => {
1070
+ const span = document.createElement('span');
1071
+ const def = (this.fieldDefs || {})[row] || {};
1072
+ span.textContent = def.title || def.label || row;
1073
+ span.style.cssText = 'cursor:pointer; padding: 0 2px;';
1074
+ span.title = `Expand to "${row}"`;
1075
+ if (i < this.rows.length - 1) {
1076
+ span.addEventListener('click', () => this.expandToDepth(i + 1));
1077
+ } else {
1078
+ span.style.cursor = 'default';
1079
+ }
1080
+ if (i > 0) {
1081
+ const sep = document.createElement('span');
1082
+ sep.textContent = ' › ';
1083
+ sep.style.color = '#ccc';
1084
+ rowLabelCell.appendChild(sep);
1085
+ }
1086
+ rowLabelCell.appendChild(span);
1087
+ });
1088
+
1089
+ // Resize handle for the first column
1090
+ const resizeHandle = document.createElement('div');
1091
+ resizeHandle.className = 'pg-col-resize-handle';
1092
+ resizeHandle.style.cssText = `
1093
+ position: absolute; top: 0; left: ${C - 4}px;
1094
+ width: 8px; height: ${H}px;
1095
+ cursor: col-resize; z-index: 20;
1096
+ `;
1097
+ this.headerEl.appendChild(resizeHandle);
1098
+ this._bindResizeHandle(resizeHandle);
1099
+
1100
+ // Columns
1101
+ if (this.colTree && this.colTree.length) {
1102
+ let offset = 0;
1103
+ for (const node of this.colTree) {
1104
+ offset = this._renderColNode(node, 0, offset, totalDepth);
1105
+ }
1106
+ }
1107
+
1108
+ // Total — full height
1109
+ const cols = this.flatCols.length ? this.flatCols : this.colKeys;
1110
+ this._absCell({
1111
+ x: C + cols.length * W,
1112
+ y: 0,
1113
+ w: W,
1114
+ h: H,
1115
+ text: this._labels.total || 'Total',
1116
+ cls: 'total-col',
1117
+ });
1118
+
1119
+ this.container.appendChild(this.headerEl);
1120
+ }
1121
+
1122
+ /**
1123
+ * Recursively renders a column header cell with absolute positioning.
1124
+ * Returns the new leafOffset.
1125
+ */
1126
+ _renderColNode(node, level, leafOffset, totalDepth) {
1127
+ const RH = PivotGrid.HEADER_HEIGHT;
1128
+ const C = this._colHeaderW;
1129
+ const W = PivotGrid.COL_W;
1130
+ const collapsed = this.collapsedCols.has(node.code);
1131
+ const isLeaf = !node.children;
1132
+ const span = this._getGroupSpan(node);
1133
+
1134
+ // Листья и свёрнутые растягиваются до конца заголовка
1135
+ const cellH = (isLeaf || collapsed)
1136
+ ? (totalDepth - level) * RH
1137
+ : RH;
1138
+
1139
+ const cls = collapsed ? 'subtotal-col'
1140
+ : isLeaf ? ''
1141
+ : 'pg-col-header-group';
1142
+
1143
+ const cell = this._absCell({
1144
+ x: C + leafOffset * W,
1145
+ y: level * RH,
1146
+ w: span * W,
1147
+ h: cellH,
1148
+ text: node.value,
1149
+ cls,
1150
+ });
1151
+
1152
+ // Collapse toggle button
1153
+ if (node.children) {
1154
+ const toggle = document.createElement('span');
1155
+ toggle.className = 'pg-toggle' + (collapsed ? ' collapsed' : '');
1156
+ toggle.textContent = '▾';
1157
+ toggle.addEventListener('click', (e) => {
1158
+ e.stopPropagation();
1159
+ this._toggleColCollapse(node.code);
1160
+ });
1161
+ cell.insertBefore(toggle, cell.firstChild);
1162
+ }
1163
+
1164
+ if (!isLeaf && !collapsed) {
1165
+ // Render children
1166
+ let childOffset = leafOffset;
1167
+ for (const child of node.children) {
1168
+ childOffset = this._renderColNode(child, level + 1, childOffset, totalDepth);
1169
+ }
1170
+
1171
+ // ∑ for group — starts one level down, stretches to the end
1172
+ if (this.columns && this.columns.length > 1 && !this._hideSubtotals) {
1173
+ const subtotalH = (totalDepth - level - 1) * RH;
1174
+ if (subtotalH > 0) {
1175
+ this._absCell({
1176
+ x: C + (leafOffset + span - 1) * W,
1177
+ y: (level + 1) * RH,
1178
+ w: W,
1179
+ h: subtotalH,
1180
+ text: '∑',
1181
+ cls: 'subtotal-col',
1182
+ });
1183
+ }
1184
+ }
1185
+ }
1186
+
1187
+ return leafOffset + span;
1188
+ }
1189
+
1190
+ /**
1191
+ * Creates and appends an absolutely positioned cell to headerEl.
1192
+ */
1193
+ _absCell({ x, y, w, h, text, cls }) {
1194
+ const cell = document.createElement('div');
1195
+ cell.className = 'pg-col-header-cell' + (cls ? ' ' + cls : '');
1196
+ cell.style.cssText = `
1197
+ position: absolute;
1198
+ left: ${x}px; top: ${y}px;
1199
+ width: ${w}px; height: ${h}px;
1200
+ box-sizing: border-box;
1201
+ `;
1202
+ cell.textContent = text;
1203
+ this.headerEl.appendChild(cell);
1204
+ return cell;
1205
+ }
1206
+
1207
+ /** Creates the scroll area div and the virtual space div inside it. */
1208
+ _mountScrollArea() {
1209
+ const H = this._headerHeight;
1210
+
1211
+ this.scrollArea = document.createElement('div');
1212
+ this.scrollArea.className = 'pg-scroll';
1213
+ this.scrollArea.style.top = H + 'px';
1214
+ this.container.appendChild(this.scrollArea);
1215
+
1216
+ this.virtualSpace = document.createElement('div');
1217
+ this.virtualSpace.style.cssText = `
1218
+ position: relative;
1219
+ width: ${this.totalWidth}px;
1220
+ height: ${this.flatRows.length * PivotGrid.ROW_HEIGHT}px;
1221
+ `;
1222
+ this.scrollArea.appendChild(this.virtualSpace);
1223
+ }
1224
+
1225
+ // ── Virtualization ──────────────────────────────────────────────────────────
1226
+
1227
+ /**
1228
+ * Renders only the rows currently in the viewport (+ BUFFER rows above/below).
1229
+ * Recycles rows that have scrolled out of view back into the pool.
1230
+ */
1231
+ _renderVisible() {
1232
+ const viewH = this.scrollArea.clientHeight;
1233
+ const scrollTop = this.scrollArea.scrollTop;
1234
+ const RH = PivotGrid.ROW_HEIGHT;
1235
+ const BUF = PivotGrid.BUFFER;
1236
+
1237
+ const first = Math.max(0, Math.floor(scrollTop / RH) - BUF);
1238
+ const last = Math.min(
1239
+ this.flatRows.length - 1,
1240
+ Math.ceil((scrollTop + viewH) / RH) + BUF
1241
+ );
1242
+
1243
+ for (const [idx, el] of this.rendered) {
1244
+ if (idx < first || idx > last) {
1245
+ this.virtualSpace.removeChild(el);
1246
+ this._recycleRow(el);
1247
+ this.rendered.delete(idx);
1248
+ }
1249
+ }
1250
+
1251
+ for (let i = first; i <= last; i++) {
1252
+ if (this.rendered.has(i)) continue;
1253
+ const el = this._acquireRow();
1254
+ this._fillRow(el, this.flatRows[i], i);
1255
+ this.virtualSpace.appendChild(el);
1256
+ this.rendered.set(i, el);
1257
+ }
1258
+ }
1259
+
1260
+ /** Returns a recycled or newly created row element. */
1261
+ _acquireRow() {
1262
+ if (this.rowPool.length) {
1263
+ const el = this.rowPool.pop();
1264
+ el.className = 'pg-row';
1265
+ el.removeAttribute('style');
1266
+ el.innerHTML = '';
1267
+ return el;
1268
+ }
1269
+ const el = document.createElement('div');
1270
+ el.className = 'pg-row';
1271
+ return el;
1272
+ }
1273
+
1274
+ /** Returns a row element to the pool for reuse. */
1275
+ _recycleRow(el) {
1276
+ this.rowPool.push(el);
1277
+ }
1278
+
1279
+ // ── Filling the Line ──────────────────────────────────────────────────────
1280
+
1281
+ /**
1282
+ * Fills a row element with header cell and value cells for the given node.
1283
+ * @param {Element} el — row element from the pool
1284
+ * @param {object} node — flat row node (or { isGrandTotal: true })
1285
+ * @param {number} idx — row index in flatRows
1286
+ */
1287
+ _fillRow(el, node, idx) {
1288
+ const RH = PivotGrid.ROW_HEIGHT;
1289
+ el.style.top = idx * RH + 'px';
1290
+ el.style.width = this.totalWidth + 'px';
1291
+ el.style.height = RH + 'px';
1292
+
1293
+ if (node.isGrandTotal) {
1294
+ el.classList.add('grand-total');
1295
+ this._fillGrandTotalRow(el);
1296
+ return;
1297
+ }
1298
+
1299
+ el.style.background = idx % 2 === 0 ? '#ffffff' : '#fcfcfc';
1300
+ this._fillHeaderCell(el, node);
1301
+ this._fillValueCells(el, node);
1302
+ }
1303
+
1304
+ /**
1305
+ * Appends the sticky left header cell (label + expand/collapse toggle) to a row.
1306
+ * @param {Element} el — row element
1307
+ * @param {object} node — row tree node
1308
+ */
1309
+ _fillHeaderCell(el, node) {
1310
+ const RH = PivotGrid.ROW_HEIGHT;
1311
+ const C = this._colHeaderW;
1312
+ const I = PivotGrid.INDENT;
1313
+
1314
+ const cell = document.createElement('div');
1315
+ cell.className = 'pg-cell-header';
1316
+ cell.style.cssText = `width:${C}px;height:${RH}px;padding-left:${8 + node.depth * I}px`;
1317
+
1318
+ if (node.children) {
1319
+ const toggle = document.createElement('span');
1320
+ toggle.className = 'pg-toggle' + (this.collapsed.has(node.code) ? ' collapsed' : '');
1321
+ toggle.textContent = '▾';
1322
+ toggle.addEventListener('click', (e) => {
1323
+ e.stopPropagation();
1324
+ this._toggleCollapse(node.code);
1325
+ });
1326
+ cell.appendChild(toggle);
1327
+ } else {
1328
+ const spacer = document.createElement('span');
1329
+ spacer.className = 'pg-toggle-spacer';
1330
+ cell.appendChild(spacer);
1331
+ }
1332
+
1333
+ const label = document.createElement('span');
1334
+ label.className = `pg-label depth-${Math.min(node.depth, 2)}`;
1335
+ label.textContent = node.value;
1336
+ cell.appendChild(label);
1337
+
1338
+ el.appendChild(cell);
1339
+ }
1340
+
1341
+ /**
1342
+ * Appends all value cells (one per column + one total) to a row.
1343
+ * Each cell fires a drillthrough event on click.
1344
+ * @param {Element} el — row element
1345
+ * @param {object} node — row tree node
1346
+ */
1347
+ _fillValueCells(el, node) {
1348
+ const RH = PivotGrid.ROW_HEIGHT;
1349
+ const W = PivotGrid.COL_W;
1350
+ const cols = this.flatCols.length ? this.flatCols : this.colKeys;
1351
+
1352
+ for (const col of cols) {
1353
+ const key = node.code + '||' + col.code;
1354
+ const val = this.cells.get(key);
1355
+ const cell = document.createElement('div');
1356
+ cell.className = 'pg-cell'
1357
+ + (val == null ? ' empty' : '')
1358
+ + (col.isSubtotal ? ' subtotal' : '');
1359
+ cell.style.cssText = `width:${W}px;height:${RH}px`;
1360
+ cell.textContent = val != null ? this._fmt(val) : '—';
1361
+ if (val != null) {
1362
+ cell.addEventListener('click', () => this._emitDrillthrough(node, col.code, val));
1363
+ }
1364
+ el.appendChild(cell);
1365
+ }
1366
+
1367
+ const totalKey = node.code + '||__total__';
1368
+ const totalVal = this.cells.get(totalKey) || 0;
1369
+ const totalCell = document.createElement('div');
1370
+ totalCell.className = 'pg-cell total';
1371
+ totalCell.style.cssText = `width:${W}px;height:${RH}px`;
1372
+ totalCell.textContent = this._fmt(totalVal);
1373
+ totalCell.addEventListener('click', () => this._emitDrillthrough(node, '__total__', totalVal));
1374
+ el.appendChild(totalCell);
1375
+ }
1376
+
1377
+ /**
1378
+ * Fills the grand total row: header label + column totals + overall grand total.
1379
+ * @param {Element} el — row element
1380
+ */
1381
+ _fillGrandTotalRow(el) {
1382
+ const RH = PivotGrid.ROW_HEIGHT;
1383
+ const C = this._colHeaderW;
1384
+ const W = PivotGrid.COL_W;
1385
+ const cols = this.flatCols.length ? this.flatCols : this.colKeys;
1386
+
1387
+ const headerCell = document.createElement('div');
1388
+ headerCell.className = 'pg-cell-header';
1389
+ headerCell.style.cssText = `width:${C}px;height:${RH}px;padding-left:8px`;
1390
+
1391
+ const spacer = document.createElement('span');
1392
+ spacer.className = 'pg-toggle-spacer';
1393
+ headerCell.appendChild(spacer);
1394
+
1395
+ const label = document.createElement('span');
1396
+ label.className = 'pg-label depth-0';
1397
+ label.textContent = this._labels.total || 'Total';
1398
+ headerCell.appendChild(label);
1399
+ el.appendChild(headerCell);
1400
+
1401
+ for (const col of cols) {
1402
+ const key = '__grand__||' + col.code;
1403
+ const val = this.cells.get(key) || 0;
1404
+ const cell = document.createElement('div');
1405
+ cell.className = 'pg-cell total' + (col.isSubtotal ? ' subtotal' : '');
1406
+ cell.style.cssText = `width:${W}px;height:${RH}px`;
1407
+ cell.textContent = this._fmt(val);
1408
+ cell.addEventListener('click', () =>
1409
+ this._emitDrillthrough({ isGrandTotal: true }, col.code, val)
1410
+ );
1411
+ el.appendChild(cell);
1412
+ }
1413
+
1414
+ const grandCell = document.createElement('div');
1415
+ grandCell.className = 'pg-cell total grand-total-val';
1416
+ grandCell.style.cssText = `width:${W}px;height:${RH}px`;
1417
+ grandCell.textContent = this._fmt(this.grandTotal || 0);
1418
+ grandCell.addEventListener('click', () =>
1419
+ this._emitDrillthrough({ isGrandTotal: true }, '__total__', this.grandTotal)
1420
+ );
1421
+ el.appendChild(grandCell);
1422
+ }
1423
+
1424
+ // ── Collapse columns ───────────────────────────────────────────────────────
1425
+
1426
+ /**
1427
+ * Toggles collapse state of a column group.
1428
+ * When expanding, collapses direct children to avoid overloading the view.
1429
+ * @param {string} code — column node code
1430
+ */
1431
+ _toggleColCollapse(code) {
1432
+ if (this.collapsedCols.has(code)) {
1433
+ this.collapsedCols.delete(code);
1434
+ // Collapse direct children
1435
+ const node = this._findColNode(code);
1436
+ if (node?.children) {
1437
+ for (const child of node.children) {
1438
+ if (child.children) this.collapsedCols.add(child.code);
1439
+ }
1440
+ }
1441
+ } else {
1442
+ this.collapsedCols.add(code);
1443
+ }
1444
+ this._rebuildCols();
1445
+ }
1446
+
1447
+ /**
1448
+ * Finds a column tree node by its code (recursive).
1449
+ * @param {string} code
1450
+ * @param {object[]} [nodes=this.colTree]
1451
+ * @returns {object|null}
1452
+ */
1453
+ _findColNode(code, nodes = this.colTree) {
1454
+ if (!nodes) return null;
1455
+ for (const node of nodes) {
1456
+ if (node.code === code) return node;
1457
+ const found = this._findColNode(code, node.children);
1458
+ if (found) return found;
1459
+ }
1460
+ return null;
1461
+ }
1462
+
1463
+ /**
1464
+ * Shows or hides subtotal columns in multi-level column mode.
1465
+ * @param {boolean} show
1466
+ */
1467
+ toggleSubtotals(show) {
1468
+ this._hideSubtotals = !show;
1469
+ this._rebuildCols();
1470
+ }
1471
+
1472
+ /** Rebuilds flat columns and re-renders the column header and grid. */
1473
+ _rebuildCols() {
1474
+ this._buildFlatCols();
1475
+ const cols = this.flatCols.length ? this.flatCols : this.colKeys;
1476
+ this.totalWidth = this._colHeaderW + (cols.length + 1) * PivotGrid.COL_W;
1477
+ this.virtualSpace.style.width = this.totalWidth + 'px';
1478
+ this.scrollArea.style.top = this._headerHeight + 'px';
1479
+ this.headerEl.remove();
1480
+ this._mountColHeader();
1481
+ this.headerEl.style.transform = `translateX(-${this.scrollArea.scrollLeft}px)`;
1482
+ this._redraw();
1483
+ }
1484
+
1485
+ // ── Redraw ─────────────────────────────────────────────────────────────────
1486
+
1487
+ /** Clears all rendered rows and re-renders the visible viewport. */
1488
+ _redraw() {
1489
+ this.virtualSpace.style.height =
1490
+ this.flatRows.length * PivotGrid.ROW_HEIGHT + 'px';
1491
+
1492
+ for (const [, el] of this.rendered) {
1493
+ this.virtualSpace.removeChild(el);
1494
+ this._recycleRow(el);
1495
+ }
1496
+ this.rendered.clear();
1497
+ this._renderVisible();
1498
+ }
1499
+
1500
+ // ── Scroll ─────────────────────────────────────────────────────────────────
1501
+
1502
+ /** Binds the scroll event — syncs header position and triggers virtual render. */
1503
+ _bindScroll() {
1504
+ let ticking = false;
1505
+ this.scrollArea.addEventListener('scroll', () => {
1506
+ this.headerEl.style.transform =
1507
+ `translateX(-${this.scrollArea.scrollLeft}px)`;
1508
+
1509
+ if (!ticking) {
1510
+ requestAnimationFrame(() => {
1511
+ this._renderVisible();
1512
+ ticking = false;
1513
+ });
1514
+ ticking = true;
1515
+ }
1516
+ });
1517
+ }
1518
+
1519
+ // ── Drillthrough ───────────────────────────────────────────────────────────
1520
+
1521
+ /**
1522
+ * Builds a context object from the clicked cell and dispatches a
1523
+ * custom "drillthrough" event on the container.
1524
+ * @param {object} node — row node (or { isGrandTotal: true })
1525
+ * @param {string} colCode — column code or "__total__"
1526
+ * @param {number} value — aggregated cell value
1527
+ */
1528
+ _emitDrillthrough(node, colCode, value) {
1529
+ const context = {};
1530
+
1531
+ if (!node.isGrandTotal) {
1532
+ const chain = this._getNodeChain(node);
1533
+ for (let i = 0; i < chain.length; i++) {
1534
+ context[this.rows[i]] = chain[i].value;
1535
+ }
1536
+ }
1537
+
1538
+ if (colCode !== '__total__') {
1539
+ const parts = colCode.split('→');
1540
+ for (let i = 0; i < parts.length; i++) {
1541
+ if (this.columns[i]) context[this.columns[i]] = parts[i];
1542
+ }
1543
+ }
1544
+
1545
+ // context holds logical field names — provider handles the mapping
1546
+ this.container.dispatchEvent(new CustomEvent('drillthrough', {
1547
+ bubbles: true,
1548
+ detail: { context, value },
1549
+ }));
1550
+ }
1551
+
1552
+ /**
1553
+ * Walks flatRows upward to build the ancestor chain for a given node.
1554
+ * Used to construct the drillthrough context.
1555
+ * @param {object} node
1556
+ * @returns {object[]}
1557
+ */
1558
+ _getNodeChain(node) {
1559
+ const chain = [node];
1560
+ if (node.depth === 0) return chain;
1561
+
1562
+ const idx = this.flatRows.indexOf(node);
1563
+ for (let i = idx - 1; i >= 0; i--) {
1564
+ const n = this.flatRows[i];
1565
+ if (n.isGrandTotal) continue;
1566
+ if (n.depth === node.depth - 1) {
1567
+ chain.unshift(n);
1568
+ if (n.depth === 0) break;
1569
+ node = n;
1570
+ }
1571
+ }
1572
+ return chain;
1573
+ }
1574
+
1575
+ // ── Utilities ────────────────────────────────────────────────────────────────
1576
+
1577
+ /** Formats a numeric value with locale-aware thousand separators. */
1578
+ _fmt(val) {
1579
+ return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(val);
1580
+ }
1581
+
1582
+ // ── Public API ──────────────────────────────────────────────────────────
1583
+
1584
+ /**
1585
+ * Binds mousedown drag on the resize handle to adjust the row-label column width.
1586
+ * @param {Element} handle
1587
+ */
1588
+ _bindResizeHandle(handle) {
1589
+ handle.addEventListener('mousedown', (e) => {
1590
+ e.preventDefault();
1591
+ const startX = e.clientX;
1592
+ const startW = this._colHeaderW;
1593
+
1594
+ const onMove = (mv) => {
1595
+ //const newW = Math.max(80, startW + mv.clientX - startX);
1596
+ const newW = Math.max(PivotGrid.COL_HEADER_W, startW + mv.clientX - startX);
1597
+ this._colHeaderW = newW;
1598
+ this._rebuild();
1599
+ };
1600
+
1601
+ const onUp = () => {
1602
+ document.removeEventListener('mousemove', onMove);
1603
+ document.removeEventListener('mouseup', onUp);
1604
+ };
1605
+
1606
+ document.addEventListener('mousemove', onMove);
1607
+ document.addEventListener('mouseup', onUp);
1608
+ });
1609
+ }
1610
+
1611
+ /** Full rebuild after column width change: remounts header and re-renders rows. */
1612
+ _rebuild() {
1613
+ this.headerEl?.remove();
1614
+ this.headerEl = null;
1615
+ this._buildFlatCols();
1616
+ this._mountColHeader();
1617
+ for (const [, el] of this.rendered) this._recycleRow(el);
1618
+ this.rendered.clear();
1619
+ this._renderVisible();
1620
+ }
1621
+
1622
+ /** Instant measure/function change — no aggregate recalculation. */
1623
+ // setMeasure(measure, func) {
1624
+ // this._measureKey = measure + '_' + func;
1625
+ // for (const [, el] of this.rendered) this._recycleRow(el);
1626
+ // this.rendered.clear();
1627
+ // this._renderVisible();
1628
+ // }
1629
+
1630
+ /**
1631
+ * Replaces the current aggregation result and re-renders the grid.
1632
+ * Top-level column groups are collapsed automatically.
1633
+ * @param {object} result
1634
+ * @param {object} [options]
1635
+ * @param {string[]} [options.rows]
1636
+ * @param {string[]} [options.columns]
1637
+ * @param {string} [options.measure]
1638
+ * @param {object} [options.fieldDefs]
1639
+ */
1640
+ setResult(result, { rows, columns, measure, fieldDefs } = {}) {
1641
+ if (rows) this.rows = rows;
1642
+ if (columns) this.columns = columns;
1643
+ if (measure) this.measure = measure;
1644
+ if (fieldDefs) this.fieldDefs = fieldDefs;
1645
+ this.collapsedCols.clear();
1646
+ this._applyResult(result);
1647
+
1648
+ // Collapse all top-level column groups
1649
+ if (this.colTree) {
1650
+ for (const node of this.colTree) {
1651
+ if (node.children) this.collapsedCols.add(node.code);
1652
+ }
1653
+ this._buildFlatCols();
1654
+ }
1655
+
1656
+ const cols = this.flatCols.length ? this.flatCols : this.colKeys;
1657
+ this.totalWidth = this._colHeaderW + (cols.length + 1) * PivotGrid.COL_W;
1658
+ this.virtualSpace.style.width = this.totalWidth + 'px';
1659
+ this.scrollArea.style.top = this._headerHeight + 'px';
1660
+
1661
+ this.headerEl.remove();
1662
+ this._mountColHeader();
1663
+ this._redraw();
1664
+ }
1665
+
1666
+ /** Collapses all row nodes and redraws. */
1667
+ collapseAll() {
1668
+ const walk = (nodes) => {
1669
+ if (!nodes) return;
1670
+ for (const node of nodes) {
1671
+ if (node.children) {
1672
+ this.collapsed.add(node.code);
1673
+ walk(node.children);
1674
+ }
1675
+ }
1676
+ };
1677
+ walk(this.tree);
1678
+ this._buildFlatRows();
1679
+ this._redraw();
1680
+ }
1681
+
1682
+ /**
1683
+ * Detects the maximum scrollable height supported by the current browser.
1684
+ * Used to cap MAX_FLAT_ROWS and prevent invisible rows.
1685
+ * @returns {number}
1686
+ */
1687
+ static _detectMaxHeight() {
1688
+ const el = document.createElement('div');
1689
+ el.style.cssText = 'position:fixed;visibility:hidden;';
1690
+ document.body.appendChild(el);
1691
+ let h = 1_000_000;
1692
+ while (h < 100_000_000) {
1693
+ el.style.height = h + 'px';
1694
+ if (el.offsetHeight < h) break;
1695
+ h *= 2;
1696
+ }
1697
+ el.remove();
1698
+ return h / 2;
1699
+ }
1700
+
1701
+ static MAX_FLAT_ROWS = Math.floor(PivotGrid._detectMaxHeight() / PivotGrid.ROW_HEIGHT);
1702
+
1703
+ /**
1704
+ * Shows a confirm dialog when the expanded row count exceeds MAX_FLAT_ROWS.
1705
+ * @param {number} count — total rows after expand
1706
+ * @param {Function} onConfirm — called if user confirms
1707
+ * @param {Function} [onCancel] — called if user cancels
1708
+ */
1709
+ _confirmLargeExpand(count, onConfirm, onCancel) {
1710
+ const millions = (count / 1_000_000).toFixed(1);
1711
+ const msg = (this._labels.confirmLargeExpand || 'Too many rows (~{millions}M). Click OK to expand anyway.').replace('{millions}', millions);
1712
+ if (window.confirm(msg)) onConfirm();
1713
+ else onCancel?.();
1714
+ }
1715
+
1716
+ /**
1717
+ * Toggles a row node's collapsed state and redraws.
1718
+ * Prompts confirmation if the resulting row count exceeds MAX_FLAT_ROWS.
1719
+ * @param {string} code — row node code
1720
+ */
1721
+ _toggleCollapse(code) {
1722
+ const wasCollapsed = this.collapsed.has(code);
1723
+ if (wasCollapsed) this.collapsed.delete(code);
1724
+ else this.collapsed.add(code);
1725
+
1726
+ this._buildFlatRows();
1727
+
1728
+ if (wasCollapsed && this.flatRows.length > PivotGrid.MAX_FLAT_ROWS) {
1729
+ this._confirmLargeExpand(this.flatRows.length,
1730
+ () => this._redraw(),
1731
+ () => {
1732
+ this.collapsed.add(code);
1733
+ this._buildFlatRows();
1734
+ }
1735
+ );
1736
+ return;
1737
+ }
1738
+
1739
+ this._redraw();
1740
+ }
1741
+
1742
+ /**
1743
+ * Expands rows up to the given depth. Clicking a depth level again collapses it.
1744
+ * @param {number} depth — 1-based depth level
1745
+ */
1746
+ expandToDepth(depth) {
1747
+ const nodesAtDepth = [];
1748
+ const walk = (nodes) => {
1749
+ for (const node of nodes) {
1750
+ if (!node.children) continue;
1751
+ if (node.depth < depth - 1) {
1752
+ this.collapsed.delete(node.code);
1753
+ walk(node.children);
1754
+ } else if (node.depth === depth - 1) {
1755
+ nodesAtDepth.push(node);
1756
+ // leave children untouched
1757
+ }
1758
+ }
1759
+ };
1760
+ walk(this.tree);
1761
+
1762
+ const anyExpanded = nodesAtDepth.some(n => !this.collapsed.has(n.code));
1763
+ for (const n of nodesAtDepth) {
1764
+ if (anyExpanded) this.collapsed.add(n.code);
1765
+ else this.collapsed.delete(n.code);
1766
+ }
1767
+
1768
+ this._buildFlatRows();
1769
+ this._redraw();
1770
+ }
1771
+
1772
+ /** Expands all row nodes. Prompts confirmation if row count exceeds MAX_FLAT_ROWS. */
1773
+ expandAll() {
1774
+ this.collapsed.clear();
1775
+ this._buildFlatRows();
1776
+
1777
+ if (this.flatRows.length > PivotGrid.MAX_FLAT_ROWS) {
1778
+ this._confirmLargeExpand(this.flatRows.length, () => this._redraw());
1779
+ return;
1780
+ }
1781
+
1782
+ this._redraw();
1783
+ }
1784
+
1785
+ /** Expands all column groups. */
1786
+ expandAllCols() {
1787
+ this.collapsedCols.clear();
1788
+ this._rebuildCols();
1789
+ }
1790
+
1791
+ /** Collapses all column groups. */
1792
+ collapseAllCols() {
1793
+ const walk = (nodes) => {
1794
+ if (!nodes) return;
1795
+ for (const node of nodes) {
1796
+ if (node.children) {
1797
+ this.collapsedCols.add(node.code);
1798
+ walk(node.children);
1799
+ }
1800
+ }
1801
+ };
1802
+ walk(this.colTree);
1803
+ this._rebuildCols();
1804
+ }
1805
+ }
1796
1806
 
1797
1807
  /**
1798
1808
  * FieldZones