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,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FieldZones
|
|
3
|
+
*
|
|
4
|
+
* Drag-and-drop zones for managing pivot fields.
|
|
5
|
+
* Three zones: FIELDS (free) → ROWS → COLUMNS
|
|
6
|
+
*
|
|
7
|
+
* Drop logic: insertion position is determined by the placeholder in the DOM —
|
|
8
|
+
* no recalculation in onUp, just read what is already in the DOM.
|
|
9
|
+
*/
|
|
10
|
+
class FieldZones {
|
|
11
|
+
|
|
12
|
+
constructor({ dimensions, fields = {}, initialRows, initialColumns, initialFilters = [], onChange, onFilterOpen }) {
|
|
13
|
+
this.dimensions = dimensions;
|
|
14
|
+
this._fieldDefs = fields;
|
|
15
|
+
this.onChange = onChange;
|
|
16
|
+
this.onFilterOpen = onFilterOpen;
|
|
17
|
+
|
|
18
|
+
this.rows = [...initialRows];
|
|
19
|
+
this.columns = [...initialColumns];
|
|
20
|
+
this.filters = [...initialFilters];
|
|
21
|
+
|
|
22
|
+
this.filterSet = new Set(initialFilters);
|
|
23
|
+
this.state = {};
|
|
24
|
+
for (const dim of dimensions) {
|
|
25
|
+
if (initialRows.includes(dim)) this.state[dim] = 'rows';
|
|
26
|
+
else if (initialColumns.includes(dim)) this.state[dim] = 'columns';
|
|
27
|
+
else this.state[dim] = 'free';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
this._placeholder = null;
|
|
31
|
+
this._render();
|
|
32
|
+
this._bindEvents();
|
|
33
|
+
this._lastMoved = null;
|
|
34
|
+
this._filterHints = {}; // { dim → { badge, tooltip } }
|
|
35
|
+
this._tooltip = document.createElement('div');
|
|
36
|
+
this._tooltip.className = 'fz-tooltip';
|
|
37
|
+
document.body.appendChild(this._tooltip);
|
|
38
|
+
this._bindTooltip();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setFilterHints(hints) {
|
|
42
|
+
this._filterHints = hints || {};
|
|
43
|
+
this._renderZone('fz-chips-filters', 'filters');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_bindTooltip() {
|
|
47
|
+
document.addEventListener('mouseover', (e) => {
|
|
48
|
+
const chip = e.target.closest('.fz-chip[data-zone="filters"]');
|
|
49
|
+
const hint = chip && this._filterHints[chip.dataset.field];
|
|
50
|
+
if (!hint) { this._tooltip.style.display = 'none'; return; }
|
|
51
|
+
this._tooltip.textContent = hint.tooltip;
|
|
52
|
+
this._tooltip.style.display = 'block';
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
document.addEventListener('mousemove', (e) => {
|
|
56
|
+
if (this._tooltip.style.display === 'none') return;
|
|
57
|
+
const t = this._tooltip;
|
|
58
|
+
const x = Math.min(e.clientX + 12, window.innerWidth - t.offsetWidth - 8);
|
|
59
|
+
const y = e.clientY - t.offsetHeight - 8; // above cursor
|
|
60
|
+
t.style.left = x + 'px';
|
|
61
|
+
t.style.top = (y < 4 ? e.clientY + 16 : y) + 'px'; // if no space above — below cursor
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
document.addEventListener('mouseout', (e) => {
|
|
65
|
+
const chip = e.target.closest('.fz-chip[data-zone="filters"]');
|
|
66
|
+
if (chip && !chip.contains(e.relatedTarget)) {
|
|
67
|
+
this._tooltip.style.display = 'none';
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Render ───────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
_render() {
|
|
75
|
+
this._renderZone('fz-chips-free', 'free');
|
|
76
|
+
this._renderZone('fz-chips-rows', 'rows');
|
|
77
|
+
this._renderZone('fz-chips-columns', 'columns');
|
|
78
|
+
this._renderZone('fz-chips-filters', 'filters');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_renderZone(containerId, zone) {
|
|
82
|
+
const el = document.getElementById(containerId);
|
|
83
|
+
if (!el) return;
|
|
84
|
+
|
|
85
|
+
const fields = zone === 'rows' ? this.rows
|
|
86
|
+
: zone === 'columns' ? this.columns
|
|
87
|
+
: zone === 'filters' ? [...this.filterSet]
|
|
88
|
+
: this.dimensions.filter(d => this.state[d] === 'free' && !this.filterSet.has(d));
|
|
89
|
+
|
|
90
|
+
el.innerHTML = fields.map(f => {
|
|
91
|
+
const hint = (zone === 'filters') ? this._filterHints[f] : null;
|
|
92
|
+
const tooltip = hint ? `${f}: ${hint.tooltip}` : f;
|
|
93
|
+
return `
|
|
94
|
+
<div class="fz-chip${hint ? ' fz-chip--filtered' : ''}"
|
|
95
|
+
data-field="${f}" data-zone="${zone}" title="">
|
|
96
|
+
<span class="fz-chip-label" draggable="false">${this._fieldDefs[f]?.title || this._fieldDefs[f]?.label || f}</span>
|
|
97
|
+
${hint ? `<span class="fz-chip-hint" draggable="false">${hint.badge}</span>` : ''}
|
|
98
|
+
${zone !== 'free'
|
|
99
|
+
? `<span class="fz-chip-remove" draggable="false" data-field="${f}" data-zone="${zone}">×</span>`
|
|
100
|
+
: ''}
|
|
101
|
+
</div>
|
|
102
|
+
`;
|
|
103
|
+
}).join('');
|
|
104
|
+
|
|
105
|
+
if (zone === 'filters' && this.onFilterOpen) {
|
|
106
|
+
el.querySelectorAll('.fz-chip').forEach(chip => {
|
|
107
|
+
chip.querySelector('.fz-chip-label').addEventListener('click', (e) => {
|
|
108
|
+
e.stopPropagation();
|
|
109
|
+
this.onFilterOpen(chip.dataset.field, chip);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (this._lastMoved) {
|
|
115
|
+
const chip = el.querySelector(`[data-field="${this._lastMoved}"]`);
|
|
116
|
+
chip?.classList.add('fz-chip--last');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Events ───────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
_bindEvents() {
|
|
123
|
+
document.addEventListener('mousedown', (e) => {
|
|
124
|
+
if (e.target.classList.contains('fz-chip-remove')) {
|
|
125
|
+
e.stopPropagation();
|
|
126
|
+
this._moveField(e.target.dataset.field, e.target.dataset.zone, 'free');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const chip = e.target.closest('.fz-chip');
|
|
130
|
+
if (!chip) return;
|
|
131
|
+
this._initDrag(e, chip);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Drag & Drop ───────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
_initDrag(e, chip) {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
|
|
140
|
+
const field = chip.dataset.field;
|
|
141
|
+
const fromZone = chip.dataset.zone;
|
|
142
|
+
const startX = e.clientX;
|
|
143
|
+
const startY = e.clientY;
|
|
144
|
+
let dragging = false;
|
|
145
|
+
let ghost = null;
|
|
146
|
+
|
|
147
|
+
const onMove = (mv) => {
|
|
148
|
+
if (!dragging && Math.hypot(mv.clientX - startX, mv.clientY - startY) > 5) {
|
|
149
|
+
dragging = true;
|
|
150
|
+
chip.classList.add('fz-chip--dragging');
|
|
151
|
+
ghost = this._createGhost(chip, mv);
|
|
152
|
+
}
|
|
153
|
+
if (!dragging) return;
|
|
154
|
+
|
|
155
|
+
ghost.style.left = mv.clientX + 12 + 'px';
|
|
156
|
+
ghost.style.top = mv.clientY - 12 + 'px';
|
|
157
|
+
|
|
158
|
+
// Hide ghost and dragging chip — otherwise elementFromPoint picks them up
|
|
159
|
+
ghost.style.visibility = 'hidden';
|
|
160
|
+
chip.style.visibility = 'hidden';
|
|
161
|
+
this._updatePlaceholder(mv);
|
|
162
|
+
ghost.style.visibility = '';
|
|
163
|
+
chip.style.visibility = '';
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const onUp = () => {
|
|
167
|
+
document.removeEventListener('mousemove', onMove);
|
|
168
|
+
document.removeEventListener('mouseup', onUp);
|
|
169
|
+
|
|
170
|
+
ghost?.remove();
|
|
171
|
+
chip.classList.remove('fz-chip--dragging');
|
|
172
|
+
chip.style.visibility = '';
|
|
173
|
+
|
|
174
|
+
if (!dragging) {
|
|
175
|
+
this._clearHighlight();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Read final drop position from placeholder
|
|
180
|
+
const ph = this._placeholder;
|
|
181
|
+
if (!ph?.parentNode) {
|
|
182
|
+
this._clearHighlight();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Zone — placeholder container
|
|
187
|
+
const zoneEl = ph.parentNode.closest('[data-fz-zone]') || ph.parentNode;
|
|
188
|
+
const toZone = zoneEl.dataset.fzZone;
|
|
189
|
+
|
|
190
|
+
// beforeField — first fz-chip after the placeholder
|
|
191
|
+
const siblings = [...ph.parentNode.children];
|
|
192
|
+
const phIdx = siblings.indexOf(ph);
|
|
193
|
+
const afterChips = siblings.slice(phIdx + 1).filter(el => el.classList.contains('fz-chip'));
|
|
194
|
+
const beforeField = afterChips[0]?.dataset.field || null;
|
|
195
|
+
|
|
196
|
+
this._clearHighlight();
|
|
197
|
+
|
|
198
|
+
if (!toZone) return;
|
|
199
|
+
|
|
200
|
+
if (toZone !== fromZone) {
|
|
201
|
+
this._moveFieldBefore(field, fromZone, toZone, beforeField);
|
|
202
|
+
} else {
|
|
203
|
+
this._reorder(field, fromZone, beforeField);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
document.addEventListener('mousemove', onMove);
|
|
208
|
+
document.addEventListener('mouseup', onUp);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Placeholder ──────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
_updatePlaceholder(e) {
|
|
214
|
+
this._clearHighlight();
|
|
215
|
+
|
|
216
|
+
// Find zone under cursor
|
|
217
|
+
const zoneEl = document.elementFromPoint(e.clientX, e.clientY)
|
|
218
|
+
?.closest('[data-fz-zone]');
|
|
219
|
+
if (zoneEl) zoneEl.classList.add('fz-zone--over');
|
|
220
|
+
|
|
221
|
+
const ph = document.createElement('div');
|
|
222
|
+
ph.className = 'fz-chip-placeholder';
|
|
223
|
+
this._placeholder = ph;
|
|
224
|
+
|
|
225
|
+
// Find chip under cursor
|
|
226
|
+
const target = document.elementFromPoint(e.clientX, e.clientY)?.closest('.fz-chip');
|
|
227
|
+
|
|
228
|
+
if (target && !target.classList.contains('fz-chip-placeholder')) {
|
|
229
|
+
const rect = target.getBoundingClientRect();
|
|
230
|
+
const insertBefore = e.clientX < rect.left + rect.width / 2;
|
|
231
|
+
target.parentNode.insertBefore(ph, insertBefore ? target : target.nextSibling);
|
|
232
|
+
} else if (zoneEl) {
|
|
233
|
+
// No chip target — append to end of zone
|
|
234
|
+
const chipsContainer = zoneEl.querySelector('[id^="fz-chips"]') || zoneEl;
|
|
235
|
+
chipsContainer.appendChild(ph);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
_clearHighlight() {
|
|
240
|
+
document.querySelectorAll('[data-fz-zone]')
|
|
241
|
+
.forEach(z => z.classList.remove('fz-zone--over'));
|
|
242
|
+
this._placeholder?.remove();
|
|
243
|
+
this._placeholder = null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Ghost ────────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
_createGhost(chip, e) {
|
|
249
|
+
const ghost = chip.cloneNode(true);
|
|
250
|
+
ghost.className = 'fz-chip fz-chip--ghost';
|
|
251
|
+
Object.assign(ghost.style, {
|
|
252
|
+
position: 'fixed',
|
|
253
|
+
left: e.clientX + 12 + 'px',
|
|
254
|
+
top: e.clientY - 12 + 'px',
|
|
255
|
+
pointerEvents: 'none',
|
|
256
|
+
zIndex: '9999',
|
|
257
|
+
opacity: '0.85',
|
|
258
|
+
});
|
|
259
|
+
document.body.appendChild(ghost);
|
|
260
|
+
return ghost;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── State mutations ──────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
_moveField(field, fromZone, toZone) {
|
|
266
|
+
if (toZone === 'filters') {
|
|
267
|
+
// Add to filters, do NOT remove from rows/columns
|
|
268
|
+
this.filterSet.add(field);
|
|
269
|
+
} else if (fromZone === 'filters') {
|
|
270
|
+
// Remove from filters (× click), primary zone unchanged
|
|
271
|
+
this.filterSet.delete(field);
|
|
272
|
+
} else {
|
|
273
|
+
// Move between rows/columns/free
|
|
274
|
+
if (fromZone === 'rows') this.rows = this.rows.filter(f => f !== field);
|
|
275
|
+
if (fromZone === 'columns') this.columns = this.columns.filter(f => f !== field);
|
|
276
|
+
if (toZone === 'rows') this.rows.push(field);
|
|
277
|
+
if (toZone === 'columns') this.columns.push(field);
|
|
278
|
+
this.state[field] = toZone;
|
|
279
|
+
}
|
|
280
|
+
this._lastMoved = field;
|
|
281
|
+
this._render();
|
|
282
|
+
this.onChange({ rows: [...this.rows], columns: [...this.columns], filters: [...this.filterSet] });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
_moveFieldBefore(field, fromZone, toZone, beforeField) {
|
|
286
|
+
if (toZone === 'filters') {
|
|
287
|
+
this.filterSet.add(field);
|
|
288
|
+
this._lastMoved = field;
|
|
289
|
+
this._render();
|
|
290
|
+
this.onChange({ rows: [...this.rows], columns: [...this.columns], filters: [...this.filterSet] });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (fromZone === 'filters') {
|
|
295
|
+
// Dragged from filters to rows/columns — add there, keep in filters
|
|
296
|
+
const arr = toZone === 'rows' ? this.rows : toZone === 'columns' ? this.columns : null;
|
|
297
|
+
if (arr && !arr.includes(field)) {
|
|
298
|
+
if (beforeField) {
|
|
299
|
+
const idx = arr.indexOf(beforeField);
|
|
300
|
+
arr.splice(idx !== -1 ? idx : arr.length, 0, field);
|
|
301
|
+
} else {
|
|
302
|
+
arr.push(field);
|
|
303
|
+
}
|
|
304
|
+
this.state[field] = toZone;
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
if (fromZone === 'rows') this.rows = this.rows.filter(f => f !== field);
|
|
308
|
+
if (fromZone === 'columns') this.columns = this.columns.filter(f => f !== field);
|
|
309
|
+
const arr = toZone === 'rows' ? this.rows : toZone === 'columns' ? this.columns : null;
|
|
310
|
+
if (arr) {
|
|
311
|
+
if (beforeField) {
|
|
312
|
+
const idx = arr.indexOf(beforeField);
|
|
313
|
+
arr.splice(idx !== -1 ? idx : arr.length, 0, field);
|
|
314
|
+
} else {
|
|
315
|
+
arr.push(field);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
this.state[field] = toZone;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
this._lastMoved = field;
|
|
322
|
+
this._render();
|
|
323
|
+
this.onChange({ rows: [...this.rows], columns: [...this.columns], filters: [...this.filterSet] });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
_reorder(field, zone, beforeField) {
|
|
327
|
+
const arr = zone === 'rows' ? this.rows : this.columns;
|
|
328
|
+
const from = arr.indexOf(field);
|
|
329
|
+
if (from === -1) return;
|
|
330
|
+
|
|
331
|
+
arr.splice(from, 1);
|
|
332
|
+
|
|
333
|
+
if (beforeField) {
|
|
334
|
+
const to = arr.indexOf(beforeField);
|
|
335
|
+
arr.splice(to !== -1 ? to : arr.length, 0, field);
|
|
336
|
+
} else {
|
|
337
|
+
arr.push(field); // placeholder was at the end — append to end
|
|
338
|
+
}
|
|
339
|
+
this._lastMoved = field;
|
|
340
|
+
this._render();
|
|
341
|
+
// this.onChange({ rows: [...this.rows], columns: [...this.columns], filters: [...this.filters] });
|
|
342
|
+
this.onChange({ rows: [...this.rows], columns: [...this.columns], filters: [...this.filterSet] });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterManager
|
|
3
|
+
*
|
|
4
|
+
* Manages filters per dimension:
|
|
5
|
+
* - If values ≤ filterCheckboxLimit → checkboxes + search
|
|
6
|
+
* - If more → text search only (contains / starts_with)
|
|
7
|
+
* - Values are fetched from the provider cache or from the server
|
|
8
|
+
*/
|
|
9
|
+
class FilterManager {
|
|
10
|
+
|
|
11
|
+
constructor({ provider, fields, config }) {
|
|
12
|
+
this.provider = provider;
|
|
13
|
+
this.fields = fields;
|
|
14
|
+
this.config = config;
|
|
15
|
+
|
|
16
|
+
// dim → { allValues, selected, searchType, searchText }
|
|
17
|
+
this._state = {};
|
|
18
|
+
this._popup = null;
|
|
19
|
+
this._openDim = null;
|
|
20
|
+
this._onChange = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
set onChange(fn) { this._onChange = fn; }
|
|
24
|
+
|
|
25
|
+
// ── Dimension management ──────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
async onDimAdded(dim) {
|
|
28
|
+
if (this._state[dim]) {
|
|
29
|
+
// already exists — just open the popup (FieldZones will call onFilterOpen)
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this._state[dim] = {
|
|
34
|
+
allValues: null,
|
|
35
|
+
selected: null, // null = all (no checkbox filter)
|
|
36
|
+
searchType: 'contains',
|
|
37
|
+
searchText: '',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Load values if checkboxes are needed
|
|
41
|
+
try {
|
|
42
|
+
const limit = this.config.filterCheckboxLimit ?? 30;
|
|
43
|
+
const count = await this.provider.countDistinct(dim);
|
|
44
|
+
if (count <= limit) {
|
|
45
|
+
this._state[dim].allValues = await this.provider.getDistinctValues(dim);
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.warn('FilterManager: failed to load values for', dim, e);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
onDimRemoved(dim) {
|
|
53
|
+
delete this._state[dim];
|
|
54
|
+
if (this._openDim === dim) this._closePopup();
|
|
55
|
+
this._notify();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Popup ────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
openFor(dim, anchorEl) {
|
|
61
|
+
this._closePopup();
|
|
62
|
+
const f = this._state[dim];
|
|
63
|
+
if (!f) return;
|
|
64
|
+
|
|
65
|
+
this._openDim = dim;
|
|
66
|
+
|
|
67
|
+
const popup = document.createElement('div');
|
|
68
|
+
popup.className = 'fm-popup';
|
|
69
|
+
document.body.appendChild(popup);
|
|
70
|
+
this._popup = popup;
|
|
71
|
+
|
|
72
|
+
this._renderPopup(popup, dim, f);
|
|
73
|
+
this._positionPopup(popup, anchorEl);
|
|
74
|
+
|
|
75
|
+
requestAnimationFrame(() => {
|
|
76
|
+
document.addEventListener('mousedown', this._handleOutside = (e) => {
|
|
77
|
+
if (!popup.contains(e.target) && !anchorEl.contains(e.target)) {
|
|
78
|
+
this._closePopup();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_renderPopup(popup, dim, f) {
|
|
85
|
+
const radioName = 'fm-stype-' + dim;
|
|
86
|
+
const checkboxesHTML = f.allValues
|
|
87
|
+
? `<div class="fm-checkbox-list">
|
|
88
|
+
<label class="fm-checkbox fm-select-all-wrap">
|
|
89
|
+
<input type="checkbox" class="fm-select-all" ${!f.selected ? 'checked' : ''}>
|
|
90
|
+
<em>All values</em>
|
|
91
|
+
</label>
|
|
92
|
+
<div class="fm-checkbox-scroll">
|
|
93
|
+
${f.allValues.map(v => `
|
|
94
|
+
<label class="fm-checkbox">
|
|
95
|
+
<input type="checkbox" value="${v.replace(/"/g, '"')}"
|
|
96
|
+
${!f.selected || f.selected.has(v) ? 'checked' : ''}>
|
|
97
|
+
<span>${v}</span>
|
|
98
|
+
</label>
|
|
99
|
+
`).join('')}
|
|
100
|
+
</div>
|
|
101
|
+
</div>`
|
|
102
|
+
: `<p class="fm-no-checkboxes">Too many values — use search</p>`;
|
|
103
|
+
|
|
104
|
+
popup.innerHTML = `
|
|
105
|
+
<div class="fm-popup-header">
|
|
106
|
+
<span class="fm-popup-title">${this.fields[dim]?.title || this.fields[dim]?.label || dim}</span>
|
|
107
|
+
<button class="fm-popup-close">×</button>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="fm-search-section">
|
|
110
|
+
<div class="fm-search-type">
|
|
111
|
+
<label><input type="radio" name="${radioName}" value="contains"
|
|
112
|
+
${f.searchType === 'contains' ? 'checked' : ''}> Contains</label>
|
|
113
|
+
<label><input type="radio" name="${radioName}" value="starts_with"
|
|
114
|
+
${f.searchType === 'starts_with' ? 'checked' : ''}> Starts with</label>
|
|
115
|
+
</div>
|
|
116
|
+
<input type="text" class="fm-search-input" placeholder="Search..." value="${f.searchText}">
|
|
117
|
+
</div>
|
|
118
|
+
${checkboxesHTML}
|
|
119
|
+
<div class="fm-popup-footer">
|
|
120
|
+
<button class="fm-btn fm-btn-clear">Clear</button>
|
|
121
|
+
<button class="fm-btn fm-btn-primary">Apply</button>
|
|
122
|
+
</div>
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
const searchInput = popup.querySelector('.fm-search-input');
|
|
126
|
+
const selectAll = popup.querySelector('.fm-select-all');
|
|
127
|
+
|
|
128
|
+
popup.querySelector('.fm-popup-close').onclick = () => this._closePopup();
|
|
129
|
+
|
|
130
|
+
// Sync the "Select all" checkbox state based on visible rows
|
|
131
|
+
const syncSelectAll = () => {
|
|
132
|
+
if (!selectAll) return;
|
|
133
|
+
const visible = [...popup.querySelectorAll('.fm-checkbox:not(.fm-select-all-wrap)')]
|
|
134
|
+
.filter(l => l.style.display !== 'none');
|
|
135
|
+
const checkedCount = visible.filter(l => l.querySelector('input').checked).length;
|
|
136
|
+
selectAll.indeterminate = checkedCount > 0 && checkedCount < visible.length;
|
|
137
|
+
selectAll.checked = visible.length > 0 && checkedCount === visible.length;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Search: hides non-matching rows and unchecks them.
|
|
141
|
+
// Important: hidden items must not silently end up in the selection on Apply.
|
|
142
|
+
const applyCheckboxSearch = () => {
|
|
143
|
+
const q = searchInput?.value.trim().toLowerCase() || '';
|
|
144
|
+
const type = popup.querySelector(`input[name="${radioName}"]:checked`)?.value || 'contains';
|
|
145
|
+
|
|
146
|
+
popup.querySelectorAll('.fm-checkbox:not(.fm-select-all-wrap)').forEach(label => {
|
|
147
|
+
const text = label.querySelector('span')?.textContent.trim().toLowerCase() || '';
|
|
148
|
+
const ok = !q || (type === 'starts_with' ? text.startsWith(q) : text.includes(q));
|
|
149
|
+
|
|
150
|
+
label.style.display = ok ? '' : 'none';
|
|
151
|
+
|
|
152
|
+
// Uncheck hidden items — if the user clears the search text later,
|
|
153
|
+
// these items will reappear unchecked, not "selected by default"
|
|
154
|
+
if (!ok) label.querySelector('input').checked = false;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
syncSelectAll();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Search type — apply to checkboxes immediately
|
|
161
|
+
popup.querySelectorAll(`input[name="${radioName}"]`).forEach(r => {
|
|
162
|
+
r.onchange = () => {
|
|
163
|
+
f.searchType = r.value;
|
|
164
|
+
applyCheckboxSearch();
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Text search
|
|
169
|
+
searchInput?.addEventListener('input', applyCheckboxSearch);
|
|
170
|
+
|
|
171
|
+
// "Select all" / "Deselect all" — affects ALL checkboxes, including hidden ones.
|
|
172
|
+
// Otherwise unchecking "All values" with active search would not reset hidden items.
|
|
173
|
+
selectAll?.addEventListener('change', () => {
|
|
174
|
+
popup.querySelectorAll('.fm-checkbox:not(.fm-select-all-wrap) input')
|
|
175
|
+
.forEach(cb => { cb.checked = selectAll.checked; });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Clear
|
|
179
|
+
popup.querySelector('.fm-btn-clear').onclick = () => {
|
|
180
|
+
f.selected = null;
|
|
181
|
+
f.searchText = '';
|
|
182
|
+
this._closePopup();
|
|
183
|
+
this._notify();
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Apply
|
|
187
|
+
popup.querySelector('.fm-btn-primary').onclick = () => {
|
|
188
|
+
const checkedRadio = popup.querySelector(`input[name="${radioName}"]:checked`);
|
|
189
|
+
f.searchType = checkedRadio ? checkedRadio.value : 'contains';
|
|
190
|
+
f.searchText = searchInput?.value.trim() || '';
|
|
191
|
+
|
|
192
|
+
if (f.allValues) {
|
|
193
|
+
const checkedBoxes = [...popup.querySelectorAll('.fm-checkbox:not(.fm-select-all-wrap) input:checked')]
|
|
194
|
+
.map(cb => cb.value);
|
|
195
|
+
f.selected = checkedBoxes.length === f.allValues.length ? null : new Set(checkedBoxes);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this._closePopup();
|
|
199
|
+
this._notify();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Apply current filter immediately on popup open —
|
|
203
|
+
// so checkboxes immediately reflect the saved searchText / searchType
|
|
204
|
+
if (f.searchText) applyCheckboxSearch();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_positionPopup(popup, anchorEl) {
|
|
208
|
+
const rect = anchorEl.getBoundingClientRect();
|
|
209
|
+
const left = Math.min(rect.left, window.innerWidth - 290);
|
|
210
|
+
popup.style.left = left + 'px';
|
|
211
|
+
popup.style.top = (rect.bottom + 6) + 'px';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
_closePopup() {
|
|
215
|
+
this._popup?.remove();
|
|
216
|
+
this._popup = null;
|
|
217
|
+
this._openDim = null;
|
|
218
|
+
if (this._handleOutside) {
|
|
219
|
+
document.removeEventListener('mousedown', this._handleOutside);
|
|
220
|
+
this._handleOutside = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Active filters ────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Returns active filters in the format expected by the provider.
|
|
228
|
+
* { dim: { values: string[]|null, searchType, searchText } }
|
|
229
|
+
*/
|
|
230
|
+
getActiveFilters() {
|
|
231
|
+
const result = {};
|
|
232
|
+
for (const [dim, f] of Object.entries(this._state)) {
|
|
233
|
+
const hasSelected = f.selected && f.selected.size > 0;
|
|
234
|
+
const hasSearch = f.searchText.length > 0;
|
|
235
|
+
if (hasSelected || hasSearch) {
|
|
236
|
+
result[dim] = {
|
|
237
|
+
values: hasSelected ? [...f.selected] : null,
|
|
238
|
+
searchType: f.searchType,
|
|
239
|
+
searchText: f.searchText,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
hasActiveFilter(dim) {
|
|
247
|
+
const f = this._state[dim];
|
|
248
|
+
if (!f) return false;
|
|
249
|
+
return (f.selected && f.selected.size > 0) || f.searchText.length > 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
getFilterHints() {
|
|
253
|
+
const result = {};
|
|
254
|
+
for (const [dim, f] of Object.entries(this._state)) {
|
|
255
|
+
const parts = [];
|
|
256
|
+
|
|
257
|
+
if (f.searchText) {
|
|
258
|
+
const prefix = f.searchType === 'starts_with' ? '' : '…';
|
|
259
|
+
parts.push(`«${f.searchText}${prefix}»`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (f.selected && f.selected.size > 0) {
|
|
263
|
+
const total = f.allValues?.length ?? null;
|
|
264
|
+
// If few items are excluded — show "except"
|
|
265
|
+
if (total && total - f.selected.size <= 2) {
|
|
266
|
+
const excluded = f.allValues.filter(v => !f.selected.has(v));
|
|
267
|
+
parts.push(`except: ${excluded.join(', ')}`);
|
|
268
|
+
} else if (f.selected.size <= 3) {
|
|
269
|
+
parts.push([...f.selected].join(', '));
|
|
270
|
+
} else {
|
|
271
|
+
parts.push(`${f.selected.size} val.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (parts.length) {
|
|
276
|
+
result[dim] = {
|
|
277
|
+
badge: parts.length === 1 && f.selected?.size
|
|
278
|
+
? String(f.selected.size) // just a number on the chip
|
|
279
|
+
: '✕',
|
|
280
|
+
tooltip: parts.join(' + '),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
_notify() {
|
|
288
|
+
this._onChange?.();
|
|
289
|
+
}
|
|
290
|
+
}
|