pivotgrid-js 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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, '&quot;')}"
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
+ }