@peter.prib/renderengine 0.13.0 → 0.15.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.
Files changed (124) hide show
  1. package/README.md +480 -302
  2. package/bin/renderengine.mjs +157 -0
  3. package/dist/renderengine-workspace.umd.js +49217 -37484
  4. package/dist/renderengine.umd.js +24899 -13093
  5. package/documentation/REFERENCE.md +68 -1
  6. package/package.json +13 -3
  7. package/src/CanvasRenderer.js +225 -200
  8. package/src/SVGRenderer.js +476 -454
  9. package/src/archimate.js +22 -2
  10. package/src/archimateArchiFormat.js +1735 -1701
  11. package/src/archimateArchive.js +36 -7
  12. package/src/archimateCSV.js +93 -37
  13. package/src/archimateDrawio.js +2 -0
  14. package/src/archimateExchangeFormat.js +3 -1
  15. package/src/chartCatalog.js +269 -3
  16. package/src/charting/Annotations.js +169 -0
  17. package/src/charting/ArchimateDiagramEditor.js +54 -10
  18. package/src/charting/Axis.js +217 -5
  19. package/src/charting/Chart.js +99 -9
  20. package/src/charting/Chart3D.js +7 -2
  21. package/src/charting/ChartBox.js +8 -1
  22. package/src/charting/ColorPalette.js +42 -4
  23. package/src/charting/DiagramEditor.js +174 -39
  24. package/src/charting/DiagramEditorCommands.js +28 -0
  25. package/src/charting/Edge.js +7 -0
  26. package/src/charting/Hierarchy.js +142 -0
  27. package/src/charting/InteractionManager.js +4 -0
  28. package/src/charting/ModelFileFormat.js +5 -1
  29. package/src/charting/SettingsManager.js +35 -0
  30. package/src/charting/ShapePaths.js +82 -10
  31. package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
  32. package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
  33. package/src/charting/ask/AskBar.js +388 -0
  34. package/src/charting/ask/AskView.js +400 -0
  35. package/src/charting/charts/AlluvialDiagram.js +311 -0
  36. package/src/charting/charts/AreaChart.js +69 -15
  37. package/src/charting/charts/BarChart.js +38 -3
  38. package/src/charting/charts/BarChart3D.js +6 -4
  39. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  40. package/src/charting/charts/BarChartBase.js +26 -6
  41. package/src/charting/charts/BeeswarmChart.js +164 -0
  42. package/src/charting/charts/BoxPlotChart.js +25 -3
  43. package/src/charting/charts/BubbleChart.js +4 -1
  44. package/src/charting/charts/CalendarHeatmapChart.js +2 -1
  45. package/src/charting/charts/CandlestickChart.js +172 -4
  46. package/src/charting/charts/ChordDiagram.js +107 -4
  47. package/src/charting/charts/CirclePackChart.js +81 -0
  48. package/src/charting/charts/ComboChart.js +233 -0
  49. package/src/charting/charts/ContourChart.js +310 -0
  50. package/src/charting/charts/DensityChart.js +1 -1
  51. package/src/charting/charts/DonutChart3D.js +13 -0
  52. package/src/charting/charts/DumbbellChart.js +217 -0
  53. package/src/charting/charts/EcdfChart.js +150 -0
  54. package/src/charting/charts/FunnelChart.js +5 -2
  55. package/src/charting/charts/GeoMapChart.js +132 -11
  56. package/src/charting/charts/GroupedBarChart.js +44 -3
  57. package/src/charting/charts/GroupedDistributionChart.js +26 -1
  58. package/src/charting/charts/HeatmapChart.js +6 -3
  59. package/src/charting/charts/HexbinChart.js +137 -0
  60. package/src/charting/charts/HierarchyChart.js +77 -3
  61. package/src/charting/charts/HistogramChart.js +2 -2
  62. package/src/charting/charts/HorizonChart.js +105 -0
  63. package/src/charting/charts/IcicleChart.js +47 -0
  64. package/src/charting/charts/KagiChart.js +167 -0
  65. package/src/charting/charts/LineChart.js +74 -18
  66. package/src/charting/charts/LollipopChart.js +104 -0
  67. package/src/charting/charts/MarimekkoChart.js +75 -0
  68. package/src/charting/charts/OhlcChart.js +79 -0
  69. package/src/charting/charts/OutlineChart.js +10 -8
  70. package/src/charting/charts/ParallelCoordinatesChart.js +11 -3
  71. package/src/charting/charts/ParetoChart.js +92 -0
  72. package/src/charting/charts/PieChart.js +195 -17
  73. package/src/charting/charts/PieChart3D.js +137 -0
  74. package/src/charting/charts/PipeChart.js +5 -2
  75. package/src/charting/charts/PointAndFigureChart.js +198 -0
  76. package/src/charting/charts/PolarAreaChart.js +189 -0
  77. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  78. package/src/charting/charts/RadarChart.js +12 -5
  79. package/src/charting/charts/RadialBarChart.js +6 -3
  80. package/src/charting/charts/RenkoChart.js +178 -0
  81. package/src/charting/charts/RidgelineChart.js +113 -0
  82. package/src/charting/charts/SankeyDiagram.js +129 -6
  83. package/src/charting/charts/ScatterChart.js +19 -3
  84. package/src/charting/charts/ScatterMatrixChart.js +119 -0
  85. package/src/charting/charts/SlopegraphChart.js +205 -0
  86. package/src/charting/charts/SparklineTableChart.js +166 -0
  87. package/src/charting/charts/StackedBarChart.js +125 -11
  88. package/src/charting/charts/StackedBarChart3DOrbit.js +14 -6
  89. package/src/charting/charts/StreamGraphChart.js +18 -3
  90. package/src/charting/charts/SunburstChart.js +28 -8
  91. package/src/charting/charts/SurfaceChart3D.js +157 -0
  92. package/src/charting/charts/TreemapChart.js +31 -11
  93. package/src/charting/charts/ViolinChart.js +20 -2
  94. package/src/charting/charts/WaterfallChart.js +2 -2
  95. package/src/charting/charts/WordCloudChart.js +2 -2
  96. package/src/charting/datamodel/ArchimateDataLink.js +70 -0
  97. package/src/charting/datamodel/DataLineage.js +150 -0
  98. package/src/charting/datamodel/DataModel.js +83 -1
  99. package/src/charting/datamodel/ModelMapping.js +87 -0
  100. package/src/charting/datamodel/SqlDdl.js +110 -85
  101. package/src/charting/datamodel/index.js +3 -0
  102. package/src/charting/decision/DecisionWorkbench.js +295 -0
  103. package/src/charting/diagrams/ArchimateDiagram.js +25 -9
  104. package/src/charting/diagrams/ArchimateModel.js +1382 -1238
  105. package/src/charting/diagrams/GraphDiagram.js +1424 -1350
  106. package/src/charting/diagrams/SketchView.js +121 -0
  107. package/src/charting/diagrams/sketchBoxKinds.js +30 -0
  108. package/src/charting/modelTree/AnalysisPanel.js +101 -23
  109. package/src/charting/modelTree/NavigatorModel.js +410 -0
  110. package/src/charting/modelTree/NavigatorPanel.js +453 -0
  111. package/src/charting/pivot/PivotChart.js +310 -13
  112. package/src/charting/pivot/PivotGridChart.js +322 -34
  113. package/src/charting/pivot/PivotView.js +626 -21
  114. package/src/charting/report/TemplatedReport.js +547 -0
  115. package/src/charting/templates/BoxTemplate.js +28 -27
  116. package/src/charting/templates/PackageTemplate.js +26 -25
  117. package/src/charting/templates/SwimlaneTemplate.js +19 -18
  118. package/src/charting/templates/Template.js +219 -212
  119. package/src/cli.js +146 -0
  120. package/src/index.js +3370 -2899
  121. package/src/workspace/Workspace.js +126 -7
  122. package/src/workspace/archimateServices.js +4 -0
  123. package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
  124. package/src/workspace/styles.js +36 -1
@@ -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
+ }