@peter.prib/renderengine 0.13.0 → 0.14.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/README.md +454 -302
- package/dist/renderengine-workspace.umd.js +2203 -113
- package/dist/renderengine.umd.js +2203 -113
- package/documentation/REFERENCE.md +30 -1
- package/package.json +8 -3
- package/src/chartCatalog.js +50 -3
- package/src/charting/Chart3D.js +4 -2
- package/src/charting/Hierarchy.js +28 -0
- package/src/charting/ask/AskBar.js +388 -0
- package/src/charting/ask/AskView.js +400 -0
- package/src/charting/charts/AreaChart.js +20 -3
- package/src/charting/charts/BarChart3D.js +2 -2
- package/src/charting/charts/BarChart3DOrbit.js +3 -1
- package/src/charting/charts/BoxPlotChart.js +2 -2
- package/src/charting/charts/BubbleChart.js +4 -1
- package/src/charting/charts/ChordDiagram.js +4 -4
- package/src/charting/charts/DensityChart.js +1 -1
- package/src/charting/charts/FunnelChart.js +1 -1
- package/src/charting/charts/HeatmapChart.js +4 -1
- package/src/charting/charts/HistogramChart.js +2 -2
- package/src/charting/charts/IcicleChart.js +47 -0
- package/src/charting/charts/LollipopChart.js +104 -0
- package/src/charting/charts/MarimekkoChart.js +75 -0
- package/src/charting/charts/OutlineChart.js +10 -8
- package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
- package/src/charting/charts/ParetoChart.js +92 -0
- package/src/charting/charts/PipeChart.js +5 -2
- package/src/charting/charts/PopulationPyramidChart.js +4 -4
- package/src/charting/charts/RadarChart.js +5 -4
- package/src/charting/charts/RadialBarChart.js +2 -2
- package/src/charting/charts/SankeyDiagram.js +4 -4
- package/src/charting/charts/ScatterChart.js +6 -3
- package/src/charting/charts/StackedBarChart.js +82 -11
- package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
- package/src/charting/charts/StreamGraphChart.js +8 -0
- package/src/charting/charts/SunburstChart.js +2 -2
- package/src/charting/charts/TreemapChart.js +4 -4
- package/src/charting/charts/ViolinChart.js +2 -2
- package/src/charting/charts/WaterfallChart.js +2 -2
- package/src/charting/charts/WordCloudChart.js +2 -2
- package/src/charting/pivot/PivotChart.js +205 -13
- package/src/charting/pivot/PivotGridChart.js +1 -1
- package/src/charting/pivot/PivotView.js +544 -21
- package/src/index.js +98 -2
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AskView — the state and next-word prediction model for an interactive "Ask" bar
|
|
3
|
+
* (question input, scrollable predictive typeahead list with semantic sense/reference/relation
|
|
4
|
+
* badges, keyboard navigation, and configurable next-word prediction), kept apart from DOM
|
|
5
|
+
* rendering so all of it can be unit-tested under Node.js without a browser.
|
|
6
|
+
*
|
|
7
|
+
* A host can supply its own `suggest(text, view)` callback (for example, semanticengine's
|
|
8
|
+
* `(text) => engine.suggest(text, { sampler: (dim) => engine.members(dim) })`), or pass
|
|
9
|
+
* declarative vocabulary hints (`measures`, `dimensions`, `members`, `vocabulary`,
|
|
10
|
+
* `sampleQuestions`) to use `AskView.nextWordPredictor(...)`.
|
|
11
|
+
*/
|
|
12
|
+
export class AskView {
|
|
13
|
+
/** Default choices for how many predicted next-word items are visible at once in the scrollable list (`0` = Off). */
|
|
14
|
+
static DEFAULT_PREDICT_CHOICES = [0, 3, 5, 8, 10];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} [options]
|
|
18
|
+
* @param {string} [options.value=''] initial question text
|
|
19
|
+
* @param {function} [options.suggest] `(text, view) => Array<string|{display, completion?, sense?, reference?, relation?, kind?}>`, or a promise of one
|
|
20
|
+
* @param {string[]} [options.measures] measure names for built-in next-word prediction
|
|
21
|
+
* @param {string[]} [options.dimensions] dimension names for built-in next-word prediction
|
|
22
|
+
* @param {function|object} [options.members] `(dimension) => Array<string|number>` or `{ dim: [...] }` for value prediction after `=`
|
|
23
|
+
* @param {Array<string|{display, completion?, sense?, reference?, relation?, kind?}>} [options.vocabulary] custom vocabulary entries
|
|
24
|
+
* @param {string[]} [options.sampleQuestions] sample questions used both as clickable pills and to learn next-word transitions
|
|
25
|
+
* @param {boolean} [options.predict=true] whether next-word prediction is initially enabled
|
|
26
|
+
* @param {number} [options.visibleCount=5] how many items are visible at once in the scrollable suggestion viewport
|
|
27
|
+
* @param {number[]} [options.predictChoices=[0, 3, 5, 8, 10]] configurable choices for the predict-next-word selector (`0` = Off)
|
|
28
|
+
* @param {number} [options.maxSuggestions=50] maximum number of suggestions kept in the scrollable list
|
|
29
|
+
* @param {function} [options.onAsk] `(question, view) => *` called when the user submits the question
|
|
30
|
+
* @param {function} [options.onComplete] `(item, view) => void` called when a suggestion is accepted
|
|
31
|
+
* @param {function} [options.onChange] `(view) => void` called whenever view state changes
|
|
32
|
+
*/
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
this.value = options.value ?? '';
|
|
35
|
+
this.samples = [...(options.sampleQuestions ?? options.samples ?? [])];
|
|
36
|
+
this.predictChoices = Array.isArray(options.predictChoices) && options.predictChoices.length > 0
|
|
37
|
+
? [...options.predictChoices]
|
|
38
|
+
: [...AskView.DEFAULT_PREDICT_CHOICES];
|
|
39
|
+
const initialVisible = Number.isFinite(options.visibleCount) && options.visibleCount > 0 ? Math.floor(options.visibleCount) : 5;
|
|
40
|
+
this.visibleCount = initialVisible;
|
|
41
|
+
this.predict = options.predict !== false && options.visibleCount !== 0;
|
|
42
|
+
if (this.predict && !this.predictChoices.includes(this.visibleCount)) {
|
|
43
|
+
this.predictChoices = [...new Set([...this.predictChoices, this.visibleCount])].sort((a, b) => a - b);
|
|
44
|
+
}
|
|
45
|
+
this.maxSuggestions = Number.isFinite(options.maxSuggestions) && options.maxSuggestions > 0
|
|
46
|
+
? Math.floor(options.maxSuggestions)
|
|
47
|
+
: 50;
|
|
48
|
+
this.suggestFn = typeof options.suggest === 'function'
|
|
49
|
+
? options.suggest
|
|
50
|
+
: AskView.nextWordPredictor({
|
|
51
|
+
measures: options.measures,
|
|
52
|
+
dimensions: options.dimensions,
|
|
53
|
+
members: options.members,
|
|
54
|
+
vocabulary: options.vocabulary,
|
|
55
|
+
sampleQuestions: this.samples,
|
|
56
|
+
});
|
|
57
|
+
this.onAsk = options.onAsk ?? null;
|
|
58
|
+
this.onComplete = options.onComplete ?? null;
|
|
59
|
+
this.onChange = options.onChange ?? null;
|
|
60
|
+
this.items = [];
|
|
61
|
+
this.selectedIndex = -1;
|
|
62
|
+
this.open = false;
|
|
63
|
+
this.history = [];
|
|
64
|
+
this._ticket = 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Current predict-next-word setting as a single number (`0` when Off, else `visibleCount`),
|
|
69
|
+
* matching `predictChoices`.
|
|
70
|
+
*/
|
|
71
|
+
get predictCount() {
|
|
72
|
+
return this.predict ? this.visibleCount : 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Enables or disables next-word prediction, or sets how many items show at once in the
|
|
77
|
+
* scrollable list (`0` or `false` turns prediction off; a positive integer enables it with
|
|
78
|
+
* that viewport size).
|
|
79
|
+
*/
|
|
80
|
+
setPredictCount(countOrEnabled) {
|
|
81
|
+
if (countOrEnabled === false || countOrEnabled === 0 || countOrEnabled === '0' || countOrEnabled === 'off') {
|
|
82
|
+
this.predict = false;
|
|
83
|
+
this.items = [];
|
|
84
|
+
this.selectedIndex = -1;
|
|
85
|
+
this.open = false;
|
|
86
|
+
this._changed();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (countOrEnabled === true) {
|
|
90
|
+
this.predict = true;
|
|
91
|
+
if (this.visibleCount <= 0) this.visibleCount = 5;
|
|
92
|
+
} else {
|
|
93
|
+
const n = Number(countOrEnabled);
|
|
94
|
+
if (Number.isFinite(n) && n > 0) {
|
|
95
|
+
this.predict = true;
|
|
96
|
+
this.visibleCount = Math.floor(n);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this.refresh();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Updates the input text and refreshes next-word predictions if enabled. */
|
|
103
|
+
setInput(text, { suggest = true } = {}) {
|
|
104
|
+
this.value = String(text ?? '');
|
|
105
|
+
if (suggest && this.predict) {
|
|
106
|
+
return this.refresh();
|
|
107
|
+
} else {
|
|
108
|
+
if (!suggest) {
|
|
109
|
+
this.open = false;
|
|
110
|
+
this.selectedIndex = -1;
|
|
111
|
+
}
|
|
112
|
+
this._changed();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Swaps the source of suggestions (a host whose vocabulary changed — another model or
|
|
118
|
+
* ontology) and refreshes the list.
|
|
119
|
+
*/
|
|
120
|
+
setSuggest(suggest) {
|
|
121
|
+
this.suggestFn = typeof suggest === 'function' ? suggest : null;
|
|
122
|
+
this.refresh();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Recomputes suggestions for the current `value` and opens the list if any match. Nothing is
|
|
127
|
+
* selected until the user moves into the list, so Enter asks the question as typed.
|
|
128
|
+
*
|
|
129
|
+
* `suggest` may answer with a promise (an engine still loading, a server's vocabulary): the
|
|
130
|
+
* list keeps what it shows until it settles, and an answer for text since changed is dropped.
|
|
131
|
+
* Returns the items, or a promise of them.
|
|
132
|
+
*/
|
|
133
|
+
refresh() {
|
|
134
|
+
const ticket = ++this._ticket;
|
|
135
|
+
if (!this.predict || !this.suggestFn) {
|
|
136
|
+
this._apply([]);
|
|
137
|
+
return this.items;
|
|
138
|
+
}
|
|
139
|
+
let raw;
|
|
140
|
+
try {
|
|
141
|
+
raw = this.suggestFn(this.value, this);
|
|
142
|
+
} catch {
|
|
143
|
+
this._apply([]);
|
|
144
|
+
return this.items;
|
|
145
|
+
}
|
|
146
|
+
if (raw && typeof raw.then === 'function') {
|
|
147
|
+
const asked = this.value;
|
|
148
|
+
return Promise.resolve(raw).then(
|
|
149
|
+
(items) => (ticket === this._ticket && asked === this.value ? this._apply(items) : this.items),
|
|
150
|
+
() => (ticket === this._ticket ? this._apply([]) : this.items),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return this._apply(raw);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Shows `raw` suggestions (anything not a list shows none). */
|
|
157
|
+
_apply(raw) {
|
|
158
|
+
this.items = Array.isArray(raw)
|
|
159
|
+
? raw.slice(0, this.maxSuggestions).map((item) => AskView.normalizeItem(item, this.value))
|
|
160
|
+
: [];
|
|
161
|
+
this.selectedIndex = -1;
|
|
162
|
+
this.open = this.items.length > 0;
|
|
163
|
+
this._changed();
|
|
164
|
+
return this.items;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Moves the active highlight up (`-1`) or down (`+1`) in the scrollable list, wrapping around.
|
|
169
|
+
* From no selection, down goes to the first item and up to the last.
|
|
170
|
+
*/
|
|
171
|
+
move(delta) {
|
|
172
|
+
if (!this.open || this.items.length === 0) {
|
|
173
|
+
this.refresh();
|
|
174
|
+
return this.selectedIndex;
|
|
175
|
+
}
|
|
176
|
+
const len = this.items.length;
|
|
177
|
+
this.selectedIndex = this.selectedIndex < 0
|
|
178
|
+
? (delta > 0 ? 0 : len - 1)
|
|
179
|
+
: (this.selectedIndex + delta + len) % len;
|
|
180
|
+
this._changed();
|
|
181
|
+
return this.selectedIndex;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Applies the suggestion at `index` (defaults to `selectedIndex`, or `0`), replacing `value`
|
|
186
|
+
* with `item.completion` and immediately predicting the next word.
|
|
187
|
+
*/
|
|
188
|
+
accept(index = this.selectedIndex >= 0 ? this.selectedIndex : 0) {
|
|
189
|
+
if (index < 0 || index >= this.items.length) return null;
|
|
190
|
+
const item = this.items[index];
|
|
191
|
+
this.value = item.completion;
|
|
192
|
+
this.onComplete?.(item, this);
|
|
193
|
+
if (this.predict) {
|
|
194
|
+
this.refresh();
|
|
195
|
+
} else {
|
|
196
|
+
this.close();
|
|
197
|
+
}
|
|
198
|
+
return item;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Closes the suggestion popup without changing `value`. */
|
|
202
|
+
close() {
|
|
203
|
+
if (!this.open && this.selectedIndex === -1) return;
|
|
204
|
+
this.open = false;
|
|
205
|
+
this.selectedIndex = -1;
|
|
206
|
+
this._changed();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Submits the current question (`value`), closes the suggestion list, and invokes `onAsk`. */
|
|
210
|
+
submit(question = this.value) {
|
|
211
|
+
this.value = String(question ?? '');
|
|
212
|
+
this.open = false;
|
|
213
|
+
this.selectedIndex = -1;
|
|
214
|
+
const trimmed = this.value.trim();
|
|
215
|
+
if (trimmed && this.history.at(-1) !== trimmed) {
|
|
216
|
+
this.history.push(trimmed);
|
|
217
|
+
}
|
|
218
|
+
this._changed();
|
|
219
|
+
return this.onAsk?.(this.value, this);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_changed() {
|
|
223
|
+
this.onChange?.(this);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Normalizes a string or suggestion object into `{ display, completion, sense, reference, relation, kind }`. */
|
|
227
|
+
static normalizeItem(item, currentText = '') {
|
|
228
|
+
if (typeof item === 'string') {
|
|
229
|
+
return {
|
|
230
|
+
display: item,
|
|
231
|
+
completion: AskView._completeWord(currentText, item),
|
|
232
|
+
sense: null,
|
|
233
|
+
reference: null,
|
|
234
|
+
relation: null,
|
|
235
|
+
kind: 'suggestion',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const display = String(item.display ?? item.word ?? item.label ?? item.completion ?? '');
|
|
239
|
+
const completion = item.completion !== undefined
|
|
240
|
+
? String(item.completion)
|
|
241
|
+
: AskView._completeWord(currentText, display);
|
|
242
|
+
return {
|
|
243
|
+
display,
|
|
244
|
+
completion,
|
|
245
|
+
sense: item.sense ?? null,
|
|
246
|
+
reference: item.reference ?? null,
|
|
247
|
+
relation: item.relation ?? null,
|
|
248
|
+
kind: item.kind ?? 'suggestion',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Replaces the trailing partial token of `currentText` with `word` (followed by a space). */
|
|
253
|
+
static _completeWord(currentText, word) {
|
|
254
|
+
const text = String(currentText ?? '');
|
|
255
|
+
if (word.includes(' ') && word.toLowerCase().startsWith(text.trim().toLowerCase()) && text.trim().length > 0) {
|
|
256
|
+
return word;
|
|
257
|
+
}
|
|
258
|
+
const match = text.match(/^(.*?)(\S*)$/);
|
|
259
|
+
const prefix = match ? match[1] : '';
|
|
260
|
+
const cleanWord = String(word).trim();
|
|
261
|
+
return `${prefix}${cleanWord} `;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Builds a context-aware next-word predictor from declarative cube/table metadata and/or
|
|
266
|
+
* sample questions. Predicts metrics, dimensions, clauses (`by`, `and`, `where`, `top`, `vs`),
|
|
267
|
+
* sampled dimension members after `=`, and learned next-word bigrams from `sampleQuestions`.
|
|
268
|
+
*/
|
|
269
|
+
static nextWordPredictor({
|
|
270
|
+
measures = [],
|
|
271
|
+
dimensions = [],
|
|
272
|
+
members = null,
|
|
273
|
+
vocabulary = [],
|
|
274
|
+
sampleQuestions = [],
|
|
275
|
+
} = {}) {
|
|
276
|
+
const catalog = [];
|
|
277
|
+
const addEntry = (display, kind, sense = null, reference = null, relation = null) => {
|
|
278
|
+
if (!display) return;
|
|
279
|
+
if (catalog.some(c => c.display.toLowerCase() === String(display).toLowerCase() && c.kind === kind)) return;
|
|
280
|
+
catalog.push({ display: String(display), kind, sense, reference, relation });
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
for (const m of measures) addEntry(m, 'metric', `Measure ${m}`, m, 'canonical');
|
|
284
|
+
for (const d of dimensions) addEntry(d, 'dimension', `Dimension ${d}`, d, 'canonical');
|
|
285
|
+
for (const v of vocabulary) {
|
|
286
|
+
if (typeof v === 'string') addEntry(v, 'term');
|
|
287
|
+
else addEntry(v.display ?? v.word ?? v.label, v.kind ?? 'term', v.sense ?? null, v.reference ?? null, v.relation ?? null);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const keywords = [
|
|
291
|
+
{ display: 'by', kind: 'keyword', sense: 'Group or slice by dimension' },
|
|
292
|
+
{ display: 'and', kind: 'keyword', sense: 'Add another measure or dimension' },
|
|
293
|
+
{ display: 'where', kind: 'keyword', sense: 'Filter by dimension member' },
|
|
294
|
+
{ display: 'top', kind: 'keyword', sense: 'Rank top N members' },
|
|
295
|
+
{ display: '=', kind: 'operator', sense: 'Equals member value' },
|
|
296
|
+
{ display: 'in', kind: 'keyword', sense: 'Match any of several members' },
|
|
297
|
+
{ display: 'vs', kind: 'keyword', sense: 'Compare against another measure or period' },
|
|
298
|
+
{ display: 'descending', kind: 'keyword', sense: 'Sort highest first' },
|
|
299
|
+
{ display: 'ascending', kind: 'keyword', sense: 'Sort lowest first' },
|
|
300
|
+
];
|
|
301
|
+
for (const kw of keywords) addEntry(kw.display, kw.kind, kw.sense);
|
|
302
|
+
|
|
303
|
+
// Learn next-word transitions (bigrams) from sampleQuestions.
|
|
304
|
+
const bigrams = new Map();
|
|
305
|
+
for (const q of sampleQuestions) {
|
|
306
|
+
const tokens = String(q).trim().split(/\s+/).filter(Boolean);
|
|
307
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
308
|
+
const prev = tokens[i].replace(/,+$/, '').toLowerCase();
|
|
309
|
+
const next = tokens[i + 1].replace(/,+$/, '');
|
|
310
|
+
if (!bigrams.has(prev)) bigrams.set(prev, new Set());
|
|
311
|
+
bigrams.get(prev).add(next);
|
|
312
|
+
addEntry(tokens[i].replace(/,+$/, ''), 'term');
|
|
313
|
+
}
|
|
314
|
+
if (tokens.length > 0) addEntry(tokens.at(-1).replace(/,+$/, ''), 'term');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const getMembers = (dim) => {
|
|
318
|
+
if (!dim || !members) return [];
|
|
319
|
+
if (typeof members === 'function') return members(dim) ?? [];
|
|
320
|
+
const foundKey = Object.keys(members).find(k => k.toLowerCase() === dim.toLowerCase());
|
|
321
|
+
return foundKey ? members[foundKey] ?? [] : [];
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
return (text) => {
|
|
325
|
+
const raw = String(text ?? '');
|
|
326
|
+
const match = raw.match(/^(.*?)(\S*)$/);
|
|
327
|
+
const before = match ? match[1] : '';
|
|
328
|
+
const partial = (match ? match[2] : '').toLowerCase();
|
|
329
|
+
const priorTokens = before.trim().split(/\s+/).filter(Boolean);
|
|
330
|
+
const lastToken = (priorTokens.at(-1) ?? '').replace(/,+$/, '').toLowerCase();
|
|
331
|
+
const prevToken = (priorTokens.at(-2) ?? '').replace(/,+$/, '').toLowerCase();
|
|
332
|
+
|
|
333
|
+
const results = [];
|
|
334
|
+
const push = (entry, bonus = 0) => {
|
|
335
|
+
const word = String(entry.display);
|
|
336
|
+
if (partial && !word.toLowerCase().startsWith(partial)) return;
|
|
337
|
+
if (results.some(r => r.display.toLowerCase() === word.toLowerCase())) return;
|
|
338
|
+
results.push({
|
|
339
|
+
display: word,
|
|
340
|
+
completion: AskView._completeWord(raw, word),
|
|
341
|
+
sense: entry.sense ?? null,
|
|
342
|
+
reference: entry.reference ?? null,
|
|
343
|
+
relation: entry.relation ?? null,
|
|
344
|
+
kind: entry.kind ?? 'suggestion',
|
|
345
|
+
_score: bonus + (word.toLowerCase() === partial ? 0 : 10),
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// 1. After `<dimension> =`, suggest sampled member values of that dimension.
|
|
350
|
+
if (lastToken === '=' && prevToken) {
|
|
351
|
+
const dimName = dimensions.find(d => d.toLowerCase() === prevToken) ?? prevToken;
|
|
352
|
+
for (const val of getMembers(dimName)) {
|
|
353
|
+
push({
|
|
354
|
+
display: String(val),
|
|
355
|
+
kind: 'value',
|
|
356
|
+
sense: `Member of ${dimName}`,
|
|
357
|
+
reference: `${dimName} = ${val}`,
|
|
358
|
+
}, 100);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 2. Context-sensitive grammar transitions.
|
|
363
|
+
if (!lastToken) {
|
|
364
|
+
for (const c of catalog.filter(e => e.kind === 'metric')) push(c, 80);
|
|
365
|
+
for (const c of catalog.filter(e => e.display === 'top')) push(c, 70);
|
|
366
|
+
for (const c of catalog.filter(e => e.kind === 'dimension')) push(c, 60);
|
|
367
|
+
} else if (lastToken === 'by' || lastToken === 'where') {
|
|
368
|
+
for (const c of catalog.filter(e => e.kind === 'dimension')) push(c, 90);
|
|
369
|
+
} else if (lastToken === 'and') {
|
|
370
|
+
const inByClause = priorTokens.some(t => t.toLowerCase() === 'by');
|
|
371
|
+
for (const c of catalog.filter(e => e.kind === (inByClause ? 'dimension' : 'metric'))) push(c, 90);
|
|
372
|
+
for (const c of catalog.filter(e => e.kind === (inByClause ? 'metric' : 'dimension'))) push(c, 70);
|
|
373
|
+
} else if (dimensions.some(d => d.toLowerCase() === lastToken)) {
|
|
374
|
+
const inWhere = priorTokens.some(t => t.toLowerCase() === 'where');
|
|
375
|
+
if (inWhere) {
|
|
376
|
+
for (const c of catalog.filter(e => e.display === '=')) push(c, 95);
|
|
377
|
+
} else {
|
|
378
|
+
for (const c of catalog.filter(e => ['and', 'where', 'descending', 'ascending'].includes(e.display))) push(c, 85);
|
|
379
|
+
}
|
|
380
|
+
} else if (measures.some(m => m.toLowerCase() === lastToken)) {
|
|
381
|
+
for (const c of catalog.filter(e => ['by', 'and', 'where', 'vs'].includes(e.display))) push(c, 90);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// 3. Learned bigram transitions from sample questions.
|
|
385
|
+
if (lastToken && bigrams.has(lastToken)) {
|
|
386
|
+
for (const nextWord of bigrams.get(lastToken)) {
|
|
387
|
+
const existing = catalog.find(c => c.display.toLowerCase() === nextWord.toLowerCase());
|
|
388
|
+
push(existing ?? { display: nextWord, kind: 'next word', sense: `Follows "${lastToken}"` }, 88);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 4. All remaining catalog entries matching the partial prefix.
|
|
393
|
+
for (const c of catalog) push(c, 20);
|
|
394
|
+
|
|
395
|
+
return results
|
|
396
|
+
.sort((a, b) => b._score - a._score)
|
|
397
|
+
.map(({ _score, ...rest }) => rest);
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}
|
|
@@ -16,8 +16,10 @@ export class AreaChart extends Chart {
|
|
|
16
16
|
const box = ChartBox.fromRenderer(this.renderer);
|
|
17
17
|
const {
|
|
18
18
|
x = box.x, y = box.y, width = box.width, height = box.height,
|
|
19
|
-
colors, title, xAxisLabel, yAxisLabel, stacked = false,
|
|
19
|
+
colors, title, xAxisLabel, yAxisLabel, stacked = false,
|
|
20
|
+
normalize = false, stackMode, legend: showLegend = false,
|
|
20
21
|
} = options;
|
|
22
|
+
const isPercent = Boolean(stacked && (normalize || stackMode === 'percent'));
|
|
21
23
|
const plotBox = { x, y, width, height };
|
|
22
24
|
const isMultiline = Array.isArray(data[0]?.values);
|
|
23
25
|
const series = isMultiline ? data : [{ name: 'default', values: data }];
|
|
@@ -26,6 +28,10 @@ export class AreaChart extends Chart {
|
|
|
26
28
|
const palette = new ColorPalette(colors);
|
|
27
29
|
const textAnchor = this.renderer.centerTextAnchor;
|
|
28
30
|
|
|
31
|
+
const pointTotals = isPercent
|
|
32
|
+
? Array.from({ length: pointCount }, (_, i) => series.reduce((sum, s) => sum + Math.max(0, s.values[i]?.value ?? 0), 0))
|
|
33
|
+
: null;
|
|
34
|
+
|
|
29
35
|
// Precompute each series' [base, top] band per point — base is 0
|
|
30
36
|
// unless stacked, in which case it's the running cumulative of prior
|
|
31
37
|
// series (computed once here, not re-derived during render).
|
|
@@ -33,12 +39,15 @@ export class AreaChart extends Chart {
|
|
|
33
39
|
const bands = series.map(s => {
|
|
34
40
|
const band = s.values.map((d, i) => {
|
|
35
41
|
const base = stacked ? running[i] : 0;
|
|
36
|
-
|
|
42
|
+
const val = isPercent
|
|
43
|
+
? (pointTotals[i] > 0 ? (Math.max(0, d.value) / pointTotals[i]) * 100 : 0)
|
|
44
|
+
: d.value;
|
|
45
|
+
return { base, top: base + val, pct: val };
|
|
37
46
|
});
|
|
38
47
|
if (stacked) running = band.map(b => b.top);
|
|
39
48
|
return band;
|
|
40
49
|
});
|
|
41
|
-
const maxValue = Math.max(...bands.flat().map(b => b.top)) || 1;
|
|
50
|
+
const maxValue = isPercent ? 100 : (Math.max(...bands.flat().map(b => b.top)) || 1);
|
|
42
51
|
|
|
43
52
|
new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
|
|
44
53
|
this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
|
|
@@ -53,6 +62,14 @@ export class AreaChart extends Chart {
|
|
|
53
62
|
const { r, g, b: bl } = palette.hexToRgb(color);
|
|
54
63
|
this.renderer.path(areaPath, `rgba(${r},${g},${bl},0.35)`, null, { tooltip: s.name });
|
|
55
64
|
this.renderer.path('M' + topPts.map(p => `${p.px},${p.py}`).join(' L'), 'none', { color, width: 2 });
|
|
65
|
+
if (typeof this.renderer.circle === 'function') {
|
|
66
|
+
topPts.forEach((p, i) => {
|
|
67
|
+
const d = s.values[i];
|
|
68
|
+
this._datum(this.renderer.circle(p.px, p.py, 3, color, { color: '#fff', width: 1 }, {
|
|
69
|
+
tooltip: `${isMultiline ? s.name + ': ' : ''}${d.label}: ${d.value}`,
|
|
70
|
+
}), { label: d.label, series: isMultiline ? s.name : null });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
56
73
|
|
|
57
74
|
if (seriesIndex === 0) {
|
|
58
75
|
const step = Axis.categoryLabelStep(s.values.map(d => d.label), width, { fontSize: 10 });
|
|
@@ -34,12 +34,12 @@ export class BarChart3D extends Chart {
|
|
|
34
34
|
const barBaseY = y + height;
|
|
35
35
|
const color = palette.colorAt(i);
|
|
36
36
|
|
|
37
|
-
this.renderer.rect(
|
|
37
|
+
this._datum(this.renderer.rect(
|
|
38
38
|
barX, barBaseY - barHeight, barWidth, barHeight,
|
|
39
39
|
color,
|
|
40
40
|
{ color: '#333', width: 1 },
|
|
41
41
|
{ tooltip: `${d.label}: ${d.value}` },
|
|
42
|
-
);
|
|
42
|
+
), { label: d.label });
|
|
43
43
|
|
|
44
44
|
// Top face
|
|
45
45
|
const topPath = `M${barX},${barBaseY - barHeight} L${barX + barWidth},${barBaseY - barHeight} L${barX + barWidth + depth},${barBaseY - barHeight - depth} L${barX + depth},${barBaseY - barHeight - depth} Z`;
|
|
@@ -40,7 +40,9 @@ export class BarChart3DOrbit extends Chart3D {
|
|
|
40
40
|
}).sort((a, b) => b.depth - a.depth);
|
|
41
41
|
|
|
42
42
|
for (const bar of bars) {
|
|
43
|
-
this.drawBox(this.renderer, bar.x0, bar.x1, 0, bar.y1, bar.z0, bar.z1, bar.color, toScreen
|
|
43
|
+
this.drawBox(this.renderer, bar.x0, bar.x1, 0, bar.y1, bar.z0, bar.z1, bar.color, toScreen, {
|
|
44
|
+
label: bar.d.x, series: bar.d.z, x: bar.d.x, z: bar.d.z,
|
|
45
|
+
});
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
new Legend({
|
|
@@ -30,9 +30,9 @@ export class BoxPlotChart extends GroupedDistributionChart {
|
|
|
30
30
|
this.renderer.path(`M${cx - boxWidth / 4},${this._toY(s.min)} L${cx + boxWidth / 4},${this._toY(s.min)}`, 'none', { color: '#555', width: 1 });
|
|
31
31
|
this.renderer.path(`M${cx - boxWidth / 4},${this._toY(s.max)} L${cx + boxWidth / 4},${this._toY(s.max)}`, 'none', { color: '#555', width: 1 });
|
|
32
32
|
const boxTop = this._toY(s.q3), boxBottom = this._toY(s.q1);
|
|
33
|
-
this.renderer.rect(cx - boxWidth / 2, boxTop, boxWidth, Math.max(1, boxBottom - boxTop), boxColor, { color: '#333', width: 1 }, {
|
|
33
|
+
this._datum(this.renderer.rect(cx - boxWidth / 2, boxTop, boxWidth, Math.max(1, boxBottom - boxTop), boxColor, { color: '#333', width: 1 }, {
|
|
34
34
|
tooltip: `${d.label}: median ${Axis.formatValue(s.median)}, Q1 ${Axis.formatValue(s.q1)}, Q3 ${Axis.formatValue(s.q3)}`,
|
|
35
|
-
});
|
|
35
|
+
}), { label: d.label, series: d.label });
|
|
36
36
|
this.renderer.path(`M${cx - boxWidth / 2},${this._toY(s.median)} L${cx + boxWidth / 2},${this._toY(s.median)}`, 'none', { color: '#fff', width: 2 });
|
|
37
37
|
s.outliers.forEach(v => {
|
|
38
38
|
this.renderer.circle(cx, this._toY(v), 3, 'none', { color: boxColor, width: 1.5 }, { tooltip: `${d.label} outlier: ${Axis.formatValue(v)}` });
|
|
@@ -83,7 +83,10 @@ export class BubbleChart extends Chart {
|
|
|
83
83
|
// 3D effect: draw a line to the "floor"
|
|
84
84
|
this.renderer.path(`M${floorX},${floorY} L${bubbleX},${bubbleY}`, 'none', { color: '#e0e0e0', width: 1 });
|
|
85
85
|
}
|
|
86
|
-
|
|
86
|
+
const series = catField ? ChartData.readColorCol(d, catField) : null;
|
|
87
|
+
this._datum(this.renderer.circle(bubbleX, bubbleY, radius, color, { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip: `${d.label}: (${d.x}, ${d.y}, ${d.z})` }), {
|
|
88
|
+
label: d.label ?? String(d.x), series: series != null ? String(series) : null,
|
|
89
|
+
});
|
|
87
90
|
});
|
|
88
91
|
|
|
89
92
|
// Category color legend
|
|
@@ -92,19 +92,19 @@ export class ChordDiagram extends Chart {
|
|
|
92
92
|
const c2x = cx + (innerRadius * 0.2) * Math.cos(t.mid);
|
|
93
93
|
const c2y = cy + (innerRadius * 0.2) * Math.sin(t.mid);
|
|
94
94
|
const width = Math.max(1, (l.value / maxLink) * maxStroke);
|
|
95
|
-
this.renderer.path(`M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`, 'none', {
|
|
95
|
+
this._datum(this.renderer.path(`M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`, 'none', {
|
|
96
96
|
color: s.color, width,
|
|
97
97
|
}, {
|
|
98
98
|
tooltip: `${s.label} ↔ ${t.label}: ${l.value}`,
|
|
99
|
-
});
|
|
99
|
+
}), { label: s.label, series: t.label });
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
// Draw node arcs.
|
|
103
103
|
nodes.forEach(n => {
|
|
104
104
|
const path = this._arcPath(cx, cy, radius, innerRadius, n.start, n.end);
|
|
105
|
-
this.renderer.path(path, n.color, { color: '#fff', width: 1 }, {
|
|
105
|
+
this._datum(this.renderer.path(path, n.color, { color: '#fff', width: 1 }, {
|
|
106
106
|
tooltip: `${n.label}: ${n.value}`,
|
|
107
|
-
});
|
|
107
|
+
}), { label: n.label });
|
|
108
108
|
|
|
109
109
|
// Label outside the circle.
|
|
110
110
|
const lx = cx + (radius + 16) * Math.cos(n.mid);
|
|
@@ -58,7 +58,7 @@ export class DensityChart extends Chart {
|
|
|
58
58
|
const topPath = 'M' + topPts.map(p => `${p.px},${p.py}`).join(' L');
|
|
59
59
|
const areaPath = topPath + ` L${x + width},${y + height} L${x},${y + height} Z`;
|
|
60
60
|
const { r, g, b } = ColorPalette.hexToRgb(color);
|
|
61
|
-
this.renderer.path(areaPath, `rgba(${r},${g},${b},0.25)`, null, { tooltip: d.label });
|
|
61
|
+
this._datum(this.renderer.path(areaPath, `rgba(${r},${g},${b},0.25)`, null, { tooltip: d.label }), { label: d.label, series: d.label });
|
|
62
62
|
this.renderer.path(topPath, 'none', { color, width: 2 });
|
|
63
63
|
});
|
|
64
64
|
|
|
@@ -38,7 +38,7 @@ export class FunnelChart extends Chart {
|
|
|
38
38
|
const bottomRight = bottomLeft + bottomWidth;
|
|
39
39
|
|
|
40
40
|
const path = `M${topLeft},${top} L${topRight},${top} L${bottomRight},${bottom} L${bottomLeft},${bottom} Z`;
|
|
41
|
-
this.renderer.path(path, palette.colorAt(i), { color: '#fff', width: 1 }, { tooltip: `${d.label}: ${d.value}` });
|
|
41
|
+
this._datum(this.renderer.path(path, palette.colorAt(i), { color: '#fff', width: 1 }, { tooltip: `${d.label}: ${d.value}` }), { label: d.label });
|
|
42
42
|
this.renderer.text(`${d.label} (${d.value})`, x + width / 2, top + stageHeight / 2, {
|
|
43
43
|
textAnchor, verticalAlign: 'middle', fontSize: '12px', fill: '#fff', fontWeight: 'bold',
|
|
44
44
|
});
|
|
@@ -52,9 +52,12 @@ export class HeatmapChart extends Chart {
|
|
|
52
52
|
const cellX = x + xi * cellW;
|
|
53
53
|
const cellY = y + yi * cellH;
|
|
54
54
|
const color = v == null ? '#eee' : ColorPalette.scaleColor(colorScale, vMin, vMax, v);
|
|
55
|
-
this.renderer.rect(cellX, cellY, Math.max(1, cellW - 1), Math.max(1, cellH - 1), color, null, {
|
|
55
|
+
const cellRect = this.renderer.rect(cellX, cellY, Math.max(1, cellW - 1), Math.max(1, cellH - 1), color, null, {
|
|
56
56
|
tooltip: `${xc} / ${yc}: ${v ?? 'n/a'}`,
|
|
57
57
|
});
|
|
58
|
+
if (v != null) {
|
|
59
|
+
this._datum(cellRect, { label: yc, series: xc, x: xc, y: yc });
|
|
60
|
+
}
|
|
58
61
|
if (showValues && v != null) {
|
|
59
62
|
this.renderer.text(Axis.formatValue(v), cellX + cellW / 2, cellY + cellH / 2, {
|
|
60
63
|
textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: ColorPalette.isDark(color) ? '#fff' : '#333',
|
|
@@ -34,9 +34,9 @@ export class HistogramChart extends Chart {
|
|
|
34
34
|
const barY = y + height - barHeight;
|
|
35
35
|
// No gap between bars (unlike barChart's categorical gaps) — bins
|
|
36
36
|
// are contiguous numeric ranges, not independent categories.
|
|
37
|
-
this.renderer.rect(barX, barY, Math.max(1, barWidth - 1), barHeight, barColor, null, {
|
|
37
|
+
this._datum(this.renderer.rect(barX, barY, Math.max(1, barWidth - 1), barHeight, barColor, null, {
|
|
38
38
|
tooltip: `${Axis.formatValue(bin.from)}–${Axis.formatValue(bin.to)}: ${bin.count}`,
|
|
39
|
-
});
|
|
39
|
+
}), { label: `${Axis.formatValue(bin.from)}–${Axis.formatValue(bin.to)}` });
|
|
40
40
|
if (i % Math.ceil(bins.length / 10 || 1) === 0) {
|
|
41
41
|
this.renderer.text(Axis.formatValue(bin.from), barX, y + height + 16, { textAnchor: 'start', fontSize: '9px' });
|
|
42
42
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ChartBox, ChartLabels } from '../ChartBox.js';
|
|
2
|
+
import { Hierarchy } from '../Hierarchy.js';
|
|
3
|
+
import { HierarchyChart } from './HierarchyChart.js';
|
|
4
|
+
import { TableChart } from './TableChart.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* IcicleChart — rectangular hierarchical partition chart (top-down flame/icicle layout).
|
|
8
|
+
* Each depth level forms a horizontal band and each node's width is proportional to its value
|
|
9
|
+
* within its parent's span.
|
|
10
|
+
*/
|
|
11
|
+
export class IcicleChart extends HierarchyChart {
|
|
12
|
+
static chartType = 'icicle';
|
|
13
|
+
|
|
14
|
+
_render() {
|
|
15
|
+
const data = this.data ?? [];
|
|
16
|
+
const options = this.options;
|
|
17
|
+
const box = ChartBox.fromRenderer(this.renderer);
|
|
18
|
+
const { x = box.x, y = box.y, width = box.width, height = box.height, title } = options;
|
|
19
|
+
const plotBox = { x, y, width, height };
|
|
20
|
+
|
|
21
|
+
new ChartLabels({ title }).draw(this.renderer, plotBox);
|
|
22
|
+
if (data.length === 0) return;
|
|
23
|
+
|
|
24
|
+
const maxDepth = Math.max(1, ...data.map(n => Hierarchy.depth(n)));
|
|
25
|
+
const bandHeight = height / maxDepth;
|
|
26
|
+
const layout = Hierarchy.icicleLayout(data, x, y, width, bandHeight);
|
|
27
|
+
const textAnchor = this.renderer.centerTextAnchor;
|
|
28
|
+
const measure = (s) => this.renderer.measureText?.(s, { fontSize: '10px' }) ?? s.length * 10 * 0.55;
|
|
29
|
+
|
|
30
|
+
layout.forEach(entry => {
|
|
31
|
+
const { node, x: rx, y: ry, width: rw, height: rh, depth, topAncestor } = entry;
|
|
32
|
+
const color = this._colorForNode(topAncestor, depth);
|
|
33
|
+
const textColor = this._textColorForNode(topAncestor, depth);
|
|
34
|
+
|
|
35
|
+
this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
|
|
36
|
+
tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
|
|
37
|
+
}), { label: node.label });
|
|
38
|
+
|
|
39
|
+
if (rw > 28 && rh > 14) {
|
|
40
|
+
const fitted = TableChart.fit(String(node.label), rw - 6, measure);
|
|
41
|
+
this.renderer.text(fitted, rx + rw / 2, ry + rh / 2, {
|
|
42
|
+
textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: textColor, 'pointer-events': 'none',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|