@aquera/nile-visualization 2.9.2 → 2.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/src/nile-chart/nile-chart.css.js +6 -0
  2. package/dist/src/nile-filter-chart/nile-filter-chart.css.js +274 -4
  3. package/dist/src/nile-filter-chart/nile-filter-chart.d.ts +59 -206
  4. package/dist/src/nile-filter-chart/nile-filter-chart.js +330 -436
  5. package/dist/src/nile-filter-chart/utils/badge.d.ts +3 -0
  6. package/dist/src/nile-filter-chart/utils/badge.js +33 -0
  7. package/dist/src/nile-filter-chart/utils/comparison.d.ts +3 -0
  8. package/dist/src/nile-filter-chart/utils/comparison.js +24 -0
  9. package/dist/src/nile-filter-chart/utils/dropdown.d.ts +3 -0
  10. package/dist/src/nile-filter-chart/utils/dropdown.js +24 -0
  11. package/dist/src/nile-filter-chart/utils/preset.d.ts +3 -0
  12. package/dist/src/nile-filter-chart/utils/preset.js +16 -0
  13. package/dist/src/nile-filter-chart/utils/prompt.d.ts +12 -0
  14. package/dist/src/nile-filter-chart/utils/prompt.js +676 -0
  15. package/dist/src/nile-filter-chart/utils/radio.d.ts +3 -0
  16. package/dist/src/nile-filter-chart/utils/radio.js +13 -0
  17. package/dist/src/nile-filter-chart/utils/search.d.ts +3 -0
  18. package/dist/src/nile-filter-chart/utils/search.js +12 -0
  19. package/dist/src/nile-filter-chart/utils/segmented.d.ts +3 -0
  20. package/dist/src/nile-filter-chart/utils/segmented.js +15 -0
  21. package/dist/src/nile-filter-chart/utils/threshold.d.ts +3 -0
  22. package/dist/src/nile-filter-chart/utils/threshold.js +58 -0
  23. package/dist/src/nile-filter-chart/utils/toggle.d.ts +3 -0
  24. package/dist/src/nile-filter-chart/utils/toggle.js +19 -0
  25. package/dist/src/nile-filter-chart/utils/types.d.ts +334 -0
  26. package/dist/src/nile-filter-chart/utils/types.js +2 -0
  27. package/dist/src/nile-kpi-chart/nile-kpi-chart.css.js +7 -4
  28. package/package.json +1 -1
@@ -4,6 +4,19 @@ import { html, nothing } from 'lit';
4
4
  import { customElement, property, state } from 'lit/decorators.js';
5
5
  import { styles } from './nile-filter-chart.css.js';
6
6
  import NileElement from '../internal/nile-element.js';
7
+ import { renderBadge } from './utils/badge.js';
8
+ import { renderDropdown } from './utils/dropdown.js';
9
+ import { renderSegmented } from './utils/segmented.js';
10
+ import { renderRadio } from './utils/radio.js';
11
+ import { renderToggle } from './utils/toggle.js';
12
+ import { renderSearch } from './utils/search.js';
13
+ import { renderComparison } from './utils/comparison.js';
14
+ import { renderThreshold } from './utils/threshold.js';
15
+ import { renderPreset } from './utils/preset.js';
16
+ import { renderPrompt, validatePromptExpression } from './utils/prompt.js';
17
+ // Lowercase + ASCII-safe slug used to derive a control's `id` from its
18
+ // `label` when the author hasn't provided one explicitly.
19
+ const slugify = (s) => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
7
20
  let NileFilterChart = NileFilterChart_1 = class NileFilterChart extends NileElement {
8
21
  constructor() {
9
22
  super(...arguments);
@@ -11,30 +24,163 @@ let NileFilterChart = NileFilterChart_1 = class NileFilterChart extends NileElem
11
24
  this.selectedValues = new Map();
12
25
  this.collapsedGroups = new Set();
13
26
  /** Currently displayed (animated) placeholder text per prompt-variant control id. */
14
- this._promptPlaceholder = new Map();
27
+ this.promptPlaceholder = new Map();
28
+ /** Validation messages per prompt control id (strict-mode parse failures). */
29
+ this.promptErrors = new Map();
30
+ /** Runtime mode per prompt control id. Only populated for controls with `queryLanguage.enabled`. */
31
+ this.promptModes = new Map();
32
+ /** Highlighted suggestion index per prompt control id (-1 = none highlighted). */
33
+ this.promptActiveIndex = new Map();
15
34
  /** Active typewriter timers per prompt control id (so we can stop them). */
16
35
  this._promptTimers = new Map();
36
+ /** Compiled filtrex predicates per prompt control id (strict-mode successes). */
37
+ this._promptEvaluators = new Map();
38
+ /** Parsed AST per prompt control id (strict-mode successes). */
39
+ this._promptAsts = new Map();
40
+ /** Monotonic per-control token used to ignore stale async validations. */
41
+ this._validationTokens = new Map();
42
+ /** Pending debounce timers for prompt input — gates validation + nile-change emission. */
43
+ this._promptDebounce = new Map();
17
44
  }
18
45
  static get styles() {
19
46
  return [styles];
20
47
  }
21
48
  connectedCallback() {
22
49
  super.connectedCallback();
50
+ this._normalizeIds();
23
51
  this._initValues();
24
52
  this.emit('nile-init');
25
53
  }
26
54
  disconnectedCallback() {
27
55
  super.disconnectedCallback();
28
56
  this._stopAllPromptAnimations();
57
+ for (const t of this._promptDebounce.values())
58
+ clearTimeout(t);
59
+ this._promptDebounce.clear();
29
60
  this.emit('nile-destroy');
30
61
  }
31
62
  updated(changed) {
32
63
  super.updated(changed);
33
64
  if (changed.has('config')) {
65
+ this._normalizeIds();
34
66
  this._initValues();
35
67
  this._syncPromptAnimations();
36
68
  }
37
69
  }
70
+ // ── FilterChartHost surface ─────────────────────────────────────────────────
71
+ setValue(id, value) {
72
+ this.selectedValues = new Map(this.selectedValues).set(id, value);
73
+ this._emitChange();
74
+ }
75
+ /**
76
+ * Update a prompt control's value. The value lands in `selectedValues`
77
+ * immediately so the input stays responsive, but validation, error
78
+ * rendering, and the `nile-change` event are debounced — they only
79
+ * fire after `_PROMPT_DEBOUNCE_MS` of keyboard quiet.
80
+ */
81
+ handlePromptInput(ctrl, value) {
82
+ this.selectedValues = new Map(this.selectedValues).set(ctrl.id, value);
83
+ const existing = this._promptDebounce.get(ctrl.id);
84
+ if (existing)
85
+ clearTimeout(existing);
86
+ const timer = setTimeout(() => {
87
+ this._promptDebounce.delete(ctrl.id);
88
+ this._validateOrClear(ctrl, value);
89
+ }, NileFilterChart_1._PROMPT_DEBOUNCE_MS);
90
+ this._promptDebounce.set(ctrl.id, timer);
91
+ }
92
+ /**
93
+ * Switch the runtime mode for a prompt control. Leaving NQL clears
94
+ * validation; entering NQL re-validates the current value. The mode
95
+ * toggle is an explicit user action, so any pending typing debounce
96
+ * is cancelled and the change applies immediately (regardless of
97
+ * `validateOn`).
98
+ */
99
+ setPromptMode(ctrl, mode) {
100
+ if (this.promptModes.get(ctrl.id) === mode)
101
+ return;
102
+ this.promptModes = new Map(this.promptModes).set(ctrl.id, mode);
103
+ const pending = this._promptDebounce.get(ctrl.id);
104
+ if (pending) {
105
+ clearTimeout(pending);
106
+ this._promptDebounce.delete(ctrl.id);
107
+ }
108
+ const value = String(this.selectedValues.get(ctrl.id) ?? '');
109
+ this._validateOrClear(ctrl, value, { explicit: true });
110
+ }
111
+ /**
112
+ * Force-validate a prompt's current value. Used by `validateOn: 'submit'`
113
+ * on Enter — bypasses the debounce and runs validation right away.
114
+ */
115
+ submitPrompt(ctrl) {
116
+ const pending = this._promptDebounce.get(ctrl.id);
117
+ if (pending) {
118
+ clearTimeout(pending);
119
+ this._promptDebounce.delete(ctrl.id);
120
+ }
121
+ const value = String(this.selectedValues.get(ctrl.id) ?? '');
122
+ this._validateOrClear(ctrl, value, { explicit: true });
123
+ }
124
+ setPromptActiveIndex(id, idx) {
125
+ this.promptActiveIndex = new Map(this.promptActiveIndex).set(id, idx);
126
+ }
127
+ _validateOrClear(ctrl, value, opts = {}) {
128
+ const isNql = this.promptModes.get(ctrl.id) === 'nql';
129
+ if (value.trim() === '') {
130
+ this._clearPromptValidation(ctrl.id);
131
+ this._emitChange();
132
+ return;
133
+ }
134
+ // In `validateOn: 'submit'` mode, automatic (debounced) ticks just
135
+ // emit the change without validating. Validation runs only when the
136
+ // call is `explicit` — i.e. Enter was pressed or the mode toggled.
137
+ if (isNql && ctrl.queryLanguage?.validateOn === 'submit' && !opts.explicit) {
138
+ this._emitChange();
139
+ return;
140
+ }
141
+ // Bump the validation token so any earlier in-flight parse is ignored.
142
+ const token = (this._validationTokens.get(ctrl.id) ?? 0) + 1;
143
+ this._validationTokens.set(ctrl.id, token);
144
+ this._emitChange();
145
+ validatePromptExpression(value, ctrl).then(({ ast, evaluate }) => {
146
+ if (this._validationTokens.get(ctrl.id) !== token)
147
+ return;
148
+ const errs = new Map(this.promptErrors);
149
+ errs.delete(ctrl.id);
150
+ this.promptErrors = errs;
151
+ this._promptEvaluators.set(ctrl.id, evaluate);
152
+ this._promptAsts.set(ctrl.id, ast);
153
+ this._emitChange();
154
+ }, err => {
155
+ if (this._validationTokens.get(ctrl.id) !== token)
156
+ return;
157
+ const error = err instanceof Error ? err : new Error(String(err));
158
+ // Basic mode swallows parse errors (suggestions can be partial / unfinished
159
+ // expressions); NQL surfaces them so the user sees what's wrong.
160
+ const errs = new Map(this.promptErrors);
161
+ if (isNql) {
162
+ console.error(`[nile-filter-chart] "${ctrl.id}" parse error:`, error.message);
163
+ errs.set(ctrl.id, error.message);
164
+ }
165
+ else {
166
+ errs.delete(ctrl.id);
167
+ }
168
+ this.promptErrors = errs;
169
+ this._promptEvaluators.delete(ctrl.id);
170
+ this._promptAsts.delete(ctrl.id);
171
+ this._emitChange();
172
+ });
173
+ }
174
+ _clearPromptValidation(id) {
175
+ this._validationTokens.set(id, (this._validationTokens.get(id) ?? 0) + 1);
176
+ if (this.promptErrors.has(id)) {
177
+ const errs = new Map(this.promptErrors);
178
+ errs.delete(id);
179
+ this.promptErrors = errs;
180
+ }
181
+ this._promptEvaluators.delete(id);
182
+ this._promptAsts.delete(id);
183
+ }
38
184
  // ── Prompt typewriter ────────────────────────────────────────────────────────
39
185
  _syncPromptAnimations() {
40
186
  const wanted = new Set();
@@ -52,9 +198,9 @@ let NileFilterChart = NileFilterChart_1 = class NileFilterChart extends NileElem
52
198
  }
53
199
  }
54
200
  _startPromptAnimation(ctrl) {
55
- const phrases = (ctrl.placeholders && ctrl.placeholders.length
201
+ const phrases = ctrl.placeholders && ctrl.placeholders.length
56
202
  ? ctrl.placeholders
57
- : [ctrl.placeholder ?? 'Ask anything…']);
203
+ : ['Ask anything…'];
58
204
  const typeSpeed = ctrl.typeSpeedMs ?? 60;
59
205
  const pauseAfterType = ctrl.pauseBeforeDeleteMs ?? 1400;
60
206
  const pauseBetween = ctrl.pauseBetweenMs ?? 400;
@@ -66,8 +212,8 @@ let NileFilterChart = NileFilterChart_1 = class NileFilterChart extends NileElem
66
212
  let nextDelay;
67
213
  if (!deleting) {
68
214
  charIdx++;
69
- const next = new Map(this._promptPlaceholder).set(ctrl.id, phrase.slice(0, charIdx));
70
- this._promptPlaceholder = next;
215
+ const next = new Map(this.promptPlaceholder).set(ctrl.id, phrase.slice(0, charIdx));
216
+ this.promptPlaceholder = next;
71
217
  if (charIdx >= phrase.length) {
72
218
  deleting = true;
73
219
  nextDelay = pauseAfterType;
@@ -78,8 +224,8 @@ let NileFilterChart = NileFilterChart_1 = class NileFilterChart extends NileElem
78
224
  }
79
225
  else {
80
226
  charIdx--;
81
- const next = new Map(this._promptPlaceholder).set(ctrl.id, phrase.slice(0, Math.max(0, charIdx)));
82
- this._promptPlaceholder = next;
227
+ const next = new Map(this.promptPlaceholder).set(ctrl.id, phrase.slice(0, Math.max(0, charIdx)));
228
+ this.promptPlaceholder = next;
83
229
  if (charIdx <= 0) {
84
230
  deleting = false;
85
231
  phraseIdx = (phraseIdx + 1) % phrases.length;
@@ -92,7 +238,7 @@ let NileFilterChart = NileFilterChart_1 = class NileFilterChart extends NileElem
92
238
  this._promptTimers.set(ctrl.id, setTimeout(tick, nextDelay));
93
239
  };
94
240
  // Kick it off
95
- this._promptPlaceholder = new Map(this._promptPlaceholder).set(ctrl.id, '');
241
+ this.promptPlaceholder = new Map(this.promptPlaceholder).set(ctrl.id, '');
96
242
  this._promptTimers.set(ctrl.id, setTimeout(tick, 100));
97
243
  }
98
244
  _stopPromptAnimation(id) {
@@ -100,14 +246,50 @@ let NileFilterChart = NileFilterChart_1 = class NileFilterChart extends NileElem
100
246
  if (t)
101
247
  clearTimeout(t);
102
248
  this._promptTimers.delete(id);
103
- const next = new Map(this._promptPlaceholder);
249
+ const next = new Map(this.promptPlaceholder);
104
250
  next.delete(id);
105
- this._promptPlaceholder = next;
251
+ this.promptPlaceholder = next;
106
252
  }
107
253
  _stopAllPromptAnimations() {
108
254
  for (const id of [...this._promptTimers.keys()])
109
255
  this._stopPromptAnimation(id);
110
256
  }
257
+ // ── Config helpers ───────────────────────────────────────────────────────────
258
+ /**
259
+ * Walk the raw config and ensure every control has a unique string `id`.
260
+ * Authors may omit `id`; this fills it in from `label` (slugified) with a
261
+ * collision suffix, or `ctrl_<index>` if `label` is also missing. Mutates
262
+ * the raw control objects in place — consistent with existing behavior
263
+ * (e.g. `aq.autocomplete` is fanned out onto controls below).
264
+ */
265
+ _normalizeIds() {
266
+ const used = new Set();
267
+ let idx = 0;
268
+ const ensure = (c) => {
269
+ if (typeof c.id === 'string' && c.id.length > 0) {
270
+ used.add(c.id);
271
+ idx++;
272
+ return;
273
+ }
274
+ const base = slugify(c.label ?? '') || `ctrl_${idx}`;
275
+ let pick = base;
276
+ let n = 2;
277
+ while (used.has(pick))
278
+ pick = `${base}_${n++}`;
279
+ used.add(pick);
280
+ c.id = pick;
281
+ idx++;
282
+ };
283
+ for (const entry of this.config?.chart?.controls ?? []) {
284
+ if (entry.type === 'group') {
285
+ for (const c of entry.controls)
286
+ ensure(c);
287
+ }
288
+ else {
289
+ ensure(entry);
290
+ }
291
+ }
292
+ }
111
293
  _flatControls() {
112
294
  const entries = this.config?.chart?.controls ?? [];
113
295
  const out = [];
@@ -124,7 +306,33 @@ let NileFilterChart = NileFilterChart_1 = class NileFilterChart extends NileElem
124
306
  _initValues() {
125
307
  const controls = this._flatControls();
126
308
  const map = new Map();
309
+ const modes = new Map();
310
+ // Single source of truth for suggestion catalogs: `aq.autocomplete`.
311
+ // Fan it out into every prompt-like control's `suggestions` so the
312
+ // renderer (which reads `ctrl.suggestions`) sees the same items the
313
+ // backend shipped under `aq`. Per-control overrides still win — only
314
+ // controls without their own `suggestions` get hydrated.
315
+ const acItems = this.config
316
+ ?.aq?.autocomplete;
317
+ if (Array.isArray(acItems)) {
318
+ for (const ctrl of controls) {
319
+ const isPromptLike = ctrl.variant === 'prompt'
320
+ || ctrl.variant === 'expression'
321
+ || ctrl.variant === 'hybrid';
322
+ if (isPromptLike && !Array.isArray(ctrl.suggestions)) {
323
+ ctrl.suggestions = acItems;
324
+ }
325
+ }
326
+ }
127
327
  for (const ctrl of controls) {
328
+ if ((ctrl.variant === 'prompt' && ctrl.queryLanguage?.enabled)
329
+ || ctrl.variant === 'expression'
330
+ || ctrl.variant === 'hybrid') {
331
+ const initialMode = ctrl.variant === 'expression'
332
+ ? 'nql'
333
+ : (ctrl.queryLanguage?.mode ?? 'strict') === 'strict' ? 'nql' : 'basic';
334
+ modes.set(ctrl.id, initialMode);
335
+ }
128
336
  if (ctrl.value !== undefined) {
129
337
  map.set(ctrl.id, ctrl.value);
130
338
  if (ctrl.variant === 'comparison') {
@@ -136,10 +344,11 @@ let NileFilterChart = NileFilterChart_1 = class NileFilterChart extends NileElem
136
344
  }
137
345
  }
138
346
  else {
139
- if (ctrl.variant === 'slider') {
140
- map.set(ctrl.id, [ctrl.min ?? 0, ctrl.max ?? 100]);
141
- }
142
- else if (ctrl.variant === 'toggle') {
347
+ // slider variant: not yet implemented
348
+ // if (ctrl.variant === 'slider') {
349
+ // map.set(ctrl.id, [ctrl.min ?? 0, ctrl.max ?? 100]);
350
+ // } else
351
+ if (ctrl.variant === 'toggle') {
143
352
  const defaults = {};
144
353
  (ctrl.options ?? []).forEach(o => { defaults[o.value] = false; });
145
354
  map.set(ctrl.id, defaults);
@@ -159,433 +368,103 @@ let NileFilterChart = NileFilterChart_1 = class NileFilterChart extends NileElem
159
368
  }
160
369
  }
161
370
  this.selectedValues = map;
162
- }
163
- _set(id, value) {
164
- this.selectedValues = new Map(this.selectedValues).set(id, value);
165
- this._emitChange();
371
+ this.promptModes = modes;
166
372
  }
167
373
  _emitChange() {
168
374
  const filters = {};
169
- this.selectedValues.forEach((val, id) => { filters[id] = val; });
170
- this.emit('nile-change', { filters });
171
- }
172
- // ── Badge ────────────────────────────────────────────────────────────────────
173
- _renderBadge(ctrl) {
174
- const current = this.selectedValues.get(ctrl.id);
175
- return html `
176
- <div class="fc-badge-group" role="group" aria-label="${ctrl.label}">
177
- ${(ctrl.options ?? []).map(opt => {
178
- const sel = ctrl.selection === 'multi'
179
- ? (Array.isArray(current) && current.includes(opt.value))
180
- : current === opt.value;
181
- return html `
182
- <nile-tag
183
- pill
184
- size="medium"
185
- variant="${sel ? (opt.ngVariant ?? 'primary') : 'normal'}"
186
- @click="${() => {
187
- if (ctrl.selection === 'single') {
188
- this._set(ctrl.id, sel ? '' : opt.value);
189
- }
190
- else {
191
- const arr = Array.isArray(current) ? [...current] : [];
192
- const idx = arr.indexOf(opt.value);
193
- if (idx === -1)
194
- arr.push(opt.value);
195
- else
196
- arr.splice(idx, 1);
197
- this._set(ctrl.id, arr);
198
- }
199
- }}"
200
- >${opt.label}</nile-tag>`;
201
- })}
202
- </div>`;
203
- }
204
- // ── Dropdown ─────────────────────────────────────────────────────────────────
205
- _renderDropdown(ctrl) {
206
- const raw = this.selectedValues.get(ctrl.id);
207
- const current = Array.isArray(raw) ? raw : (raw ? [raw] : []);
208
- const isMulti = ctrl.selection === 'multi';
209
- return html `
210
- <nile-select
211
- .value="${isMulti ? current : (current[0] ?? '')}"
212
- ?multiple="${isMulti}"
213
- searchEnabled
214
- placeholder="${ctrl.placeholder ?? `Select ${ctrl.label || 'option'}…`}"
215
- @nile-change="${(e) => {
216
- e.stopPropagation();
217
- this._set(ctrl.id, e.detail.value);
218
- }}"
219
- >
220
- ${(ctrl.options ?? []).map(opt => html `
221
- <nile-option
222
- value="${opt.value}"
223
- ?selected="${isMulti ? current.includes(opt.value) : current[0] === opt.value}"
224
- >${opt.label}</nile-option>`)}
225
- </nile-select>`;
226
- }
227
- // ── Segmented ────────────────────────────────────────────────────────────────
228
- _renderSegmented(ctrl) {
229
- return html `
230
- <div class="fc-segmented-scroll">
231
- <nile-button-toggle-group
232
- .value="${this.selectedValues.get(ctrl.id) ?? ''}"
233
- ?multiple="${ctrl.selection === 'multi'}"
234
- @nile-change="${(e) => { e.stopPropagation(); this._set(ctrl.id, e.detail.value); }}"
235
- >
236
- ${(ctrl.options ?? []).map(opt => html `
237
- <nile-button-toggle value="${opt.value}">${opt.label}</nile-button-toggle>`)}
238
- </nile-button-toggle-group>
239
- </div>`;
240
- }
241
- // ── Radio ────────────────────────────────────────────────────────────────────
242
- _renderRadio(ctrl) {
243
- const current = this.selectedValues.get(ctrl.id) ?? '';
244
- return html `
245
- <nile-radio-group
246
- .value="${current}"
247
- @nile-change="${(e) => { e.stopPropagation(); this._set(ctrl.id, e.detail.value); }}"
248
- >
249
- ${(ctrl.options ?? []).map(opt => html `
250
- <nile-radio value="${opt.value}" ?checked="${current === opt.value}">${opt.label}</nile-radio>`)}
251
- </nile-radio-group>`;
252
- }
253
- // ── Toggle ───────────────────────────────────────────────────────────────────
254
- _renderToggle(ctrl) {
255
- const current = this.selectedValues.get(ctrl.id) ?? {};
256
- return html `
257
- <div class="fc-toggle-group">
258
- ${(ctrl.options ?? []).map(opt => html `
259
- <nile-slide-toggle
260
- label="${opt.label}"
261
- ?isChecked="${!!current[opt.value]}"
262
- fullWidth
263
- @nile-change="${(e) => {
264
- e.stopPropagation();
265
- const updated = { ...current, [opt.value]: e.detail.checked };
266
- this._set(ctrl.id, updated);
267
- }}"
268
- ></nile-slide-toggle>`)}
269
- </div>`;
270
- }
271
- // ── Slider ───────────────────────────────────────────────────────────────────
272
- // private _renderSlider(ctrl: FilterControl): TemplateResult {
273
- // const min = ctrl.min ?? 0;
274
- // const max = ctrl.max ?? 100;
275
- // const val: number[] = this.selectedValues.get(ctrl.id) ?? [min, max];
276
- // const fmt = (n: number) => `${ctrl.prefix ?? ''}${n.toLocaleString()}${ctrl.suffix ?? ''}`;
277
- //
278
- // return html`
279
- // <div class="fc-slider-wrap">
280
- // <div class="fc-slider-label">
281
- // <span class="fc-slider-range">${fmt(val[0])} — <strong>${fmt(val[1])}</strong></span>
282
- // </div>
283
- // <nile-slider
284
- // .minValue="${min}"
285
- // .maxValue="${max}"
286
- // ?rangeSlider="${true}"
287
- // .rangeOneValue="${val[0]}"
288
- // .rangeTwoValue="${val[1]}"
289
- // @nile-button-first-change-end="${(e: CustomEvent) => {
290
- // e.stopPropagation();
291
- // this._set(ctrl.id, [e.detail.value, val[1]]);
292
- // }}"
293
- // @nile-button-last-change-end="${(e: CustomEvent) => {
294
- // e.stopPropagation();
295
- // this._set(ctrl.id, [val[0], e.detail.value]);
296
- // }}"
297
- // >
298
- // <span slot="prefix">${fmt(min)}</span>
299
- // <span slot="suffix">${fmt(max)}</span>
300
- // </nile-slider>
301
- // </div>`;
302
- // }
303
- // ── Search ───────────────────────────────────────────────────────────────────
304
- _renderSearch(ctrl) {
305
- return html `
306
- <nile-input
307
- type="search"
308
- placeholder="${ctrl.placeholder ?? `Search ${ctrl.label}…`}"
309
- .value="${this.selectedValues.get(ctrl.id) ?? ''}"
310
- clearable
311
- @nile-input="${(e) => { e.stopPropagation(); this._set(ctrl.id, e.detail.value); }}"
312
- ></nile-input>`;
313
- }
314
- // ── Comparison ───────────────────────────────────────────────────────────────
315
- _renderComparison(ctrl) {
316
- const valA = this.selectedValues.get(ctrl.id) ?? '';
317
- const valB = this.selectedValues.get(`${ctrl.id}__b`) ?? '';
318
- return html `
319
- <div class="fc-comparison">
320
- <nile-select
321
- .value="${valA}"
322
- placeholder="Period A"
323
- @nile-change="${(e) => { e.stopPropagation(); this._set(ctrl.id, e.detail.value); }}"
324
- >
325
- ${(ctrl.options ?? []).map(o => html `<nile-option value="${o.value}">${o.label}</nile-option>`)}
326
- </nile-select>
327
- <span class="fc-vs">VS</span>
328
- <nile-select
329
- .value="${valB}"
330
- placeholder="Period B"
331
- @nile-change="${(e) => { e.stopPropagation(); this._set(`${ctrl.id}__b`, e.detail.value); }}"
332
- >
333
- ${(ctrl.options ?? []).map(o => html `<nile-option value="${o.value}">${o.label}</nile-option>`)}
334
- </nile-select>
335
- </div>`;
336
- }
337
- // ── Threshold ────────────────────────────────────────────────────────────────
338
- _renderThreshold(ctrl) {
339
- const metric = this.selectedValues.get(ctrl.id) ?? '';
340
- const op = this.selectedValues.get(`${ctrl.id}__op`) ?? '>';
341
- const val = this.selectedValues.get(`${ctrl.id}__val`) ?? '';
342
- const operators = [
343
- { value: '>', label: '> (greater)' },
344
- { value: '>=', label: '>= (min)' },
345
- { value: '<', label: '< (less)' },
346
- { value: '<=', label: '<= (max)' },
347
- { value: '=', label: '= (equals)' },
348
- { value: '!=', label: '≠ (not)' },
349
- ];
350
- const metricLabel = (ctrl.options ?? []).find(o => o.value === metric)?.label ?? metric;
351
- const hasPreview = metric && val !== '';
352
- const previewText = hasPreview ? `${metricLabel} ${op} ${val}` : 'Set metric, condition, and value above';
353
- return html `
354
- <div class="fc-threshold">
355
- <div class="fc-threshold-metric-row">
356
- <span class="fc-threshold-where">WHERE</span>
357
- <div class="fc-threshold-field fc-threshold-field--metric">
358
- <span class="fc-threshold-field-label">Metric</span>
359
- <nile-select
360
- .value="${metric}"
361
- placeholder="Select metric…"
362
- @nile-change="${(e) => { e.stopPropagation(); this._set(ctrl.id, e.detail.value); }}"
363
- >
364
- ${(ctrl.options ?? []).map(o => html `<nile-option value="${o.value}">${o.label}</nile-option>`)}
365
- </nile-select>
366
- </div>
367
- </div>
368
- <div class="fc-threshold-cond-row">
369
- <div class="fc-threshold-field fc-threshold-field--op">
370
- <span class="fc-threshold-field-label">Condition</span>
371
- <nile-select
372
- .value="${op}"
373
- @nile-change="${(e) => { e.stopPropagation(); this._set(`${ctrl.id}__op`, e.detail.value); }}"
374
- >
375
- ${operators.map(o => html `<nile-option value="${o.value}">${o.label}</nile-option>`)}
376
- </nile-select>
377
- </div>
378
- <div class="fc-threshold-field fc-threshold-field--val">
379
- <span class="fc-threshold-field-label">Value</span>
380
- <nile-input
381
- type="number"
382
- placeholder="e.g. 10000"
383
- .value="${String(val)}"
384
- @nile-input="${(e) => { e.stopPropagation(); this._set(`${ctrl.id}__val`, e.detail.value); }}"
385
- ></nile-input>
386
- </div>
387
- </div>
388
- <div class="fc-threshold-preview ${hasPreview ? '' : 'fc-threshold-preview--empty'}">
389
- ${hasPreview ? html `<strong>Filter:</strong>` : nothing}
390
- ${previewText}
391
- </div>
392
- </div>`;
393
- }
394
- // ── Tree ─────────────────────────────────────────────────────────────────────
395
- // private _renderTree(ctrl: FilterControl): TemplateResult {
396
- // const renderNodes = (nodes: TreeNode[]): TemplateResult[] =>
397
- // nodes.map(node => html`
398
- // <nile-tree-item value="${node.value}" ?expanded="${node.expanded ?? false}">
399
- // <span>${node.label}</span>
400
- // ${node.children ? renderNodes(node.children) : nothing}
401
- // </nile-tree-item>`);
402
- //
403
- // return html`
404
- // <nile-tree
405
- // selection="${ctrl.selection === 'multi' ? 'multiple' : 'single'}"
406
- // @nile-selection-change="${(e: CustomEvent) => {
407
- // e.stopPropagation();
408
- // this._set(ctrl.id, e.detail?.detail?.selection ?? []);
409
- // }}"
410
- // >
411
- // ${renderNodes(ctrl.treeData ?? [])}
412
- // </nile-tree>`;
413
- // }
414
- // ── Preset ───────────────────────────────────────────────────────────────────
415
- _renderPreset(ctrl) {
416
- const current = this.selectedValues.get(ctrl.id);
417
- return html `
418
- <div class="fc-preset-list">
419
- ${(ctrl.options ?? []).map(opt => html `
420
- <button
421
- class="fc-preset-item ${current === opt.value ? 'fc-preset-item--selected' : ''}"
422
- @click="${() => this._set(ctrl.id, opt.value)}"
423
- >
424
- ${opt.icon ? html `<span class="fc-preset-icon">${opt.icon}</span>` : nothing}
425
- ${opt.label}
426
- </button>`)}
427
- </div>`;
428
- }
429
- static async _getCompileExpression() {
430
- if (!NileFilterChart_1._filtrexLoading) {
431
- NileFilterChart_1._filtrexLoading = import('filtrex').then(m => {
432
- const fn = m.compileExpression
433
- ?? m.default?.compileExpression;
434
- if (typeof fn !== 'function') {
435
- throw new Error('filtrex: compileExpression export not found');
436
- }
437
- return fn;
438
- });
439
- }
440
- return NileFilterChart_1._filtrexLoading;
441
- }
442
- static async _getJsep() {
443
- if (!NileFilterChart_1._jsepLoading) {
444
- NileFilterChart_1._jsepLoading = import('jsep').then(m => {
445
- const candidate = m.default ?? m;
446
- if (typeof candidate !== 'function') {
447
- throw new Error('jsep: function export not found');
448
- }
449
- const fn = candidate;
450
- try {
451
- fn.addBinaryOp?.('and', 2);
452
- fn.addBinaryOp?.('or', 1);
453
- fn.addBinaryOp?.('in', 6);
454
- fn.addUnaryOp?.('not');
455
- }
456
- catch {
457
- }
458
- return fn;
459
- });
460
- }
461
- return NileFilterChart_1._jsepLoading;
462
- }
463
- async _handleSubmit(value, ctrl) {
464
- const ql = ctrl.queryLanguage;
465
- if (!ql?.enabled || (ql.mode ?? 'auto') === 'auto') {
466
- this.emit('nile-prompt-submit', { id: ctrl.id, value });
467
- if (typeof ctrl.onSubmit === 'function')
468
- ctrl.onSubmit(value, ctrl.id);
469
- return;
470
- }
471
- try {
472
- const jsep = await NileFilterChart_1._getJsep();
473
- const ast = jsep(value);
474
- const compileExpression = await NileFilterChart_1._getCompileExpression();
475
- const extraFunctions = {
476
- ...NileFilterChart_1._builtInFiltrexFns,
477
- ...(ql.extraFunctions ?? {}),
478
- };
479
- const evaluate = compileExpression(value, {
480
- extraFunctions,
481
- ...(ql.customProp ? { customProp: ql.customProp } : {}),
482
- });
483
- const json = { source: value, ast };
484
- this.emit('nile-prompt-submit', { id: ctrl.id, value: json, evaluate });
485
- if (typeof ctrl.onSubmit === 'function')
486
- ctrl.onSubmit(json, ctrl.id, evaluate);
375
+ this.selectedValues.forEach((val, id) => {
376
+ // Prompt controls in NQL mode with a successful parse emit the
377
+ // structured `{ source, ast }` payload so downstream consumers get
378
+ // both the typed text and the parsed shape. Basic mode (and NQL
379
+ // pre-validation / parse-failure) emit the raw string — the same
380
+ // shape every other variant uses.
381
+ const mode = this.promptModes.get(id);
382
+ const ast = this._promptAsts.get(id);
383
+ if (mode === 'nql' && ast && typeof val === 'string' && val.trim() !== '') {
384
+ filters[id] = { source: val, ast };
385
+ }
386
+ else {
387
+ filters[id] = val;
388
+ }
389
+ });
390
+ const detail = { filters };
391
+ if (this.promptErrors.size > 0) {
392
+ const errors = {};
393
+ this.promptErrors.forEach((msg, id) => { errors[id] = msg; });
394
+ detail.errors = errors;
487
395
  }
488
- catch (err) {
489
- const error = err instanceof Error ? err : new Error(String(err));
490
- this.emit('nile-prompt-parse-error', {
491
- id: ctrl.id,
492
- input: value,
493
- error: { message: error.message },
396
+ if (this._promptEvaluators.size > 0) {
397
+ // Only surface evaluators for controls whose active mode is NQL.
398
+ // In Basic / Prompt mode the consumer expects a plain-string payload
399
+ // — leaking the cached evaluator from the last NQL pass would lie
400
+ // about what the user has selected.
401
+ const evaluators = {};
402
+ this._promptEvaluators.forEach((fn, id) => {
403
+ if (this.promptModes.get(id) === 'nql')
404
+ evaluators[id] = fn;
494
405
  });
495
- if (typeof ctrl.onParseError === 'function') {
496
- ctrl.onParseError(value, error, ctrl.id);
497
- }
406
+ if (Object.keys(evaluators).length > 0)
407
+ detail.evaluators = evaluators;
498
408
  }
409
+ this.emit('nile-change', detail);
499
410
  }
500
- _renderPrompt(ctrl) {
501
- const value = String(this.selectedValues.get(ctrl.id) ?? '');
502
- const animated = this._promptPlaceholder.get(ctrl.id) ?? '';
503
- const styleParts = [];
504
- if (ctrl.gradientColors && ctrl.gradientColors.length > 0) {
505
- const dir = ctrl.gradientDirection ?? '90deg';
506
- styleParts.push(`--fc-prompt-gradient: linear-gradient(${dir}, ${ctrl.gradientColors.join(', ')})`);
507
- }
508
- else if (ctrl.gradientDirection) {
509
- // Direction-only override: keep the default palette but rotate it.
510
- styleParts.push(`--fc-prompt-gradient: linear-gradient(${ctrl.gradientDirection},` +
511
- ' #2563eb 0%, #3b82f6 18%, #06b6d4 36%, #6366f1 54%,' +
512
- ' #8b5cf6 72%, #2563eb 100%)');
513
- }
514
- if (typeof ctrl.gradientSpeedMs === 'number' && ctrl.gradientSpeedMs > 0) {
515
- styleParts.push(`--fc-prompt-gradient-speed: ${ctrl.gradientSpeedMs}ms`);
516
- }
517
- const inlineStyle = styleParts.length ? styleParts.join('; ') + ';' : '';
518
- return html `
519
- <div class="fc-prompt" part="filter-prompt" style="${inlineStyle}">
520
- <div class="fc-prompt__inner">
521
- <input
522
- class="fc-prompt__input"
523
- type="text"
524
- autocomplete="off"
525
- spellcheck="false"
526
- .value="${value}"
527
- placeholder="${animated}"
528
- @input="${(e) => {
529
- const t = e.target;
530
- // Update the internal map (also fires the bulk `nile-change`
531
- // event with every control's value).
532
- this._set(ctrl.id, t.value);
533
- // Dedicated, prompt-specific stream — every keystroke emits
534
- // just this control's typed string so consumers don't have to
535
- // sift through the full filters map for live updates (e.g.
536
- // debounced AI suggestions, auto-complete, character counters).
537
- this.emit('nile-prompt-input', { id: ctrl.id, value: t.value });
538
- // Optional inline callback supplied via the config — same
539
- // payload, shaped as a function call instead of an event.
540
- if (typeof ctrl.onType === 'function') {
541
- ctrl.onType(t.value, ctrl.id);
542
- }
543
- }}"
544
- @keydown="${(e) => {
545
- if (e.key === 'Enter') {
546
- const t = e.target;
547
- this._handleSubmit(t.value, ctrl);
548
- }
549
- }}"
550
- />
551
- </div>
552
- </div>`;
553
- }
554
- // ── Card wrapper ─────────────────────────────────────────────────────────────
411
+ // ── Render ───────────────────────────────────────────────────────────────────
555
412
  _renderControl(ctrl) {
556
413
  let body;
557
414
  switch (ctrl.variant) {
558
415
  case 'badge':
559
- body = this._renderBadge(ctrl);
416
+ body = renderBadge(this, ctrl);
560
417
  break;
561
418
  case 'dropdown':
562
- body = this._renderDropdown(ctrl);
419
+ body = renderDropdown(this, ctrl);
563
420
  break;
564
421
  case 'segmented':
565
- body = this._renderSegmented(ctrl);
422
+ body = renderSegmented(this, ctrl);
566
423
  break;
567
424
  case 'radio':
568
- body = this._renderRadio(ctrl);
425
+ body = renderRadio(this, ctrl);
569
426
  break;
570
427
  case 'toggle':
571
- body = this._renderToggle(ctrl);
428
+ body = renderToggle(this, ctrl);
572
429
  break;
573
- // case 'slider': body = this._renderSlider(ctrl); break;
574
430
  case 'search':
575
- body = this._renderSearch(ctrl);
431
+ body = renderSearch(this, ctrl);
576
432
  break;
577
433
  case 'comparison':
578
- body = this._renderComparison(ctrl);
434
+ body = renderComparison(this, ctrl);
579
435
  break;
580
436
  case 'threshold':
581
- body = this._renderThreshold(ctrl);
437
+ body = renderThreshold(this, ctrl);
582
438
  break;
583
- // case 'tree': body = this._renderTree(ctrl); break;
584
439
  case 'preset':
585
- body = this._renderPreset(ctrl);
440
+ body = renderPreset(this, ctrl);
586
441
  break;
587
442
  case 'prompt':
588
- body = this._renderPrompt(ctrl);
443
+ body = renderPrompt(this, ctrl);
444
+ break;
445
+ case 'expression':
446
+ body = renderPrompt(this, {
447
+ ...ctrl,
448
+ queryLanguage: {
449
+ ...(ctrl.queryLanguage ?? {}),
450
+ enabled: true,
451
+ mode: 'strict',
452
+ },
453
+ });
454
+ break;
455
+ case 'hybrid':
456
+ body = renderPrompt(this, {
457
+ ...ctrl,
458
+ queryLanguage: {
459
+ ...(ctrl.queryLanguage ?? {}),
460
+ enabled: true,
461
+ mode: ctrl.queryLanguage?.mode ?? 'strict',
462
+ toggle: {
463
+ basic: { label: ctrl.queryLanguage?.toggle?.basic?.label ?? 'Prompt' },
464
+ nql: { label: ctrl.queryLanguage?.toggle?.nql?.label ?? 'Expression' },
465
+ },
466
+ },
467
+ });
589
468
  break;
590
469
  default: body = html ``;
591
470
  }
@@ -596,6 +475,18 @@ let NileFilterChart = NileFilterChart_1 = class NileFilterChart extends NileElem
596
475
  <div class="fc-control__body">${body}</div>
597
476
  </div>`;
598
477
  }
478
+ /**
479
+ * If any prompt control in `controls` has its toggle enabled and is currently
480
+ * in NQL mode, collapse the visible set down to just that prompt — Basic mode
481
+ * shows everything, JQL mode shows only the expression input.
482
+ */
483
+ _filterByPromptMode(controls) {
484
+ const nqlPrompt = controls.find(c => ((c.variant === 'prompt' && c.queryLanguage?.enabled)
485
+ || c.variant === 'hybrid'
486
+ || c.variant === 'expression')
487
+ && this.promptModes.get(c.id) === 'nql');
488
+ return nqlPrompt ? [nqlPrompt] : controls;
489
+ }
599
490
  _renderGroup(group) {
600
491
  const collapsed = this.collapsedGroups.has(group.label);
601
492
  const toggle = () => {
@@ -606,6 +497,7 @@ let NileFilterChart = NileFilterChart_1 = class NileFilterChart extends NileElem
606
497
  next.add(group.label);
607
498
  this.collapsedGroups = next;
608
499
  };
500
+ const visible = this._filterByPromptMode(group.controls);
609
501
  return html `
610
502
  <div class="fc-group" part="filter-group">
611
503
  <div class="fc-group__header ${group.collapsible ? 'fc-group__header--collapsible' : ''}"
@@ -620,39 +512,32 @@ let NileFilterChart = NileFilterChart_1 = class NileFilterChart extends NileElem
620
512
  </div>
621
513
  ${collapsed ? nothing : html `
622
514
  <div class="fc-group__body">
623
- ${group.controls.map(ctrl => this._renderControl(ctrl))}
515
+ ${visible.map(ctrl => this._renderControl(ctrl))}
624
516
  </div>`}
625
517
  </div>`;
626
518
  }
627
519
  render() {
520
+ this._normalizeIds();
628
521
  const entries = this.config?.chart?.controls ?? [];
522
+ // Top-level prompt-mode collapse: if a top-level prompt is in NQL mode,
523
+ // hide its top-level sibling controls (groups stay; their own collapse
524
+ // happens inside `_renderGroup`).
525
+ const topLevelControls = entries.filter(e => e.type !== 'group');
526
+ const visibleTop = new Set(this._filterByPromptMode(topLevelControls));
629
527
  return html `
630
528
  <div class="fc-root" part="filter-root">
631
- ${entries.map(entry => entry.type === 'group'
632
- ? this._renderGroup(entry)
633
- : this._renderControl(entry))}
529
+ ${entries.map(entry => {
530
+ if (entry.type === 'group') {
531
+ return this._renderGroup(entry);
532
+ }
533
+ const ctrl = entry;
534
+ return visibleTop.has(ctrl) ? this._renderControl(ctrl) : nothing;
535
+ })}
634
536
  </div>`;
635
537
  }
636
538
  };
637
- NileFilterChart._builtInFiltrexFns = {
638
- contains: (s, sub) => String(s ?? '').includes(String(sub ?? '')),
639
- startsWith: (s, p) => String(s ?? '').startsWith(String(p ?? '')),
640
- endsWith: (s, p) => String(s ?? '').endsWith(String(p ?? '')),
641
- lower: (s) => String(s ?? '').toLowerCase(),
642
- upper: (s) => String(s ?? '').toUpperCase(),
643
- len: (s) => (s == null ? 0 : Array.isArray(s) ? s.length : String(s).length),
644
- isEmpty: (s) => s == null || s === '' || (Array.isArray(s) && s.length === 0),
645
- isNotEmpty: (s) => !(s == null || s === '' || (Array.isArray(s) && s.length === 0)),
646
- between: (n, lo, hi) => Number(n) >= Number(lo) && Number(n) <= Number(hi),
647
- year: (d) => new Date(d).getFullYear(),
648
- month: (d) => new Date(d).getMonth() + 1,
649
- day: (d) => new Date(d).getDate(),
650
- daysAgo: (d) => (Date.now() - +new Date(d)) / 86400000,
651
- matches: (s, re) => new RegExp(String(re ?? '')).test(String(s ?? '')),
652
- coalesce: (...xs) => xs.find(x => x != null && x !== '') ?? null,
653
- };
654
- NileFilterChart._filtrexLoading = null;
655
- NileFilterChart._jsepLoading = null;
539
+ /** Pause after the last keystroke before validating and emitting nile-change. */
540
+ NileFilterChart._PROMPT_DEBOUNCE_MS = 500;
656
541
  __decorate([
657
542
  property({ attribute: false })
658
543
  ], NileFilterChart.prototype, "config", void 0);
@@ -664,7 +549,16 @@ __decorate([
664
549
  ], NileFilterChart.prototype, "collapsedGroups", void 0);
665
550
  __decorate([
666
551
  state()
667
- ], NileFilterChart.prototype, "_promptPlaceholder", void 0);
552
+ ], NileFilterChart.prototype, "promptPlaceholder", void 0);
553
+ __decorate([
554
+ state()
555
+ ], NileFilterChart.prototype, "promptErrors", void 0);
556
+ __decorate([
557
+ state()
558
+ ], NileFilterChart.prototype, "promptModes", void 0);
559
+ __decorate([
560
+ state()
561
+ ], NileFilterChart.prototype, "promptActiveIndex", void 0);
668
562
  NileFilterChart = NileFilterChart_1 = __decorate([
669
563
  customElement('nile-filter-chart')
670
564
  ], NileFilterChart);