pivotgrid-js 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2867 @@
1
+ /**
2
+ * Encodes string values of a single column into uint16 indices.
3
+ * Used inside ColumnStore — one instance per dimension.
4
+ */
5
+ class DictionaryEncoder {
6
+ constructor() {
7
+ this._map = new Map(); // string → index
8
+ this._reverse = []; // index → string
9
+ }
10
+
11
+ /** Returns the numeric index for a value, creating it if needed */
12
+ encode(value) {
13
+ const str = String(value);
14
+ if (!this._map.has(str)) {
15
+ const idx = this._reverse.length;
16
+ this._map.set(str, idx);
17
+ this._reverse.push(str);
18
+ }
19
+ return this._map.get(str);
20
+ }
21
+
22
+ /** Restores the string value by index */
23
+ decode(index) {
24
+ return this._reverse[index];
25
+ }
26
+
27
+ get size() {
28
+ return this._reverse.length;
29
+ }
30
+ }
31
+ /**
32
+ * Columnar row storage backed by TypedArrays.
33
+ * Dimensions → Uint16Array (via DictionaryEncoder),
34
+ * measures → Float64Array.
35
+ * ~10× memory savings compared to an array of objects.
36
+ */
37
+ class ColumnStore {
38
+ constructor({ dimensions, measures, funcs, capacity }) {
39
+ this.dimensions = dimensions;
40
+ this.capacity = capacity;
41
+ this.length = 0;
42
+
43
+ // Expand revenue → revenue_sum, revenue_avg...
44
+ const expandedMeasures = measures.flatMap(m =>
45
+ funcs.map(fn => `${m}_${fn}`)
46
+ );
47
+ this.measures = expandedMeasures; // overwrite
48
+
49
+ // One encoder per dimension
50
+ this.encoders = {};
51
+ for (const dim of dimensions) {
52
+ this.encoders[dim] = new DictionaryEncoder();
53
+ }
54
+
55
+ // Columns: Uint16 for dimensions, Float64 for measures
56
+ this.dimCols = {};
57
+ this.measCols = {};
58
+ for (const dim of dimensions) this.dimCols[dim] = new Uint16Array(capacity);
59
+ for (const m of expandedMeasures) this.measCols[m] = new Float64Array(capacity);
60
+ }
61
+
62
+ /** Returns true if no more rows can be added */
63
+ isFull() {
64
+ return this.length >= this.capacity;
65
+ }
66
+
67
+ /**
68
+ * Appends rows to the store.
69
+ * Rows beyond capacity are silently discarded.
70
+ * @param {Object[]} rows
71
+ * @returns {number} number of rows actually added
72
+ */
73
+ append(rows) {
74
+ const room = this.capacity - this.length;
75
+ const batch = rows.length > room ? rows.slice(0, room) : rows;
76
+
77
+ for (let i = 0; i < batch.length; i++) {
78
+ const row = batch[i];
79
+ const idx = this.length + i;
80
+ for (const dim of this.dimensions) {
81
+ this.dimCols[dim][idx] = this.encoders[dim].encode(row[dim]);
82
+ }
83
+ for (const m of this.measures) {
84
+ this.measCols[m][idx] = Number(row[m]) || 0;
85
+ }
86
+ }
87
+
88
+ this.length += batch.length;
89
+ return batch.length;
90
+ }
91
+
92
+ /**
93
+ * Returns an iterable view of rows without materialising
94
+ * the full object array — Aggregator traverses data on-the-fly.
95
+ * Supports for…of, .forEach and .length.
96
+ */
97
+ rows() {
98
+ const { dimensions, measures, dimCols, measCols, encoders, length } = this;
99
+
100
+ const iterable = {
101
+ length,
102
+
103
+ forEach(fn) {
104
+ for (let i = 0; i < length; i++) {
105
+ const row = {};
106
+ for (const dim of dimensions) row[dim] = encoders[dim].decode(dimCols[dim][i]);
107
+ for (const m of measures) row[m] = measCols[m][i];
108
+ fn(row, i);
109
+ }
110
+ },
111
+
112
+ [Symbol.iterator]() {
113
+ let i = 0;
114
+ return {
115
+ next() {
116
+ if (i >= length) return { done: true, value: undefined };
117
+ const row = {};
118
+ for (const dim of dimensions) row[dim] = encoders[dim].decode(dimCols[dim][i]);
119
+ for (const m of measures) row[m] = measCols[m][i];
120
+ i++;
121
+ return { done: false, value: row };
122
+ }
123
+ };
124
+ }
125
+ };
126
+
127
+ return iterable;
128
+ }
129
+ }
130
+ /**
131
+ * Aggregator
132
+ *
133
+ * Builds the pivot structure from aggRows.
134
+ * Supports fieldDefs for correct sorting of dates and lookup fields.
135
+ *
136
+ * Usage:
137
+ * const agg = new Aggregator();
138
+ *
139
+ * const result = agg.build({
140
+ * rows: ['region', 'month'],
141
+ * columns: ['channel'],
142
+ * measure: 'revenue',
143
+ * func: 'sum',
144
+ * aggRows,
145
+ * fieldDefs: {
146
+ * region: { label: 'region' },
147
+ * month: { label: 'month_name', sortKey: 'month_num' },
148
+ * },
149
+ * });
150
+ */
151
+
152
+ class Aggregator {
153
+
154
+ build({ rows, columns, measure, func, aggRows, fieldDefs = {} }) {
155
+ const measureKey = `${measure}_${func}`;
156
+ const cells = new Map();
157
+ let grandTotal = 0;
158
+ const hasColumns = columns && columns.length > 0;
159
+
160
+ // Pre-compute label and sortKey once, outside the loop
161
+ const rowCols = rows.map(f => (fieldDefs[f] || {}).label || f);
162
+ const rowSorts = rows.map(f => (fieldDefs[f] || {}).sortKey || null);
163
+ const colCols = hasColumns ? columns.map(f => (fieldDefs[f] || {}).label || f) : [];
164
+ const colSorts = hasColumns ? columns.map(f => (fieldDefs[f] || {}).sortKey || null) : [];
165
+
166
+ const rowDepth = rows.length;
167
+ const colDepth = colCols.length;
168
+ const rowKeysBuf = new Array(rowDepth);
169
+ const colKeysBuf = new Array(colDepth);
170
+
171
+ // Build tree roots inline — no extra passes over aggRows needed
172
+ const rowRoot = new Map();
173
+ const colRoot = new Map();
174
+
175
+ for (const row of aggRows) {
176
+ const val = Number(row[measureKey]) || 0;
177
+ grandTotal += val;
178
+
179
+ // Row keys + row tree in a single pass
180
+ let rNode = rowRoot;
181
+ for (let d = 0; d < rowDepth; d++) {
182
+ const v = String(row[rowCols[d]] ?? '');
183
+ const sortVal = rowSorts[d] ? row[rowSorts[d]] : v;
184
+ rowKeysBuf[d] = d === 0 ? v : rowKeysBuf[d - 1] + '→' + v;
185
+ if (!rNode.has(v)) rNode.set(v, { sortKey: sortVal, children: new Map() });
186
+ rNode = rNode.get(v).children;
187
+ }
188
+
189
+ // Column keys + column tree in a single pass
190
+ if (hasColumns) {
191
+ let cNode = colRoot;
192
+ for (let d = 0; d < colDepth; d++) {
193
+ const v = String(row[colCols[d]] ?? '');
194
+ const sortVal = colSorts[d] ? row[colSorts[d]] : v;
195
+ colKeysBuf[d] = d === 0 ? v : colKeysBuf[d - 1] + '→' + v;
196
+ if (!cNode.has(v)) cNode.set(v, { sortKey: sortVal, children: new Map() });
197
+ cNode = cNode.get(v).children;
198
+ }
199
+ }
200
+
201
+ // Accumulate cell values
202
+ for (let d = 0; d < rowDepth; d++) {
203
+ const rk = rowKeysBuf[d];
204
+ if (hasColumns) {
205
+ for (let cd = 0; cd < colDepth; cd++) {
206
+ const key = rk + '||' + colKeysBuf[cd];
207
+ cells.set(key, (cells.get(key) || 0) + val);
208
+ }
209
+ }
210
+ const totalKey = rk + '||__total__';
211
+ cells.set(totalKey, (cells.get(totalKey) || 0) + val);
212
+ }
213
+
214
+ if (hasColumns) {
215
+ for (let cd = 0; cd < colDepth; cd++) {
216
+ const gtKey = '__grand__||' + colKeysBuf[cd];
217
+ cells.set(gtKey, (cells.get(gtKey) || 0) + val);
218
+ }
219
+ }
220
+ }
221
+
222
+ // Map trees → node arrays
223
+ const toNodes = (map, depth, parentKey, maxDepth) =>
224
+ [...map.entries()]
225
+ .sort(([, a], [, b]) => {
226
+ const av = a.sortKey, bv = b.sortKey;
227
+ if (av !== bv && !isNaN(Number(av)) && !isNaN(Number(bv))) return Number(av) - Number(bv);
228
+ return String(av).localeCompare(String(bv), 'ru');
229
+ })
230
+ .map(([val, data]) => {
231
+ const code = parentKey ? parentKey + '→' + val : val;
232
+ const children = depth + 1 < maxDepth
233
+ ? toNodes(data.children, depth + 1, code, maxDepth)
234
+ : null;
235
+ return { value: val, code, depth, children };
236
+ });
237
+
238
+ const tree = toNodes(rowRoot, 0, '', rowDepth);
239
+ const colTree = hasColumns ? toNodes(colRoot, 0, '', colDepth) : null;
240
+ const colKeys = hasColumns ? this._flattenColTree(colTree) : [];
241
+
242
+ return { cells, colKeys, colTree, tree, grandTotal };
243
+ }
244
+
245
+ // ── Get the label value for a field from a row ───────────────────────────
246
+
247
+ _labelVal(row, field, fieldDefs) {
248
+ const def = fieldDefs[field] || {};
249
+ return String(row[def.label || field] ?? '');
250
+ }
251
+
252
+ // ── Tree ─────────────────────────────────────────────────────────────────
253
+
254
+ /**
255
+ * Builds the tree in a single pass over aggRows.
256
+ * Groups by label, sorts by sortKey (if present).
257
+ */
258
+ _buildTree(aggRows, levels, fieldDefs = {}) {
259
+ if (!levels.length) return null;
260
+
261
+ // Map<labelValue, { sortKey: value, children: Map }>
262
+ const root = new Map();
263
+
264
+ for (const row of aggRows) {
265
+ let node = root;
266
+ for (let d = 0; d < levels.length; d++) {
267
+ const field = levels[d];
268
+ const def = fieldDefs[field] || {};
269
+ const labelCol = def.label || field;
270
+ const sortCol = def.sortKey || null;
271
+ const labelVal = String(row[labelCol] ?? '');
272
+ const sortVal = sortCol ? row[sortCol] : labelVal;
273
+
274
+ if (!node.has(labelVal)) {
275
+ node.set(labelVal, { sortKey: sortVal, children: new Map() });
276
+ }
277
+ node = node.get(labelVal).children;
278
+ }
279
+ }
280
+
281
+ // Recursively convert Map → node tree
282
+ const toNodes = (map, depth, parentKey) => {
283
+ return [...map.entries()]
284
+ .sort(([, aData], [, bData]) => {
285
+ const a = aData.sortKey;
286
+ const b = bData.sortKey;
287
+ // Numeric sort if both values are numbers
288
+ if (a !== b && !isNaN(Number(a)) && !isNaN(Number(b))) {
289
+ return Number(a) - Number(b);
290
+ }
291
+ return String(a).localeCompare(String(b), 'ru');
292
+ })
293
+ .map(([val, data]) => {
294
+ const code = parentKey ? parentKey + '→' + val : val;
295
+ const children = depth + 1 < levels.length
296
+ ? toNodes(data.children, depth + 1, code)
297
+ : null;
298
+ return { value: val, code, depth, children };
299
+ });
300
+ };
301
+
302
+ return toNodes(root, 0, '');
303
+ }
304
+
305
+ // Flat list of all column tree nodes
306
+ _flattenColTree(nodes) {
307
+ const result = [];
308
+ const walk = (nodes) => {
309
+ for (const node of nodes) {
310
+ result.push({
311
+ code: node.code,
312
+ label: node.value,
313
+ depth: node.depth,
314
+ hasChildren: !!node.children,
315
+ });
316
+ if (node.children) walk(node.children);
317
+ }
318
+ };
319
+ walk(nodes);
320
+ return result;
321
+ }
322
+ }
323
+
324
+ /**
325
+ * ArrayProvider
326
+ *
327
+ * Provider for local array data.
328
+ * Implements the same interface as RestProvider —
329
+ * used for demo mode without a server.
330
+ */
331
+ class ArrayProvider {
332
+
333
+ constructor({ data, dimensions, measures, funcs, fields = {},
334
+ cachedDimensions = [], maxCachedRows = 500_000,
335
+ drillthroughQuery = null }) {
336
+ this.data = data;
337
+ this.dimensions = dimensions;
338
+ this.measures = measures;
339
+ this.funcs = funcs;
340
+ this.fields = fields;
341
+ this.maxCachedRows = maxCachedRows;
342
+ this.drillthroughQuery = drillthroughQuery;
343
+
344
+ this._cachedDims = [...cachedDimensions];
345
+ this._store = null;
346
+ this._cacheRows = 0;
347
+ }
348
+
349
+ // ── Cache API ──────────────────────────────────────────────────────────────
350
+
351
+ async prefetch() {
352
+ this._store = null;
353
+ this._cacheRows = 0;
354
+ if (!this._cachedDims.length) return;
355
+
356
+ const aggRows = this._groupBy(this._cachedDims);
357
+ this._store = this._makeStore(this._cachedDims, aggRows);
358
+ this._cacheRows = aggRows.length;
359
+ }
360
+
361
+ async countRows(logicalFields) {
362
+ if (!logicalFields.length) return 0;
363
+ const keys = new Set();
364
+ for (const row of this.data) {
365
+ keys.add(logicalFields.map(f => this._val(row, f)).join('|'));
366
+ }
367
+ return keys.size;
368
+ }
369
+
370
+ async refreshCache(newDims) {
371
+ this._cachedDims = [...newDims];
372
+ await this.prefetch();
373
+ }
374
+
375
+ get cachedDimensions() { return [...this._cachedDims]; }
376
+ get cacheRows() { return this._cacheRows; }
377
+
378
+ // ── Grid data ─────────────────────────────────────────────────────────────
379
+
380
+ getBestRows(requiredDims = [], activeFilters = {}) {
381
+ if (!this._store) return null;
382
+
383
+ const hasAllRequired = requiredDims.every(dim => {
384
+ const col = (this.fields[dim] || {}).label || dim;
385
+ return this._store.dimensions.includes(col);
386
+ });
387
+ if (!hasAllRequired) return null;
388
+
389
+ const filterDims = Object.keys(activeFilters);
390
+ const hasAllFilterDims = filterDims.every(dim => {
391
+ const col = (this.fields[dim] || {}).label || dim;
392
+ return this._store.dimensions.includes(col);
393
+ });
394
+ if (!hasAllFilterDims) return null;
395
+
396
+ const rows = this._store.rows();
397
+ return filterDims.length > 0
398
+ ? this._filterRows(rows, activeFilters)
399
+ : rows;
400
+ }
401
+
402
+ async getRowsForDims(requiredDims, activeFilters = {}) {
403
+ const cached = this.getBestRows(requiredDims, activeFilters);
404
+ if (cached) return { rows: cached, fromCache: true };
405
+
406
+ const rows = this._groupBy(requiredDims, activeFilters);
407
+ return { rows, fromCache: false };
408
+ }
409
+
410
+ // ── Drillthrough ───────────────────────────────────────────────────────────
411
+
412
+ async countDistinct(logicalField) {
413
+ const col = (this.fields[logicalField] || {}).label || logicalField;
414
+ const vals = new Set(this.data.map(r => String(r[col] ?? '')));
415
+ return vals.size;
416
+ }
417
+
418
+ async getDistinctValues(logicalField) {
419
+ const def = this.fields[logicalField] || {};
420
+ const col = def.label || logicalField;
421
+ const sortCol = def.sortKey || col;
422
+ const vals = [...new Set(this.data.map(r => String(r[col] ?? '')))];
423
+ return vals.sort((a, b) => {
424
+ const av = this.data.find(r => String(r[col]) === a)?.[sortCol];
425
+ const bv = this.data.find(r => String(r[col]) === b)?.[sortCol];
426
+ if (av !== undefined && bv !== undefined && !isNaN(Number(av)) && !isNaN(Number(bv))) {
427
+ return Number(av) - Number(bv);
428
+ }
429
+ return String(av ?? a).localeCompare(String(bv ?? b), 'ru');
430
+ });
431
+ }
432
+
433
+ async drillthrough({ filters = {}, limit = 200 }) {
434
+ let rows = this.data;
435
+
436
+ // Apply filters
437
+ for (const [dim, val] of Object.entries(filters)) {
438
+ const col = (this.fields[dim] || {}).label || dim;
439
+ rows = rows.filter(r => String(r[col] ?? '') === String(val));
440
+ }
441
+
442
+ return rows.slice(0, limit);
443
+ }
444
+
445
+ // ── Aggregation ───────────────────────────────────────────────────────────
446
+
447
+ _groupBy(logicalFields, activeFilters = {}) {
448
+ // Filter source data
449
+ let data = this.data;
450
+ if (Object.keys(activeFilters).length) {
451
+ data = this._filterRawRows(data, activeFilters);
452
+ }
453
+
454
+ const cols = logicalFields.map(f => (this.fields[f] || {}).label || f);
455
+ const groups = new Map();
456
+
457
+ for (const row of data) {
458
+ const key = cols.map(c => String(row[c] ?? '')).join('|§|');
459
+ if (!groups.has(key)) {
460
+ const entry = {};
461
+ for (const col of cols) entry[col] = row[col];
462
+ // Add sortKey columns if present
463
+ for (const f of logicalFields) {
464
+ const def = this.fields[f] || {};
465
+ if (def.sortKey) entry[def.sortKey] = row[def.sortKey];
466
+ }
467
+ // Initialize aggregates
468
+ for (const m of this.measures) {
469
+ for (const fn of this.funcs) {
470
+ entry[`${m}_${fn}`] = fn === 'min' ? Infinity : fn === 'max' ? -Infinity : 0;
471
+ }
472
+ entry[`__count_${m}`] = 0;
473
+ entry[`__sum2_${m}`] = 0;
474
+ }
475
+ groups.set(key, entry);
476
+ }
477
+ const entry = groups.get(key);
478
+ for (const m of this.measures) {
479
+ const v = Number(row[m]) || 0;
480
+ entry[`__count_${m}`]++;
481
+ entry[`${m}_sum`] = (entry[`${m}_sum`] || 0) + v;
482
+ entry[`${m}_count`]= entry[`__count_${m}`];
483
+ entry[`${m}_min`] = Math.min(entry[`${m}_min`] ?? Infinity, v);
484
+ entry[`${m}_max`] = Math.max(entry[`${m}_max`] ?? -Infinity, v);
485
+ entry[`__sum2_${m}`] += v * v;
486
+ }
487
+ }
488
+
489
+ // Compute avg, stddev, variance
490
+ for (const entry of groups.values()) {
491
+ for (const m of this.measures) {
492
+ const n = entry[`__count_${m}`] || 1;
493
+ const sum = entry[`${m}_sum`] || 0;
494
+ const sum2= entry[`__sum2_${m}`] || 0;
495
+ entry[`${m}_avg`] = sum / n;
496
+ const variance = sum2 / n - (sum / n) ** 2;
497
+ entry[`${m}_variance`] = variance;
498
+ entry[`${m}_stddev`] = Math.sqrt(Math.max(0, variance));
499
+ delete entry[`__count_${m}`];
500
+ delete entry[`__sum2_${m}`];
501
+ }
502
+ }
503
+
504
+ return [...groups.values()];
505
+ }
506
+
507
+ _filterRawRows(data, activeFilters) {
508
+ const predicates = [];
509
+ for (const [dim, filter] of Object.entries(activeFilters)) {
510
+ const col = (this.fields[dim] || {}).label || dim;
511
+ if (filter.values && filter.values.length > 0) {
512
+ const valSet = new Set(filter.values);
513
+ predicates.push(row => valSet.has(String(row[col] ?? '')));
514
+ }
515
+ if (filter.searchText) {
516
+ const text = filter.searchText.toLowerCase();
517
+ predicates.push(filter.searchType === 'starts_with'
518
+ ? row => String(row[col] ?? '').toLowerCase().startsWith(text)
519
+ : row => String(row[col] ?? '').toLowerCase().includes(text)
520
+ );
521
+ }
522
+ }
523
+ if (!predicates.length) return data;
524
+ return data.filter(row => predicates.every(p => p(row)));
525
+ }
526
+
527
+ _filterRows(rows, activeFilters) {
528
+ const predicates = [];
529
+ for (const [dim, filter] of Object.entries(activeFilters)) {
530
+ const col = (this.fields[dim] || {}).label || dim;
531
+ if (filter.values && filter.values.length > 0) {
532
+ const valSet = new Set(filter.values);
533
+ predicates.push(row => valSet.has(String(row[col] ?? '')));
534
+ }
535
+ if (filter.searchText) {
536
+ const text = filter.searchText.toLowerCase();
537
+ predicates.push(filter.searchType === 'starts_with'
538
+ ? row => String(row[col] ?? '').toLowerCase().startsWith(text)
539
+ : row => String(row[col] ?? '').toLowerCase().includes(text)
540
+ );
541
+ }
542
+ }
543
+ if (!predicates.length) return rows;
544
+ const filtered = [];
545
+ for (const row of rows) {
546
+ if (predicates.every(p => p(row))) filtered.push(row);
547
+ }
548
+ return filtered;
549
+ }
550
+
551
+ // ── Helpers ────────────────────────────────────────────────────────────────
552
+
553
+ _val(row, logicalField) {
554
+ const col = (this.fields[logicalField] || {}).label || logicalField;
555
+ return String(row[col] ?? '');
556
+ }
557
+
558
+ _makeStore(logicalFields, rows) {
559
+ const dims = logicalFields.map(f => (this.fields[f] || {}).label || f);
560
+ // Add sortKey columns
561
+ for (const f of logicalFields) {
562
+ const def = this.fields[f] || {};
563
+ if (def.sortKey && !dims.includes(def.sortKey)) dims.push(def.sortKey);
564
+ }
565
+ const store = new ColumnStore({
566
+ dimensions: dims,
567
+ measures: this.measures,
568
+ funcs: this.funcs,
569
+ capacity: this.maxCachedRows,
570
+ });
571
+ store.append(rows);
572
+ return store;
573
+ }
574
+
575
+ async load() {
576
+ throw new Error('Use prefetch() / getRowsForDims() / drillthrough()');
577
+ }
578
+ }
579
+
580
+ /**
581
+ * RestProvider
582
+ *
583
+ * Caching strategy:
584
+ * - On startup, one GROUP BY over cachedDimensions → cache (ColumnStore)
585
+ * - Everything else is lazy, not cached
586
+ * - countRows(dims) → COUNT query for UI validation before adding to cache
587
+ * - refreshCache(dims) → clear cache + new GROUP BY
588
+ */
589
+ class RestProvider {
590
+
591
+ constructor({ url, query, dimensions, measures, funcs, fields = {},
592
+ cachedDimensions = [], maxCachedRows = 500_000, drillthroughQuery = null }) {
593
+ this.url = url;
594
+ this.query = query;
595
+ this.dimensions = dimensions;
596
+ this.measures = measures;
597
+ this.funcs = funcs;
598
+ this.fields = fields;
599
+ this.maxCachedRows = maxCachedRows;
600
+
601
+ this._cachedDims = [...cachedDimensions];
602
+ this._store = null; // single ColumnStore cache
603
+ this._cacheRows = 0; // rows in cache after last prefetch
604
+
605
+ this.drillthroughQuery = drillthroughQuery;
606
+ }
607
+
608
+ // ── Public API ─────────────────────────────────────────────────────────────
609
+
610
+ /**
611
+ * Initial load: one GROUP BY over cachedDimensions.
612
+ * Does nothing if the list is empty.
613
+ */
614
+ async prefetch() {
615
+ this._store = null;
616
+ this._cacheRows = 0;
617
+
618
+ if (!this._cachedDims.length) return;
619
+
620
+ const rows = await this._fetchGroupBy(this._cachedDims);
621
+ this._store = this._makeStore(this._cachedDims, rows);
622
+ this._cacheRows = rows.length;
623
+ }
624
+
625
+ /**
626
+ * COUNT of rows for a GROUP BY over the given set of dimensions.
627
+ * Used by CacheManager to validate before adding a dimension to cache.
628
+ * @param {string[]} logicalFields — logical field names (from CONFIG.dimensions)
629
+ * @returns {Promise<number>}
630
+ */
631
+ async countRows(logicalFields) {
632
+ if (!logicalFields.length) return 0;
633
+ const cols = this._expandFields(logicalFields).join(', ');
634
+ const sql = `
635
+ SELECT COUNT(*) AS cnt
636
+ FROM (
637
+ SELECT ${cols}
638
+ FROM (${this.query}) _t
639
+ GROUP BY ${cols}
640
+ ) _g
641
+ `;
642
+ const rows = await this._execute(sql);
643
+ return Number(rows[0]?.cnt || 0);
644
+ }
645
+
646
+ /**
647
+ * Clears the cache and reloads GROUP BY over the new set of dimensions.
648
+ * Called by the "Refresh cache" button.
649
+ */
650
+ async refreshCache(newDims) {
651
+ this._cachedDims = [...newDims];
652
+ await this.prefetch();
653
+ }
654
+
655
+ /** Current list of cached dimensions. */
656
+ get cachedDimensions() { return [...this._cachedDims]; }
657
+
658
+ /** Number of rows in cache (updated after prefetch/refreshCache). */
659
+ get cacheRows() { return this._cacheRows; }
660
+
661
+ // ── Grid data ────────────────────────────────────────────────────────────────
662
+
663
+ /**
664
+ * Returns iterable rows from cache if the cache covers requiredDims.
665
+ * Otherwise returns null.
666
+ */
667
+ getBestRows(requiredDims = [], activeFilters = {}) {
668
+ if (!this._store) return null;
669
+
670
+ const hasAllRequired = requiredDims.every(dim => {
671
+ const col = (this.fields[dim] || {}).label || dim;
672
+ return this._store.dimensions.includes(col);
673
+ });
674
+ if (!hasAllRequired) return null;
675
+
676
+ // If any filter dimension is missing from store — fall back to lazy SQL with WHERE
677
+ const filterDims = Object.keys(activeFilters);
678
+ const hasAllFilterDims = filterDims.every(dim => {
679
+ const col = (this.fields[dim] || {}).label || dim;
680
+ return this._store.dimensions.includes(col);
681
+ });
682
+ if (!hasAllFilterDims) return null;
683
+
684
+ const rows = this._store.rows();
685
+ return filterDims.length > 0
686
+ ? this._filterRows(rows, activeFilters)
687
+ : rows;
688
+ }
689
+
690
+ async getRowsForDims(requiredDims, activeFilters = {}) {
691
+ const cached = this.getBestRows(requiredDims, activeFilters);
692
+ if (cached) return { rows: cached, fromCache: true };
693
+
694
+ const rows = await this._fetchGroupBy(requiredDims, activeFilters);
695
+ return { rows, fromCache: false };
696
+ }
697
+
698
+ /**
699
+ * Filters rows from cache by active filters (no server request).
700
+ */
701
+ _filterRows(rows, activeFilters) {
702
+ const predicates = [];
703
+ for (const [dim, filter] of Object.entries(activeFilters)) {
704
+ const col = (this.fields[dim] || {}).label || dim;
705
+ if (filter.values && filter.values.length > 0) {
706
+ const valSet = new Set(filter.values);
707
+ predicates.push(row => valSet.has(String(row[col] ?? '')));
708
+ }
709
+ if (filter.searchText) {
710
+ const text = filter.searchText.toLowerCase();
711
+ predicates.push(filter.searchType === 'starts_with'
712
+ ? row => String(row[col] ?? '').toLowerCase().startsWith(text)
713
+ : row => String(row[col] ?? '').toLowerCase().includes(text)
714
+ );
715
+ }
716
+ }
717
+ if (!predicates.length) return rows;
718
+
719
+ // Materialise into array — stable and predictable
720
+ const filtered = [];
721
+ for (const row of rows) {
722
+ if (predicates.every(p => p(row))) filtered.push(row);
723
+ }
724
+ return filtered;
725
+ }
726
+
727
+ // ── Drillthrough ───────────────────────────────────────────────────────────
728
+
729
+ async countDistinct(logicalField) {
730
+ const col = (this.fields[logicalField] || {}).label || logicalField;
731
+ const sql = `SELECT COUNT(DISTINCT ${col}) AS cnt FROM (${this.query}) _t`;
732
+ const rows = await this._execute(sql);
733
+ return Number(rows[0]?.cnt || 0);
734
+ }
735
+
736
+ async getDistinctValues(logicalField) {
737
+ const def = this.fields[logicalField] || {};
738
+ const col = def.label || logicalField;
739
+ const sortCol = def.sortKey || col;
740
+ const sql = sortCol !== col
741
+ ? `SELECT DISTINCT ${col}, ${sortCol} FROM (${this.query}) _t ORDER BY ${sortCol}`
742
+ : `SELECT DISTINCT ${col} FROM (${this.query}) _t ORDER BY ${sortCol}`;
743
+ const rows = await this._execute(sql);
744
+ return rows.map(r => String(r[col] ?? ''));
745
+ }
746
+
747
+ async drillthrough({ filters = {} }) {
748
+ const where = this._buildWhere(filters);
749
+ const sql = this.drillthroughQuery
750
+ ? this.drillthroughQuery.replace('{filters}', where ? where.replace('WHERE ', '') : '1=1')
751
+ : `SELECT * FROM (${this.query}) _t ${where} LIMIT 200`;
752
+ return this._execute(sql);
753
+ }
754
+
755
+ // ── SQL helpers ────────────────────────────────────────────────────────────
756
+
757
+ _fetchGroupBy(logicalFields, activeFilters = {}) {
758
+ const select = [];
759
+ const groupBy = [];
760
+ const orderBy = [];
761
+
762
+ for (const field of logicalFields) {
763
+ const def = this.fields[field] || {};
764
+ if (def.sortKey) {
765
+ select.push(def.sortKey, def.label);
766
+ groupBy.push(def.sortKey, def.label);
767
+ orderBy.push(def.sortKey);
768
+ } else {
769
+ const col = def.label || field;
770
+ select.push(col);
771
+ groupBy.push(col);
772
+ orderBy.push(col);
773
+ }
774
+ }
775
+
776
+ const aggExprs = this.measures.flatMap(m =>
777
+ this.funcs.map(fn => `${fn.toUpperCase()}(${m}) AS ${m}_${fn}`)
778
+ ).join(', ');
779
+
780
+ const where = this._buildFiltersWhere(activeFilters);
781
+
782
+ const sql = `
783
+ SELECT ${[...select, aggExprs].join(', ')}
784
+ FROM (${this.query}) _t
785
+ ${where}
786
+ GROUP BY ${groupBy.join(', ')}
787
+ ORDER BY ${orderBy.join(', ')}
788
+ `;
789
+
790
+ return this._execute(sql);
791
+ }
792
+
793
+ /** Builds a WHERE clause for active filters (for SQL queries). */
794
+ _buildFiltersWhere(activeFilters = {}) {
795
+ const conditions = [];
796
+ for (const [dim, filter] of Object.entries(activeFilters)) {
797
+ const col = (this.fields[dim] || {}).label || dim;
798
+
799
+ if (filter.values && filter.values.length > 0) {
800
+ const vals = filter.values
801
+ .map(v => `'${String(v).replace(/'/g, "''")}'`)
802
+ .join(', ');
803
+ conditions.push(`${col} IN (${vals})`);
804
+ }
805
+
806
+ if (filter.searchText) {
807
+ const esc = filter.searchText.replace(/'/g, "''");
808
+ conditions.push(filter.searchType === 'starts_with'
809
+ ? `${col} ILIKE '${esc}%'`
810
+ : `${col} ILIKE '%${esc}%'`
811
+ );
812
+ }
813
+ }
814
+ return conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
815
+ }
816
+
817
+ /** Expands logical field names into real DB column names. */
818
+ _expandFields(logicalFields) {
819
+ const cols = [];
820
+ for (const field of logicalFields) {
821
+ const def = this.fields[field] || {};
822
+ if (def.sortKey) cols.push(def.sortKey);
823
+ cols.push(def.label || field);
824
+ }
825
+ return cols;
826
+ }
827
+
828
+ _makeStore(logicalFields, rows) {
829
+ const dims = this._expandFields(logicalFields);
830
+ const store = new ColumnStore({
831
+ dimensions: dims,
832
+ measures: this.measures,
833
+ funcs: this.funcs,
834
+ capacity: this.maxCachedRows,
835
+ });
836
+ store.append(rows);
837
+ return store;
838
+ }
839
+
840
+ _buildWhere(filters) {
841
+ const keys = Object.keys(filters);
842
+ if (!keys.length) return '';
843
+ return 'WHERE ' + keys.map(k => {
844
+ const def = this.fields[k] || {};
845
+ const col = def.label || k;
846
+ return `${col} = '${String(filters[k]).replace(/'/g, "''")}'`;
847
+ }).join(' AND ');
848
+ }
849
+
850
+ // ── HTTP ───────────────────────────────────────────────────────────────────
851
+
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
+ });
858
+
859
+ if (!res.ok) {
860
+ const err = await res.json().catch(() => ({}));
861
+ throw new Error(`Server error ${res.status}: ${err.error || ''}`);
862
+ }
863
+
864
+ const rows = await res.json();
865
+ return rows.map(row => {
866
+ const out = {};
867
+ for (const k of Object.keys(row)) out[k.toLowerCase()] = row[k];
868
+ return out;
869
+ });
870
+ }
871
+
872
+ async load() {
873
+ throw new Error('Use prefetch() / getRowsForDims() / drillthrough()');
874
+ }
875
+ }
876
+
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
+ }
1796
+
1797
+ /**
1798
+ * FieldZones
1799
+ *
1800
+ * Drag-and-drop zones for managing pivot fields.
1801
+ * Three zones: FIELDS (free) → ROWS → COLUMNS
1802
+ *
1803
+ * Drop logic: insertion position is determined by the placeholder in the DOM —
1804
+ * no recalculation in onUp, just read what is already in the DOM.
1805
+ */
1806
+ class FieldZones {
1807
+
1808
+ constructor({ dimensions, fields = {}, initialRows, initialColumns, initialFilters = [], onChange, onFilterOpen }) {
1809
+ this.dimensions = dimensions;
1810
+ this._fieldDefs = fields;
1811
+ this.onChange = onChange;
1812
+ this.onFilterOpen = onFilterOpen;
1813
+
1814
+ this.rows = [...initialRows];
1815
+ this.columns = [...initialColumns];
1816
+ this.filters = [...initialFilters];
1817
+
1818
+ this.filterSet = new Set(initialFilters);
1819
+ this.state = {};
1820
+ for (const dim of dimensions) {
1821
+ if (initialRows.includes(dim)) this.state[dim] = 'rows';
1822
+ else if (initialColumns.includes(dim)) this.state[dim] = 'columns';
1823
+ else this.state[dim] = 'free';
1824
+ }
1825
+
1826
+ this._placeholder = null;
1827
+ this._render();
1828
+ this._bindEvents();
1829
+ this._lastMoved = null;
1830
+ this._filterHints = {}; // { dim → { badge, tooltip } }
1831
+ this._tooltip = document.createElement('div');
1832
+ this._tooltip.className = 'fz-tooltip';
1833
+ document.body.appendChild(this._tooltip);
1834
+ this._bindTooltip();
1835
+ }
1836
+
1837
+ setFilterHints(hints) {
1838
+ this._filterHints = hints || {};
1839
+ this._renderZone('fz-chips-filters', 'filters');
1840
+ }
1841
+
1842
+ _bindTooltip() {
1843
+ document.addEventListener('mouseover', (e) => {
1844
+ const chip = e.target.closest('.fz-chip[data-zone="filters"]');
1845
+ const hint = chip && this._filterHints[chip.dataset.field];
1846
+ if (!hint) { this._tooltip.style.display = 'none'; return; }
1847
+ this._tooltip.textContent = hint.tooltip;
1848
+ this._tooltip.style.display = 'block';
1849
+ });
1850
+
1851
+ document.addEventListener('mousemove', (e) => {
1852
+ if (this._tooltip.style.display === 'none') return;
1853
+ const t = this._tooltip;
1854
+ const x = Math.min(e.clientX + 12, window.innerWidth - t.offsetWidth - 8);
1855
+ const y = e.clientY - t.offsetHeight - 8; // above cursor
1856
+ t.style.left = x + 'px';
1857
+ t.style.top = (y < 4 ? e.clientY + 16 : y) + 'px'; // if no space above — below cursor
1858
+ });
1859
+
1860
+ document.addEventListener('mouseout', (e) => {
1861
+ const chip = e.target.closest('.fz-chip[data-zone="filters"]');
1862
+ if (chip && !chip.contains(e.relatedTarget)) {
1863
+ this._tooltip.style.display = 'none';
1864
+ }
1865
+ });
1866
+ }
1867
+
1868
+ // ── Render ───────────────────────────────────────────────────────────────
1869
+
1870
+ _render() {
1871
+ this._renderZone('fz-chips-free', 'free');
1872
+ this._renderZone('fz-chips-rows', 'rows');
1873
+ this._renderZone('fz-chips-columns', 'columns');
1874
+ this._renderZone('fz-chips-filters', 'filters');
1875
+ }
1876
+
1877
+ _renderZone(containerId, zone) {
1878
+ const el = document.getElementById(containerId);
1879
+ if (!el) return;
1880
+
1881
+ const fields = zone === 'rows' ? this.rows
1882
+ : zone === 'columns' ? this.columns
1883
+ : zone === 'filters' ? [...this.filterSet]
1884
+ : this.dimensions.filter(d => this.state[d] === 'free' && !this.filterSet.has(d));
1885
+
1886
+ el.innerHTML = fields.map(f => {
1887
+ const hint = (zone === 'filters') ? this._filterHints[f] : null;
1888
+ const tooltip = hint ? `${f}: ${hint.tooltip}` : f;
1889
+ return `
1890
+ <div class="fz-chip${hint ? ' fz-chip--filtered' : ''}"
1891
+ data-field="${f}" data-zone="${zone}" title="">
1892
+ <span class="fz-chip-label" draggable="false">${this._fieldDefs[f]?.title || this._fieldDefs[f]?.label || f}</span>
1893
+ ${hint ? `<span class="fz-chip-hint" draggable="false">${hint.badge}</span>` : ''}
1894
+ ${zone !== 'free'
1895
+ ? `<span class="fz-chip-remove" draggable="false" data-field="${f}" data-zone="${zone}">×</span>`
1896
+ : ''}
1897
+ </div>
1898
+ `;
1899
+ }).join('');
1900
+
1901
+ if (zone === 'filters' && this.onFilterOpen) {
1902
+ el.querySelectorAll('.fz-chip').forEach(chip => {
1903
+ chip.querySelector('.fz-chip-label').addEventListener('click', (e) => {
1904
+ e.stopPropagation();
1905
+ this.onFilterOpen(chip.dataset.field, chip);
1906
+ });
1907
+ });
1908
+ }
1909
+
1910
+ if (this._lastMoved) {
1911
+ const chip = el.querySelector(`[data-field="${this._lastMoved}"]`);
1912
+ chip?.classList.add('fz-chip--last');
1913
+ }
1914
+ }
1915
+
1916
+ // ── Events ───────────────────────────────────────────────────────────────
1917
+
1918
+ _bindEvents() {
1919
+ document.addEventListener('mousedown', (e) => {
1920
+ if (e.target.classList.contains('fz-chip-remove')) {
1921
+ e.stopPropagation();
1922
+ this._moveField(e.target.dataset.field, e.target.dataset.zone, 'free');
1923
+ return;
1924
+ }
1925
+ const chip = e.target.closest('.fz-chip');
1926
+ if (!chip) return;
1927
+ this._initDrag(e, chip);
1928
+ });
1929
+ }
1930
+
1931
+ // ── Drag & Drop ───────────────────────────────────────────────────────────
1932
+
1933
+ _initDrag(e, chip) {
1934
+ e.preventDefault();
1935
+
1936
+ const field = chip.dataset.field;
1937
+ const fromZone = chip.dataset.zone;
1938
+ const startX = e.clientX;
1939
+ const startY = e.clientY;
1940
+ let dragging = false;
1941
+ let ghost = null;
1942
+
1943
+ const onMove = (mv) => {
1944
+ if (!dragging && Math.hypot(mv.clientX - startX, mv.clientY - startY) > 5) {
1945
+ dragging = true;
1946
+ chip.classList.add('fz-chip--dragging');
1947
+ ghost = this._createGhost(chip, mv);
1948
+ }
1949
+ if (!dragging) return;
1950
+
1951
+ ghost.style.left = mv.clientX + 12 + 'px';
1952
+ ghost.style.top = mv.clientY - 12 + 'px';
1953
+
1954
+ // Hide ghost and dragging chip — otherwise elementFromPoint picks them up
1955
+ ghost.style.visibility = 'hidden';
1956
+ chip.style.visibility = 'hidden';
1957
+ this._updatePlaceholder(mv);
1958
+ ghost.style.visibility = '';
1959
+ chip.style.visibility = '';
1960
+ };
1961
+
1962
+ const onUp = () => {
1963
+ document.removeEventListener('mousemove', onMove);
1964
+ document.removeEventListener('mouseup', onUp);
1965
+
1966
+ ghost?.remove();
1967
+ chip.classList.remove('fz-chip--dragging');
1968
+ chip.style.visibility = '';
1969
+
1970
+ if (!dragging) {
1971
+ this._clearHighlight();
1972
+ return;
1973
+ }
1974
+
1975
+ // Read final drop position from placeholder
1976
+ const ph = this._placeholder;
1977
+ if (!ph?.parentNode) {
1978
+ this._clearHighlight();
1979
+ return;
1980
+ }
1981
+
1982
+ // Zone — placeholder container
1983
+ const zoneEl = ph.parentNode.closest('[data-fz-zone]') || ph.parentNode;
1984
+ const toZone = zoneEl.dataset.fzZone;
1985
+
1986
+ // beforeField — first fz-chip after the placeholder
1987
+ const siblings = [...ph.parentNode.children];
1988
+ const phIdx = siblings.indexOf(ph);
1989
+ const afterChips = siblings.slice(phIdx + 1).filter(el => el.classList.contains('fz-chip'));
1990
+ const beforeField = afterChips[0]?.dataset.field || null;
1991
+
1992
+ this._clearHighlight();
1993
+
1994
+ if (!toZone) return;
1995
+
1996
+ if (toZone !== fromZone) {
1997
+ this._moveFieldBefore(field, fromZone, toZone, beforeField);
1998
+ } else {
1999
+ this._reorder(field, fromZone, beforeField);
2000
+ }
2001
+ };
2002
+
2003
+ document.addEventListener('mousemove', onMove);
2004
+ document.addEventListener('mouseup', onUp);
2005
+ }
2006
+
2007
+ // ── Placeholder ──────────────────────────────────────────────────────────
2008
+
2009
+ _updatePlaceholder(e) {
2010
+ this._clearHighlight();
2011
+
2012
+ // Find zone under cursor
2013
+ const zoneEl = document.elementFromPoint(e.clientX, e.clientY)
2014
+ ?.closest('[data-fz-zone]');
2015
+ if (zoneEl) zoneEl.classList.add('fz-zone--over');
2016
+
2017
+ const ph = document.createElement('div');
2018
+ ph.className = 'fz-chip-placeholder';
2019
+ this._placeholder = ph;
2020
+
2021
+ // Find chip under cursor
2022
+ const target = document.elementFromPoint(e.clientX, e.clientY)?.closest('.fz-chip');
2023
+
2024
+ if (target && !target.classList.contains('fz-chip-placeholder')) {
2025
+ const rect = target.getBoundingClientRect();
2026
+ const insertBefore = e.clientX < rect.left + rect.width / 2;
2027
+ target.parentNode.insertBefore(ph, insertBefore ? target : target.nextSibling);
2028
+ } else if (zoneEl) {
2029
+ // No chip target — append to end of zone
2030
+ const chipsContainer = zoneEl.querySelector('[id^="fz-chips"]') || zoneEl;
2031
+ chipsContainer.appendChild(ph);
2032
+ }
2033
+ }
2034
+
2035
+ _clearHighlight() {
2036
+ document.querySelectorAll('[data-fz-zone]')
2037
+ .forEach(z => z.classList.remove('fz-zone--over'));
2038
+ this._placeholder?.remove();
2039
+ this._placeholder = null;
2040
+ }
2041
+
2042
+ // ── Ghost ────────────────────────────────────────────────────────────────
2043
+
2044
+ _createGhost(chip, e) {
2045
+ const ghost = chip.cloneNode(true);
2046
+ ghost.className = 'fz-chip fz-chip--ghost';
2047
+ Object.assign(ghost.style, {
2048
+ position: 'fixed',
2049
+ left: e.clientX + 12 + 'px',
2050
+ top: e.clientY - 12 + 'px',
2051
+ pointerEvents: 'none',
2052
+ zIndex: '9999',
2053
+ opacity: '0.85',
2054
+ });
2055
+ document.body.appendChild(ghost);
2056
+ return ghost;
2057
+ }
2058
+
2059
+ // ── State mutations ──────────────────────────────────────────────────────
2060
+
2061
+ _moveField(field, fromZone, toZone) {
2062
+ if (toZone === 'filters') {
2063
+ // Add to filters, do NOT remove from rows/columns
2064
+ this.filterSet.add(field);
2065
+ } else if (fromZone === 'filters') {
2066
+ // Remove from filters (× click), primary zone unchanged
2067
+ this.filterSet.delete(field);
2068
+ } else {
2069
+ // Move between rows/columns/free
2070
+ if (fromZone === 'rows') this.rows = this.rows.filter(f => f !== field);
2071
+ if (fromZone === 'columns') this.columns = this.columns.filter(f => f !== field);
2072
+ if (toZone === 'rows') this.rows.push(field);
2073
+ if (toZone === 'columns') this.columns.push(field);
2074
+ this.state[field] = toZone;
2075
+ }
2076
+ this._lastMoved = field;
2077
+ this._render();
2078
+ this.onChange({ rows: [...this.rows], columns: [...this.columns], filters: [...this.filterSet] });
2079
+ }
2080
+
2081
+ _moveFieldBefore(field, fromZone, toZone, beforeField) {
2082
+ if (toZone === 'filters') {
2083
+ this.filterSet.add(field);
2084
+ this._lastMoved = field;
2085
+ this._render();
2086
+ this.onChange({ rows: [...this.rows], columns: [...this.columns], filters: [...this.filterSet] });
2087
+ return;
2088
+ }
2089
+
2090
+ if (fromZone === 'filters') {
2091
+ // Dragged from filters to rows/columns — add there, keep in filters
2092
+ const arr = toZone === 'rows' ? this.rows : toZone === 'columns' ? this.columns : null;
2093
+ if (arr && !arr.includes(field)) {
2094
+ if (beforeField) {
2095
+ const idx = arr.indexOf(beforeField);
2096
+ arr.splice(idx !== -1 ? idx : arr.length, 0, field);
2097
+ } else {
2098
+ arr.push(field);
2099
+ }
2100
+ this.state[field] = toZone;
2101
+ }
2102
+ } else {
2103
+ if (fromZone === 'rows') this.rows = this.rows.filter(f => f !== field);
2104
+ if (fromZone === 'columns') this.columns = this.columns.filter(f => f !== field);
2105
+ const arr = toZone === 'rows' ? this.rows : toZone === 'columns' ? this.columns : null;
2106
+ if (arr) {
2107
+ if (beforeField) {
2108
+ const idx = arr.indexOf(beforeField);
2109
+ arr.splice(idx !== -1 ? idx : arr.length, 0, field);
2110
+ } else {
2111
+ arr.push(field);
2112
+ }
2113
+ }
2114
+ this.state[field] = toZone;
2115
+ }
2116
+
2117
+ this._lastMoved = field;
2118
+ this._render();
2119
+ this.onChange({ rows: [...this.rows], columns: [...this.columns], filters: [...this.filterSet] });
2120
+ }
2121
+
2122
+ _reorder(field, zone, beforeField) {
2123
+ const arr = zone === 'rows' ? this.rows : this.columns;
2124
+ const from = arr.indexOf(field);
2125
+ if (from === -1) return;
2126
+
2127
+ arr.splice(from, 1);
2128
+
2129
+ if (beforeField) {
2130
+ const to = arr.indexOf(beforeField);
2131
+ arr.splice(to !== -1 ? to : arr.length, 0, field);
2132
+ } else {
2133
+ arr.push(field); // placeholder was at the end — append to end
2134
+ }
2135
+ this._lastMoved = field;
2136
+ this._render();
2137
+ // this.onChange({ rows: [...this.rows], columns: [...this.columns], filters: [...this.filters] });
2138
+ this.onChange({ rows: [...this.rows], columns: [...this.columns], filters: [...this.filterSet] });
2139
+ }
2140
+ }
2141
+
2142
+ /**
2143
+ * FilterManager
2144
+ *
2145
+ * Manages filters per dimension:
2146
+ * - If values ≤ filterCheckboxLimit → checkboxes + search
2147
+ * - If more → text search only (contains / starts_with)
2148
+ * - Values are fetched from the provider cache or from the server
2149
+ */
2150
+ class FilterManager {
2151
+
2152
+ constructor({ provider, fields, config }) {
2153
+ this.provider = provider;
2154
+ this.fields = fields;
2155
+ this.config = config;
2156
+
2157
+ // dim → { allValues, selected, searchType, searchText }
2158
+ this._state = {};
2159
+ this._popup = null;
2160
+ this._openDim = null;
2161
+ this._onChange = null;
2162
+ }
2163
+
2164
+ set onChange(fn) { this._onChange = fn; }
2165
+
2166
+ // ── Dimension management ──────────────────────────────────────────────────
2167
+
2168
+ async onDimAdded(dim) {
2169
+ if (this._state[dim]) {
2170
+ // already exists — just open the popup (FieldZones will call onFilterOpen)
2171
+ return;
2172
+ }
2173
+
2174
+ this._state[dim] = {
2175
+ allValues: null,
2176
+ selected: null, // null = all (no checkbox filter)
2177
+ searchType: 'contains',
2178
+ searchText: '',
2179
+ };
2180
+
2181
+ // Load values if checkboxes are needed
2182
+ try {
2183
+ const limit = this.config.filterCheckboxLimit ?? 30;
2184
+ const count = await this.provider.countDistinct(dim);
2185
+ if (count <= limit) {
2186
+ this._state[dim].allValues = await this.provider.getDistinctValues(dim);
2187
+ }
2188
+ } catch (e) {
2189
+ console.warn('FilterManager: failed to load values for', dim, e);
2190
+ }
2191
+ }
2192
+
2193
+ onDimRemoved(dim) {
2194
+ delete this._state[dim];
2195
+ if (this._openDim === dim) this._closePopup();
2196
+ this._notify();
2197
+ }
2198
+
2199
+ // ── Popup ────────────────────────────────────────────────────────────────
2200
+
2201
+ openFor(dim, anchorEl) {
2202
+ this._closePopup();
2203
+ const f = this._state[dim];
2204
+ if (!f) return;
2205
+
2206
+ this._openDim = dim;
2207
+
2208
+ const popup = document.createElement('div');
2209
+ popup.className = 'fm-popup';
2210
+ document.body.appendChild(popup);
2211
+ this._popup = popup;
2212
+
2213
+ this._renderPopup(popup, dim, f);
2214
+ this._positionPopup(popup, anchorEl);
2215
+
2216
+ requestAnimationFrame(() => {
2217
+ document.addEventListener('mousedown', this._handleOutside = (e) => {
2218
+ if (!popup.contains(e.target) && !anchorEl.contains(e.target)) {
2219
+ this._closePopup();
2220
+ }
2221
+ });
2222
+ });
2223
+ }
2224
+
2225
+ _renderPopup(popup, dim, f) {
2226
+ const radioName = 'fm-stype-' + dim;
2227
+ const checkboxesHTML = f.allValues
2228
+ ? `<div class="fm-checkbox-list">
2229
+ <label class="fm-checkbox fm-select-all-wrap">
2230
+ <input type="checkbox" class="fm-select-all" ${!f.selected ? 'checked' : ''}>
2231
+ <em>All values</em>
2232
+ </label>
2233
+ <div class="fm-checkbox-scroll">
2234
+ ${f.allValues.map(v => `
2235
+ <label class="fm-checkbox">
2236
+ <input type="checkbox" value="${v.replace(/"/g, '&quot;')}"
2237
+ ${!f.selected || f.selected.has(v) ? 'checked' : ''}>
2238
+ <span>${v}</span>
2239
+ </label>
2240
+ `).join('')}
2241
+ </div>
2242
+ </div>`
2243
+ : `<p class="fm-no-checkboxes">Too many values — use search</p>`;
2244
+
2245
+ popup.innerHTML = `
2246
+ <div class="fm-popup-header">
2247
+ <span class="fm-popup-title">${this.fields[dim]?.title || this.fields[dim]?.label || dim}</span>
2248
+ <button class="fm-popup-close">×</button>
2249
+ </div>
2250
+ <div class="fm-search-section">
2251
+ <div class="fm-search-type">
2252
+ <label><input type="radio" name="${radioName}" value="contains"
2253
+ ${f.searchType === 'contains' ? 'checked' : ''}> Contains</label>
2254
+ <label><input type="radio" name="${radioName}" value="starts_with"
2255
+ ${f.searchType === 'starts_with' ? 'checked' : ''}> Starts with</label>
2256
+ </div>
2257
+ <input type="text" class="fm-search-input" placeholder="Search..." value="${f.searchText}">
2258
+ </div>
2259
+ ${checkboxesHTML}
2260
+ <div class="fm-popup-footer">
2261
+ <button class="fm-btn fm-btn-clear">Clear</button>
2262
+ <button class="fm-btn fm-btn-primary">Apply</button>
2263
+ </div>
2264
+ `;
2265
+
2266
+ const searchInput = popup.querySelector('.fm-search-input');
2267
+ const selectAll = popup.querySelector('.fm-select-all');
2268
+
2269
+ popup.querySelector('.fm-popup-close').onclick = () => this._closePopup();
2270
+
2271
+ // Sync the "Select all" checkbox state based on visible rows
2272
+ const syncSelectAll = () => {
2273
+ if (!selectAll) return;
2274
+ const visible = [...popup.querySelectorAll('.fm-checkbox:not(.fm-select-all-wrap)')]
2275
+ .filter(l => l.style.display !== 'none');
2276
+ const checkedCount = visible.filter(l => l.querySelector('input').checked).length;
2277
+ selectAll.indeterminate = checkedCount > 0 && checkedCount < visible.length;
2278
+ selectAll.checked = visible.length > 0 && checkedCount === visible.length;
2279
+ };
2280
+
2281
+ // Search: hides non-matching rows and unchecks them.
2282
+ // Important: hidden items must not silently end up in the selection on Apply.
2283
+ const applyCheckboxSearch = () => {
2284
+ const q = searchInput?.value.trim().toLowerCase() || '';
2285
+ const type = popup.querySelector(`input[name="${radioName}"]:checked`)?.value || 'contains';
2286
+
2287
+ popup.querySelectorAll('.fm-checkbox:not(.fm-select-all-wrap)').forEach(label => {
2288
+ const text = label.querySelector('span')?.textContent.trim().toLowerCase() || '';
2289
+ const ok = !q || (type === 'starts_with' ? text.startsWith(q) : text.includes(q));
2290
+
2291
+ label.style.display = ok ? '' : 'none';
2292
+
2293
+ // Uncheck hidden items — if the user clears the search text later,
2294
+ // these items will reappear unchecked, not "selected by default"
2295
+ if (!ok) label.querySelector('input').checked = false;
2296
+ });
2297
+
2298
+ syncSelectAll();
2299
+ };
2300
+
2301
+ // Search type — apply to checkboxes immediately
2302
+ popup.querySelectorAll(`input[name="${radioName}"]`).forEach(r => {
2303
+ r.onchange = () => {
2304
+ f.searchType = r.value;
2305
+ applyCheckboxSearch();
2306
+ };
2307
+ });
2308
+
2309
+ // Text search
2310
+ searchInput?.addEventListener('input', applyCheckboxSearch);
2311
+
2312
+ // "Select all" / "Deselect all" — affects ALL checkboxes, including hidden ones.
2313
+ // Otherwise unchecking "All values" with active search would not reset hidden items.
2314
+ selectAll?.addEventListener('change', () => {
2315
+ popup.querySelectorAll('.fm-checkbox:not(.fm-select-all-wrap) input')
2316
+ .forEach(cb => { cb.checked = selectAll.checked; });
2317
+ });
2318
+
2319
+ // Clear
2320
+ popup.querySelector('.fm-btn-clear').onclick = () => {
2321
+ f.selected = null;
2322
+ f.searchText = '';
2323
+ this._closePopup();
2324
+ this._notify();
2325
+ };
2326
+
2327
+ // Apply
2328
+ popup.querySelector('.fm-btn-primary').onclick = () => {
2329
+ const checkedRadio = popup.querySelector(`input[name="${radioName}"]:checked`);
2330
+ f.searchType = checkedRadio ? checkedRadio.value : 'contains';
2331
+ f.searchText = searchInput?.value.trim() || '';
2332
+
2333
+ if (f.allValues) {
2334
+ const checkedBoxes = [...popup.querySelectorAll('.fm-checkbox:not(.fm-select-all-wrap) input:checked')]
2335
+ .map(cb => cb.value);
2336
+ f.selected = checkedBoxes.length === f.allValues.length ? null : new Set(checkedBoxes);
2337
+ }
2338
+
2339
+ this._closePopup();
2340
+ this._notify();
2341
+ };
2342
+
2343
+ // Apply current filter immediately on popup open —
2344
+ // so checkboxes immediately reflect the saved searchText / searchType
2345
+ if (f.searchText) applyCheckboxSearch();
2346
+ }
2347
+
2348
+ _positionPopup(popup, anchorEl) {
2349
+ const rect = anchorEl.getBoundingClientRect();
2350
+ const left = Math.min(rect.left, window.innerWidth - 290);
2351
+ popup.style.left = left + 'px';
2352
+ popup.style.top = (rect.bottom + 6) + 'px';
2353
+ }
2354
+
2355
+ _closePopup() {
2356
+ this._popup?.remove();
2357
+ this._popup = null;
2358
+ this._openDim = null;
2359
+ if (this._handleOutside) {
2360
+ document.removeEventListener('mousedown', this._handleOutside);
2361
+ this._handleOutside = null;
2362
+ }
2363
+ }
2364
+
2365
+ // ── Active filters ────────────────────────────────────────────────────────
2366
+
2367
+ /**
2368
+ * Returns active filters in the format expected by the provider.
2369
+ * { dim: { values: string[]|null, searchType, searchText } }
2370
+ */
2371
+ getActiveFilters() {
2372
+ const result = {};
2373
+ for (const [dim, f] of Object.entries(this._state)) {
2374
+ const hasSelected = f.selected && f.selected.size > 0;
2375
+ const hasSearch = f.searchText.length > 0;
2376
+ if (hasSelected || hasSearch) {
2377
+ result[dim] = {
2378
+ values: hasSelected ? [...f.selected] : null,
2379
+ searchType: f.searchType,
2380
+ searchText: f.searchText,
2381
+ };
2382
+ }
2383
+ }
2384
+ return result;
2385
+ }
2386
+
2387
+ hasActiveFilter(dim) {
2388
+ const f = this._state[dim];
2389
+ if (!f) return false;
2390
+ return (f.selected && f.selected.size > 0) || f.searchText.length > 0;
2391
+ }
2392
+
2393
+ getFilterHints() {
2394
+ const result = {};
2395
+ for (const [dim, f] of Object.entries(this._state)) {
2396
+ const parts = [];
2397
+
2398
+ if (f.searchText) {
2399
+ const prefix = f.searchType === 'starts_with' ? '' : '…';
2400
+ parts.push(`«${f.searchText}${prefix}»`);
2401
+ }
2402
+
2403
+ if (f.selected && f.selected.size > 0) {
2404
+ const total = f.allValues?.length ?? null;
2405
+ // If few items are excluded — show "except"
2406
+ if (total && total - f.selected.size <= 2) {
2407
+ const excluded = f.allValues.filter(v => !f.selected.has(v));
2408
+ parts.push(`except: ${excluded.join(', ')}`);
2409
+ } else if (f.selected.size <= 3) {
2410
+ parts.push([...f.selected].join(', '));
2411
+ } else {
2412
+ parts.push(`${f.selected.size} val.`);
2413
+ }
2414
+ }
2415
+
2416
+ if (parts.length) {
2417
+ result[dim] = {
2418
+ badge: parts.length === 1 && f.selected?.size
2419
+ ? String(f.selected.size) // just a number on the chip
2420
+ : '✕',
2421
+ tooltip: parts.join(' + '),
2422
+ };
2423
+ }
2424
+ }
2425
+ return result;
2426
+ }
2427
+
2428
+ _notify() {
2429
+ this._onChange?.();
2430
+ }
2431
+ }
2432
+
2433
+ /**
2434
+ * i18n.js — переводы интерфейса PivotGrid
2435
+ *
2436
+ * Язык задаётся через data-lang на контейнере:
2437
+ * <div data-config="sales" data-lang="en"></div>
2438
+ *
2439
+ * По умолчанию: ru
2440
+ * Добавить язык: добавить новый ключ в I18N.
2441
+ */
2442
+ const I18N = {
2443
+ ru: {
2444
+ loading: 'Загрузка данных...',
2445
+ loadingGrid: 'Загрузка данных…',
2446
+ cache: 'Кэш',
2447
+ constructor: 'Конструктор',
2448
+ filters: 'Фильтры',
2449
+ fields: 'Поля',
2450
+ rows: 'Строки',
2451
+ columns: 'Колонки',
2452
+ measure: 'Мера',
2453
+ func: 'Функция',
2454
+ expandRows: 'Развернуть строки',
2455
+ collapseRows: 'Свернуть строки',
2456
+ expandCols: 'Развернуть колонки',
2457
+ collapseCols: 'Свернуть колонки',
2458
+ subtotals: '∑ Подитоги',
2459
+ exportCsv: '↓ CSV',
2460
+ drillthrough: 'Drillthrough',
2461
+ allData: 'Все данные',
2462
+ noData: 'Нет данных',
2463
+ shown: 'Показано',
2464
+ firstN: 'Первые {n} записей',
2465
+ total: 'Итого',
2466
+ cacheEmpty: 'Кэш пуст',
2467
+ cacheActual: 'Актуален',
2468
+ cacheStale: 'Изменён · нужно обновить',
2469
+ cacheRefresh: '↺ Обновить кэш',
2470
+ errorPrefix: 'Ошибка: ',
2471
+ cacheAdd: 'Добавить в кэш',
2472
+ cacheRemove: 'Убрать из кэша',
2473
+ cacheExceeds: 'строк — превышает лимит',
2474
+ cacheRefreshError: 'Ошибка обновления кэша: ',
2475
+ title: 'Сводная таблица',
2476
+ // config-editor
2477
+ ce_server: 'Сервер',
2478
+ ce_database: 'База данных',
2479
+ ce_query: 'Запрос',
2480
+ ce_mainQuery: 'Основной query',
2481
+ ce_colsQuery: 'Запрос для получения колонок',
2482
+ ce_funcs: 'Функции агрегации',
2483
+ ce_fetchCols: 'Получить колонки',
2484
+ ce_drillthrough: 'Drillthrough',
2485
+ ce_cols: 'Колонки',
2486
+ ce_colDb: 'Колонка БД',
2487
+ ce_colTitle: 'Название (title)',
2488
+ ce_initialState: 'Начальное состояние',
2489
+ ce_maxCacheRows: 'Макс. строк кэша',
2490
+ ce_filterLimit: 'Лимит чекбоксов',
2491
+ ce_configJs: 'config.js',
2492
+ ce_save: 'Сохранить',
2493
+ ce_loadFromServer: 'Загрузить с сервера',
2494
+ ce_preview: '▶ Предпросмотр',
2495
+ ce_saveConfig: '↑ Сохранить',
2496
+ ce_selectConfig: '— выбрать конфиг —',
2497
+ ce_configName: 'Имя конфига',
2498
+ ce_dimension: 'Измерение',
2499
+ ce_measure_type: 'Мера',
2500
+ ce_fillUrl: 'Заполните URL и запрос',
2501
+ ce_zeroRows: 'Запрос вернул 0 строк',
2502
+ ce_colsLoaded: 'Получено {n} колонок',
2503
+ ce_loadError: 'Ошибка: ',
2504
+ ce_fetchBtn: 'Получить колонки',
2505
+ ce_emptyFields: 'Нажмите «Получить колонки»',
2506
+ ce_fillDbFields: 'Заполните host, database и user',
2507
+ ce_dbSaved: 'Настройки сохранены',
2508
+ ce_dbLoaded: 'Настройки загружены (пароль не передаётся)',
2509
+ ce_enterName: 'Введите имя конфига',
2510
+ ce_emptyConfig: 'Конфиг пуст',
2511
+ ce_configSaved: 'Конфиг «{name}» сохранён',
2512
+ ce_loadFailed: 'Ошибка загрузки: ',
2513
+ ce_saveFailed: 'Ошибка сохранения: ',
2514
+ ce_connector: 'Коннектор',
2515
+ ce_dtSqlLabel: 'SELECT запрос (используй {filters})',
2516
+ ce_dtUrlLabel: 'Base URL (фильтры добавятся как ?region=Север)',
2517
+ ce_testConnection: 'Проверить соединение',
2518
+ ce_testOk: 'Соединение успешно',
2519
+ ce_testError: 'Ошибка соединения: ',
2520
+ confirmLargeExpand: 'Слишком много строк для отображения.\n\nПосле разворачивания грид будет содержать ~{millions} млн строк, что превышает возможности браузера. Часть данных в нижней части будет недоступна.\n\nРекомендуем свернуть часть измерений.\n\nНажмите ОК чтобы всё равно развернуть, Отмена чтобы отказаться.',
2521
+ ce_newConfig: 'Новый',
2522
+ ce_deleteConfig: 'Удалить',
2523
+ ce_confirmDelete: 'Удалить конфиг «{name}»?',
2524
+ ce_deleteOk: 'Конфиг «{name}» удалён',
2525
+ ce_deleteFailed: 'Ошибка удаления: ',
2526
+ },
2527
+ en: {
2528
+ loading: 'Loading...',
2529
+ loadingGrid: 'Loading data…',
2530
+ cache: 'Cache',
2531
+ constructor: 'Constructor',
2532
+ filters: 'Filters',
2533
+ fields: 'Fields',
2534
+ rows: 'Rows',
2535
+ columns: 'Columns',
2536
+ measure: 'Measure',
2537
+ func: 'Function',
2538
+ expandRows: 'Expand rows',
2539
+ collapseRows: 'Collapse rows',
2540
+ expandCols: 'Expand columns',
2541
+ collapseCols: 'Collapse columns',
2542
+ subtotals: '∑ Subtotals',
2543
+ exportCsv: '↓ CSV',
2544
+ drillthrough: 'Drillthrough',
2545
+ allData: 'All data',
2546
+ noData: 'No data',
2547
+ shown: 'Shown',
2548
+ firstN: 'First {n} records',
2549
+ total: 'Total',
2550
+ cacheEmpty: 'Cache empty',
2551
+ cacheActual: 'Up to date',
2552
+ cacheStale: 'Changed · refresh needed',
2553
+ cacheRefresh: '↺ Refresh cache',
2554
+ errorPrefix: 'Error: ',
2555
+ cacheAdd: 'Add to cache',
2556
+ cacheRemove: 'Remove from cache',
2557
+ cacheExceeds: 'rows — exceeds limit',
2558
+ cacheRefreshError: 'Cache refresh error: ',
2559
+ title: 'Pivot Table',
2560
+ // config-editor
2561
+ ce_server: 'Server',
2562
+ ce_database: 'Database',
2563
+ ce_query: 'Query',
2564
+ ce_mainQuery: 'Main query',
2565
+ ce_colsQuery: 'Query to fetch columns',
2566
+ ce_funcs: 'Aggregation functions',
2567
+ ce_fetchCols: 'Fetch columns',
2568
+ ce_drillthrough: 'Drillthrough',
2569
+ ce_cols: 'Columns',
2570
+ ce_colDb: 'DB column',
2571
+ ce_colTitle: 'Title',
2572
+ ce_initialState: 'Initial state',
2573
+ ce_maxCacheRows: 'Max cache rows',
2574
+ ce_filterLimit: 'Checkbox limit',
2575
+ ce_configJs: 'config.js',
2576
+ ce_save: 'Save',
2577
+ ce_loadFromServer: 'Load from server',
2578
+ ce_preview: '▶ Preview',
2579
+ ce_saveConfig: '↑ Save',
2580
+ ce_selectConfig: '— select config —',
2581
+ ce_configName: 'Config name',
2582
+ ce_dimension: 'Dimension',
2583
+ ce_measure_type: 'Measure',
2584
+ ce_fillUrl: 'Fill in URL and query',
2585
+ ce_zeroRows: 'Query returned 0 rows',
2586
+ ce_colsLoaded: '{n} columns fetched',
2587
+ ce_loadError: 'Error: ',
2588
+ ce_fetchBtn: 'Fetch columns',
2589
+ ce_emptyFields: 'Click "Fetch columns"',
2590
+ ce_fillDbFields: 'Fill in host, database and user',
2591
+ ce_dbSaved: 'Settings saved',
2592
+ ce_dbLoaded: 'Settings loaded (password not included)',
2593
+ ce_enterName: 'Enter config name',
2594
+ ce_emptyConfig: 'Config is empty',
2595
+ ce_configSaved: 'Config "{name}" saved',
2596
+ ce_loadFailed: 'Load error: ',
2597
+ ce_saveFailed: 'Save error: ',
2598
+ ce_connector: 'Connector',
2599
+ ce_dtSqlLabel: 'SELECT query (use {filters})',
2600
+ ce_dtUrlLabel: 'Base URL (filters will be appended as ?region=North)',
2601
+ ce_testConnection: 'Test connection',
2602
+ ce_testOk: 'Connection successful',
2603
+ ce_testError: 'Connection error: ',
2604
+ confirmLargeExpand: 'Too many rows to display.\n\nAfter expanding, the grid will contain ~{millions}M rows which exceeds browser limits. Some rows at the bottom may not be reachable.\n\nConsider collapsing some row dimensions.\n\nClick OK to expand anyway, or Cancel to abort.',
2605
+ ce_newConfig: 'New',
2606
+ ce_deleteConfig: 'Delete',
2607
+ ce_confirmDelete: 'Delete config "{name}"?',
2608
+ ce_deleteOk: 'Config "{name}" deleted',
2609
+ ce_deleteFailed: 'Delete error: ',
2610
+ },
2611
+ };
2612
+
2613
+ /**
2614
+ * cache-manager.js
2615
+ *
2616
+ * Manages the dimension cache UI:
2617
+ * - Renders dimension chips with cached/uncached state
2618
+ * - Shows a fill meter and status label
2619
+ * - Validates row count before adding a dimension to cache
2620
+ * - Triggers cache refresh via the provider
2621
+ */
2622
+ class CacheManager {
2623
+
2624
+ /**
2625
+ * @param {object} options
2626
+ * @param {object} options.provider — data provider (RestProvider or ArrayProvider)
2627
+ * @param {string[]} options.dimensions — full list of dimensions
2628
+ * @param {number} options.maxCachedRows — row limit for the cache
2629
+ * @param {number} options.initialCount — current row count in cache
2630
+ * @param {Function} options.onRefresh — callback after cache refresh
2631
+ * @param {string} [options.lang='ru'] — UI language
2632
+ */
2633
+ constructor({ provider, dimensions, maxCachedRows, initialCount, onRefresh, lang = 'ru' }) {
2634
+ this._provider = provider;
2635
+ this._dims = dimensions;
2636
+ this._maxRows = maxCachedRows;
2637
+ this._cached = new Set(provider.cachedDimensions);
2638
+ this._count = initialCount;
2639
+ this._stale = false;
2640
+ this._checking = false;
2641
+ this._toastTimer = null;
2642
+ this._onRefresh = onRefresh;
2643
+ this._t = (key, vars = {}) => {
2644
+ let str = (I18N[lang] || I18N.ru)[key] || key;
2645
+ for (const [k, v] of Object.entries(vars)) str = str.replace(`{${k}}`, v);
2646
+ return str;
2647
+ };
2648
+
2649
+ this._render();
2650
+ this._bindRefresh();
2651
+ }
2652
+
2653
+ // ── Render ────────────────────────────────────────────────────────────────
2654
+
2655
+ /** Renders all UI parts: chips, meter, status. */
2656
+ _render() {
2657
+ this._renderChips();
2658
+ this._renderMeter();
2659
+ this._renderStatus();
2660
+ }
2661
+
2662
+ /** Renders dimension chips with cached/uncached state. */
2663
+ _renderChips() {
2664
+ const body = document.getElementById('cache-chips');
2665
+ body.innerHTML = '';
2666
+
2667
+ for (const dim of this._dims) {
2668
+ const chip = document.createElement('div');
2669
+ chip.className = 'cache-chip' + (this._cached.has(dim) ? ' is-cached' : '');
2670
+ chip.dataset.dim = dim;
2671
+ const def = CONFIG.fields[dim] || {};
2672
+ chip.textContent = def.title || def.label || dim;
2673
+ chip.title = this._cached.has(dim)
2674
+ ? this._t('cacheRemove')
2675
+ : this._t('cacheAdd');
2676
+ chip.addEventListener('click', () => this._toggle(dim));
2677
+ body.appendChild(chip);
2678
+ }
2679
+ }
2680
+
2681
+ /** Returns the chip element for a given dimension. */
2682
+ _chip(dim) {
2683
+ return document.querySelector(`.cache-chip[data-dim="${dim}"]`);
2684
+ }
2685
+
2686
+ /** Updates the fill meter bar and label. */
2687
+ _renderMeter() {
2688
+ const fill = document.getElementById('cache-meter-fill');
2689
+ const label = document.getElementById('cache-meter-label');
2690
+ const pct = this._cached.size > 0
2691
+ ? Math.min(this._count / this._maxRows * 100, 100)
2692
+ : 0;
2693
+ const cls = pct < 60 ? 'ok' : pct < 85 ? 'warn' : 'danger';
2694
+
2695
+ fill.style.width = pct.toFixed(1) + '%';
2696
+ fill.className = `cache-meter-fill ${cls}`;
2697
+
2698
+ if (this._cached.size === 0) {
2699
+ label.textContent = '—';
2700
+ label.className = 'cache-meter-label';
2701
+ } else {
2702
+ label.textContent = `~${this._count.toLocaleString()} / ${this._maxRows.toLocaleString()}`;
2703
+ label.className = `cache-meter-label ${cls}`;
2704
+ }
2705
+ }
2706
+
2707
+ /** Updates the cache status label and refresh button state. */
2708
+ _renderStatus() {
2709
+ const status = document.getElementById('cache-status');
2710
+ const btn = document.getElementById('btn-refresh-cache');
2711
+
2712
+ if (this._stale) {
2713
+ status.textContent = this._t('cacheStale');
2714
+ status.className = 'cache-status stale';
2715
+ btn.disabled = false;
2716
+ btn.classList.add('cache-refresh-btn--stale');
2717
+ } else if (this._cached.size === 0) {
2718
+ status.textContent = this._t('cacheEmpty');
2719
+ status.className = 'cache-status empty';
2720
+ btn.disabled = true;
2721
+ btn.classList.remove('cache-refresh-btn--stale');
2722
+ } else {
2723
+ status.textContent = this._t('cacheActual');
2724
+ status.className = 'cache-status fresh';
2725
+ btn.disabled = true;
2726
+ btn.classList.remove('cache-refresh-btn--stale');
2727
+ }
2728
+ }
2729
+
2730
+ // ── Toggle ────────────────────────────────────────────────────────────────
2731
+
2732
+ /**
2733
+ * Toggles a dimension in/out of the cache set.
2734
+ * When adding, runs a COUNT query to validate the row limit first.
2735
+ * @param {string} dim — logical dimension name
2736
+ */
2737
+ async _toggle(dim) {
2738
+ if (this._checking) return;
2739
+
2740
+ const chip = this._chip(dim);
2741
+ if (!chip) return;
2742
+
2743
+ if (this._cached.has(dim)) {
2744
+ this._cached.delete(dim);
2745
+ chip.className = 'cache-chip';
2746
+ chip.title = this._t('cacheAdd');
2747
+ this._stale = true;
2748
+ this._renderStatus();
2749
+ this._refreshCountAsync();
2750
+ return;
2751
+ }
2752
+
2753
+ this._checking = true;
2754
+ chip.className = 'cache-chip is-checking';
2755
+
2756
+ try {
2757
+ const trial = [...this._cached, dim];
2758
+ const count = await this._provider.countRows(trial);
2759
+
2760
+ if (count > this._maxRows) {
2761
+ chip.className = 'cache-chip is-rejected';
2762
+ this._showToast(
2763
+ `«${dim}»: ~${count.toLocaleString()} — ${this._t('cacheExceeds')} ${this._maxRows.toLocaleString()}`
2764
+ );
2765
+ setTimeout(() => {
2766
+ chip.className = 'cache-chip';
2767
+ chip.title = this._t('cacheAdd');
2768
+ }, 700);
2769
+ } else {
2770
+ this._cached.add(dim);
2771
+ this._count = count;
2772
+ this._stale = true;
2773
+ chip.className = 'cache-chip is-cached';
2774
+ chip.title = this._t('cacheRemove');
2775
+ }
2776
+ } catch (err) {
2777
+ chip.className = 'cache-chip';
2778
+ this._showToast(this._t('errorPrefix') + (err.message || err));
2779
+ } finally {
2780
+ this._checking = false;
2781
+ this._renderMeter();
2782
+ this._renderStatus();
2783
+ }
2784
+ }
2785
+
2786
+ /**
2787
+ * Refreshes the row count for the current cached dimensions asynchronously.
2788
+ * Used after removing a dimension from cache.
2789
+ */
2790
+ async _refreshCountAsync() {
2791
+ if (this._cached.size === 0) {
2792
+ this._count = 0;
2793
+ this._renderMeter();
2794
+ return;
2795
+ }
2796
+ try {
2797
+ this._count = await this._provider.countRows([...this._cached]);
2798
+ } catch {
2799
+ this._count = 0;
2800
+ }
2801
+ this._renderMeter();
2802
+ }
2803
+
2804
+ // ── Refresh ───────────────────────────────────────────────────────────────
2805
+
2806
+ /** Binds the "Refresh cache" button click handler. */
2807
+ _bindRefresh() {
2808
+ const btn = document.getElementById('btn-refresh-cache');
2809
+ const zone = document.querySelector('.cache-zone');
2810
+
2811
+ btn.addEventListener('click', async () => {
2812
+ btn.disabled = true;
2813
+ zone.classList.add('cache-zone--loading');
2814
+ this._showFullscreenLoader(true);
2815
+
2816
+ try {
2817
+ await this._provider.refreshCache([...this._cached]);
2818
+ this._count = this._provider.cacheRows;
2819
+ this._stale = false;
2820
+ this._renderChips();
2821
+ this._renderMeter();
2822
+ this._renderStatus();
2823
+ await this._onRefresh?.();
2824
+ } catch (err) {
2825
+ this._showToast(this._t('cacheRefreshError') + (err.message || err));
2826
+ } finally {
2827
+ this._showFullscreenLoader(false);
2828
+ zone.classList.remove('cache-zone--loading');
2829
+ this._renderStatus();
2830
+ }
2831
+ });
2832
+ }
2833
+
2834
+ // ── Toast / Loader ────────────────────────────────────────────────────────
2835
+
2836
+ /**
2837
+ * Shows or hides the fullscreen loading overlay.
2838
+ * @param {boolean} on
2839
+ */
2840
+ _showFullscreenLoader(on) {
2841
+ let el = document.getElementById('cache-fullscreen-loader');
2842
+ if (on) {
2843
+ if (!el) {
2844
+ el = document.createElement('div');
2845
+ el.id = 'cache-fullscreen-loader';
2846
+ el.textContent = this._t('loading');
2847
+ document.body.appendChild(el);
2848
+ }
2849
+ } else {
2850
+ el?.remove();
2851
+ }
2852
+ }
2853
+
2854
+ /**
2855
+ * Shows a brief toast notification at the bottom of the screen.
2856
+ * @param {string} msg — message to display
2857
+ */
2858
+ _showToast(msg) {
2859
+ const toast = document.getElementById('cache-toast');
2860
+ toast.textContent = msg;
2861
+ toast.classList.add('visible');
2862
+ clearTimeout(this._toastTimer);
2863
+ this._toastTimer = setTimeout(() => toast.classList.remove('visible'), 3500);
2864
+ }
2865
+ }
2866
+
2867
+ export { DictionaryEncoder, ColumnStore, Aggregator, ArrayProvider, RestProvider, PivotGrid, FieldZones, FilterManager, CacheManager, I18N };