pivotgrid-js 0.1.2 → 0.1.4

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