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.
- package/LICENSE +29 -0
- package/LICENSE.commercial +60 -0
- package/README.dev.md +247 -0
- package/README.md +253 -0
- package/config/config-editor.css +298 -0
- package/config/config-editor.html +202 -0
- package/config/config-editor.js +687 -0
- package/demo_data/demo-config.js +38 -0
- package/demo_data/demo-data.js +1 -0
- package/dist/pivotgrid.cjs.js +2867 -0
- package/dist/pivotgrid.css +1091 -0
- package/dist/pivotgrid.esm.js +2867 -0
- package/dist/pivotgrid.js +2865 -0
- package/dist/pivotgrid.min.js +18 -0
- package/engine/aggregator.js +193 -0
- package/engine/column-store.js +99 -0
- package/engine/dictionary-encoder.js +30 -0
- package/package.json +50 -0
- package/providers/array-provider.js +255 -0
- package/providers/rest-provider.js +296 -0
- package/server/.env +5 -0
- package/server/README.md +88 -0
- package/server/configs/main_config.json +112 -0
- package/server/connectors/__init__.py +0 -0
- package/server/connectors/__pycache__/postgresql.cpython-312.pyc +0 -0
- package/server/connectors/postgresql.py +34 -0
- package/server/server.py +328 -0
- package/src/field-zones.css +167 -0
- package/src/field-zones.js +344 -0
- package/src/filter-manager.js +290 -0
- package/src/pivot.css +252 -0
- package/src/pivot.js +919 -0
- package/widget/cache-manager.js +253 -0
- package/widget/i18n.js +179 -0
- package/widget/pivot-widget.js +572 -0
- package/widget/widget.css +672 -0
|
@@ -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, '"')}"
|
|
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 };
|