@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.
Files changed (44) hide show
  1. package/README.md +454 -302
  2. package/dist/renderengine-workspace.umd.js +2203 -113
  3. package/dist/renderengine.umd.js +2203 -113
  4. package/documentation/REFERENCE.md +30 -1
  5. package/package.json +8 -3
  6. package/src/chartCatalog.js +50 -3
  7. package/src/charting/Chart3D.js +4 -2
  8. package/src/charting/Hierarchy.js +28 -0
  9. package/src/charting/ask/AskBar.js +388 -0
  10. package/src/charting/ask/AskView.js +400 -0
  11. package/src/charting/charts/AreaChart.js +20 -3
  12. package/src/charting/charts/BarChart3D.js +2 -2
  13. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  14. package/src/charting/charts/BoxPlotChart.js +2 -2
  15. package/src/charting/charts/BubbleChart.js +4 -1
  16. package/src/charting/charts/ChordDiagram.js +4 -4
  17. package/src/charting/charts/DensityChart.js +1 -1
  18. package/src/charting/charts/FunnelChart.js +1 -1
  19. package/src/charting/charts/HeatmapChart.js +4 -1
  20. package/src/charting/charts/HistogramChart.js +2 -2
  21. package/src/charting/charts/IcicleChart.js +47 -0
  22. package/src/charting/charts/LollipopChart.js +104 -0
  23. package/src/charting/charts/MarimekkoChart.js +75 -0
  24. package/src/charting/charts/OutlineChart.js +10 -8
  25. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  26. package/src/charting/charts/ParetoChart.js +92 -0
  27. package/src/charting/charts/PipeChart.js +5 -2
  28. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  29. package/src/charting/charts/RadarChart.js +5 -4
  30. package/src/charting/charts/RadialBarChart.js +2 -2
  31. package/src/charting/charts/SankeyDiagram.js +4 -4
  32. package/src/charting/charts/ScatterChart.js +6 -3
  33. package/src/charting/charts/StackedBarChart.js +82 -11
  34. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
  35. package/src/charting/charts/StreamGraphChart.js +8 -0
  36. package/src/charting/charts/SunburstChart.js +2 -2
  37. package/src/charting/charts/TreemapChart.js +4 -4
  38. package/src/charting/charts/ViolinChart.js +2 -2
  39. package/src/charting/charts/WaterfallChart.js +2 -2
  40. package/src/charting/charts/WordCloudChart.js +2 -2
  41. package/src/charting/pivot/PivotChart.js +205 -13
  42. package/src/charting/pivot/PivotGridChart.js +1 -1
  43. package/src/charting/pivot/PivotView.js +544 -21
  44. package/src/index.js +98 -2
@@ -74,7 +74,7 @@ catalog `key` for you.
74
74
  | `sankey` | `sankeyDiagram(data, opts)` | `{nodes?: Array<{id,label?,color?}>, links: Array<{source,target,value}>}` | proportional-width flow links; `nodeWidth`, `linkOpacity` |
75
75
  | `chord` | `chordDiagram(data, opts)` | same as `sankey` | nodes on a circle with curved chords of proportional thickness |
76
76
  | `candlestick` | `candlestick(data, opts)` | `Array<{label, open, high, low, close}>` | `upColor`, `downColor`, `markers` (tooltip annotations) |
77
- | `stackedBar` | `stackedBarChart(data, opts)` | `Array<{label, values:number[]}>` | `colors` (no built-in legend — a known gap) |
77
+ | `stackedBar` | `stackedBarChart(data, opts)` | multi-series `Array<{name, values:Array<{label,value}>}>` or `Array<{label, values:number[]}>` | `colors`, `legend`, `seriesLabels`; draws Y-axis ticks and legend |
78
78
  | `pipe` | `pipeChart(data, opts)` | `Array<{label, value, metric?}>` or multi-series | `metric` tapers line width; `legend` |
79
79
  | `radar` | `radarChart(data, opts)` | `Array<{axis, value}>` or multi-series | `colors`, `legend` |
80
80
  | `funnel` | `funnelChart(data, opts)` | `Array<{label, value}>` (largest first) | `colors` |
@@ -3282,6 +3282,35 @@ const mine = ICONS.with({ // a host's own icons over
3282
3282
  the workspace bundle), until a `foreignObject`, whose children are HTML again. A spec's `ns` sets the namespace
3283
3283
  explicitly.
3284
3284
 
3285
+ ## Ask Bar & Next-Word Typeahead (`AskBar` / `AskView`)
3286
+
3287
+ `AskBar` (`src/charting/ask/AskBar.js`) and its DOM-free state model `AskView` (`src/charting/ask/AskView.js`) provide the interactive question bar and scrollable next-word prediction dropdown (as used in `semanticengine/examples/browser/index.html` and the **Cubing** tab in `test/charts.html`).
3288
+
3289
+ ```javascript
3290
+ import { RenderEngine, AskBar, AskView } from '@peter.prib/renderengine';
3291
+
3292
+ const bar = RenderEngine.askBar(document.getElementById('ask-bar'), {
3293
+ value: 'top 3 customers by revenue',
3294
+ // Either pass a custom suggestion callback (e.g. semanticengine's engine.suggest):
3295
+ suggest: (text) => semanticEngine.suggest(text, { sampler: (dim) => semanticEngine.members(dim) }),
3296
+ // Or pass declarative cube/table metadata for built-in next-word prediction:
3297
+ // measures: ['Revenue', 'Cost'],
3298
+ // dimensions: ['Region', 'Quarter'],
3299
+ // members: (dim) => [...],
3300
+ sampleQuestions: ['revenue by region', 'top 3 customers by revenue'],
3301
+ visibleCount: 5, // items visible at once in the scrollable list
3302
+ predictChoices: [0, 3, 5, 8, 10], // 0 = Off; user can switch in the bar's selector
3303
+ onAsk: (question) => runQuery(question),
3304
+ });
3305
+ ```
3306
+
3307
+ - **Configurable scrollable next-word prediction:** `visibleCount` (default `5`) sets how many predicted items show at once before scrolling (`max-height` on `.re-ask-suggest-box` with `overflow-y: auto`), and `predictChoices` (default `[0, 3, 5, 8, 10]`) populates the **Predict next word** selector (`Off`, `3 visible (scrollable)`, `5 visible (scrollable)`, …). Set `showPredictControl: false` to hide the selector.
3308
+ - **Semantic badges:** each suggestion item (`{ display, completion, sense?, reference?, relation?, kind? }`) renders its `display` token, optional `sense → reference` subtitle, lexical `relation` badge (`synonym`, `hypernym`, `hyponym`, `antonym`), and `kind` badge (`metric`, `dimension`, `keyword`, `value`).
3309
+ - **Keyboard navigation:** nothing in the list is selected until you move into it, so `Enter` asks the question as typed. `↑` / `↓` move through the scrollable list, starting at the last or first item and scrolling the active item into view. `Enter` completes the selected item; `Tab` completes the selected item, or the first. Either one immediately predicts the following word. `Escape` closes the popup.
3310
+ - **Async sources and answers:** `suggest` can return a promise, for example from an engine that is still loading or a vocabulary on a server. The list keeps showing what it had until the promise settles. An answer for text the user has since changed is dropped, and a rejected promise shows no suggestions. `onAsk` can also return a promise of `{ rows, columns, complete }`, which `resultEngine` shows once it settles.
3311
+ - **For a host:** `bar.setSuggest(fn)` swaps where suggestions come from, for example when the user picks another model or ontology. `bar.setValue(text)` sets the question without suggesting. `bar.focus()` puts the cursor in the question, and `bar.destroy()` removes the bar and cancels any pending close.
3312
+ - **Accessible:** the input is a WAI-ARIA combobox over a `listbox` of `option`s. `aria-expanded` follows the popup and `aria-activedescendant` follows the active item. Each bar's list has its own id, so several bars can share a page.
3313
+
3285
3314
  ## Where to go next
3286
3315
 
3287
3316
  - **`ARCHITECTURE.md`** — module layout, the chart-type catalog mechanics, how to add a new chart type.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@peter.prib/renderengine",
3
- "version": "0.13.0",
4
- "description": "ArchiMate modeller, diagram editor and chart library for the browser, with no runtime dependencies.",
3
+ "version": "0.14.0",
4
+ "description": "ArchiMate modeller, data modeller, diagram editor, OLAP cube & pivot chart studio, and 50+ type chart library for the browser, with no runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "module": "src/index.js",
@@ -28,7 +28,7 @@
28
28
  "archimate": "node scripts/open.mjs examples/archimate.html",
29
29
  "diagram": "node scripts/open.mjs examples/diagram-editor.html",
30
30
  "datamodel": "node scripts/open.mjs examples/data-model.html",
31
- "test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/table/*.js ./test/charting/pivot/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
31
+ "test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/table/*.js ./test/charting/pivot/*.js ./test/charting/ask/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
32
32
  "build": "rollup -c",
33
33
  "prepublishOnly": "npm test && npm run build",
34
34
  "screenshots": "node scripts/screenshots/screenshots.mjs"
@@ -38,6 +38,11 @@
38
38
  "archi",
39
39
  "diagram",
40
40
  "diagram-editor",
41
+ "data-modelling",
42
+ "bian",
43
+ "olap",
44
+ "pivot",
45
+ "cube",
41
46
  "modelling",
42
47
  "charts",
43
48
  "svg",
@@ -318,12 +318,15 @@ export const CHART_CATALOG = [
318
318
  },
319
319
  {
320
320
  key: 'stackedBar', label: 'Stacked Bar', method: 'stackedBarChart', category: '2d',
321
- coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
321
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
322
+ seriesCapable: true,
322
323
  axes: [
323
324
  { role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_X, required: true },
324
- { role: 'values', kind: AXIS_KIND.NUMERIC_LIST, channel: ENCODING_CHANNEL.POSITION_Y_STACKED, required: true },
325
+ { role: 'values', kind: AXIS_KIND.NUMERIC_LIST, channel: ENCODING_CHANNEL.POSITION_Y_STACKED, required: false },
326
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y_STACKED, required: false },
327
+ { role: 'series', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.COLOR, required: false },
325
328
  ],
326
- dataShape: "Array<{label, values:number[]}> e.g. [{label:'Jan', values:[10,20]}] — no built-in legend option despite being multi-series (a real gap; areaChart's stacked mode has one, this doesn't)",
329
+ dataShape: "Array<{label, values:number[]}> or multi-series Array<{name, values:Array<{label,value}>}> — segments stacked from zero, with optional series legend",
327
330
  streamShape: 'flat', // a new stacked bar is a whole new {label,values} record appended to the outer array, not growth of an existing record's values
328
331
  },
329
332
  {
@@ -564,6 +567,50 @@ export const CHART_CATALOG = [
564
567
  dataShape: 'Array<{label, x, y, z, size?, category?}> — real orbit camera (drag-rotate/wheel-zoom); a future sphere/ellipsoid variant is this same shape with a second shape-size-channeled role, not a new chart type',
565
568
  streamShape: 'flat',
566
569
  },
570
+ {
571
+ key: 'pareto', label: 'Pareto', method: 'paretoChart', category: '2d',
572
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SEMANTIC, camera: CAMERA_MODE.NONE,
573
+ axes: [
574
+ { role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_X, required: true },
575
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y, required: true },
576
+ ],
577
+ dataShape: 'Array<{label, value}> — sorted descending with a right-hand 0-100% cumulative percentage line and 80% cutoff reference line',
578
+ streamShape: 'flat',
579
+ },
580
+ {
581
+ key: 'lollipop', label: 'Lollipop', method: 'lollipopChart', category: '2d',
582
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
583
+ seriesCapable: true,
584
+ axes: [
585
+ { role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_X, required: true },
586
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y, required: true },
587
+ { role: 'series', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.COLOR, required: false },
588
+ ],
589
+ dataShape: 'Array<{label, value}> or multi-series Array<{name, values:Array<{label,value}>}> — stem-and-dot alternative to bars',
590
+ streamShape: 'flat',
591
+ },
592
+ {
593
+ key: 'marimekko', label: 'Marimekko', method: 'marimekkoChart', category: '2d',
594
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
595
+ seriesCapable: true,
596
+ axes: [
597
+ { role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.SHAPE_WIDTH, required: true },
598
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y_HEIGHT, required: true },
599
+ { role: 'series', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.COLOR, required: true },
600
+ ],
601
+ dataShape: 'Multi-series Array<{name, values:Array<{label,value}>}> or Array<{label, values:number[]}> — variable-width 100%-stacked mosaic columns',
602
+ streamShape: 'series',
603
+ },
604
+ {
605
+ key: 'icicle', label: 'Icicle', method: 'icicleChart', category: '2d',
606
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
607
+ axes: [
608
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.ANGLE_OR_AREA, required: true },
609
+ { role: 'group', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_ORDER, required: false },
610
+ ],
611
+ dataShape: 'Array<{label, value?, children?}> — rectangular hierarchical partition chart (top-down bands by depth, width proportional to value)',
612
+ streamShape: 'flat',
613
+ },
567
614
  {
568
615
  key: 'bar3DOrbit', label: 'Bar (3D Orbit)', method: 'barChart3DOrbit', category: '3d',
569
616
  coordSystem: COORD_SYSTEM.ORBIT_3D, title: true, legend: false, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.ORBIT,
@@ -72,8 +72,9 @@ export class Chart3D extends Chart {
72
72
 
73
73
  /**
74
74
  * Draw one 3D box through the orbit camera. Returns average corner depth.
75
+ * When `datum` is supplied (`{ label, series? }`), each face is recorded via `_datum()`.
75
76
  */
76
- drawBox(renderer, x0, x1, y0, y1, z0, z1, color, toScreen) {
77
+ drawBox(renderer, x0, x1, y0, y1, z0, z1, color, toScreen, datum = null) {
77
78
  const camera = this.camera;
78
79
  const faceXSign = Math.sin(camera.theta) >= 0 ? 1 : -1;
79
80
  const faceZSign = Math.cos(camera.theta) >= 0 ? 1 : -1;
@@ -87,7 +88,8 @@ export class Chart3D extends Chart {
87
88
 
88
89
  const drawFace = (pts, shade) => {
89
90
  const d = `M${pts[0].x},${pts[0].y} L${pts[1].x},${pts[1].y} L${pts[2].x},${pts[2].y} L${pts[3].x},${pts[3].y} Z`;
90
- renderer.path(d, ColorPalette.lighten(color, shade), { color: '#333', width: 1 });
91
+ const el = renderer.path(d, ColorPalette.lighten(color, shade), { color: '#333', width: 1 });
92
+ if (datum) this._datum(el, datum);
91
93
  };
92
94
  drawFace(top, 0.15);
93
95
  drawFace(xSide, faceXSign > 0 ? -0.05 : -0.2);
@@ -145,6 +145,34 @@ export class Hierarchy {
145
145
  return result;
146
146
  }
147
147
 
148
+ /**
149
+ * Recursive rectangular partition (icicle / flame graph) layout over a node tree.
150
+ * Partitions `width` proportionally to node value at each level and steps down by `bandHeight` per depth.
151
+ * @returns {Array<{node: object, x: number, y: number, width: number, height: number, depth: number, isLeaf: boolean, topAncestor: object}>}
152
+ */
153
+ static icicleLayout(nodes, x, y, width, bandHeight, depth = 0, topAncestors = null) {
154
+ const items = (nodes ?? [])
155
+ .map(node => ({ node, value: Hierarchy.value(node) }))
156
+ .filter(it => it.value > 0);
157
+ const total = items.reduce((s, it) => s + it.value, 0) || 1;
158
+
159
+ const result = [];
160
+ let cursorX = x;
161
+ for (const it of items) {
162
+ const { node } = it;
163
+ const spanW = (it.value / total) * width;
164
+ const isLeaf = !node.children?.length;
165
+ const topAncestor = topAncestors ? topAncestors.get(node) : node;
166
+ result.push({ node, x: cursorX, y, width: spanW, height: bandHeight, depth, isLeaf, topAncestor });
167
+ if (!isLeaf) {
168
+ const childTopAncestors = new Map(node.children.map(c => [c, topAncestor]));
169
+ result.push(...Hierarchy.icicleLayout(node.children, cursorX, y + bandHeight, spanW, bandHeight, depth + 1, childTopAncestors));
170
+ }
171
+ cursorX += spanW;
172
+ }
173
+ return result;
174
+ }
175
+
148
176
  /**
149
177
  * Flat, pre-order list of *visible* rows for an outline view. A branch
150
178
  * present in `collapsed` contributes its own row but none of its
@@ -0,0 +1,388 @@
1
+ import { AskView } from './AskView.js';
2
+
3
+ /**
4
+ * AskBar — a self-contained browser UI component for asking questions with preemptive
5
+ * next-word prediction in a scrollable list (generalizing the Ask bar from
6
+ * `semanticengine/examples/browser/index.html` into a reusable RenderEngine capability).
7
+ *
8
+ * Follows the same OO encapsulation pattern as `ArchimateDiagramEditor`:
9
+ * - Owns DOM creation, CSS injection, event wiring, and keyboard/scroll management.
10
+ * - Separates pure state and prediction logic into `AskView` (`this.view`) so all logic is
11
+ * DOM-free testable under Node.js.
12
+ * - Includes a configurable **Predict next word** selector allowing the user (or host) to
13
+ * choose how many predicted items are visible at once in the scrollable list (e.g. `Off`,
14
+ * `3`, `5`, `8`, `10`), while scrolling (`overflow-y: auto`) or pressing `↑`/`↓` browses
15
+ * through the full list of predictions.
16
+ */
17
+ export class AskBar {
18
+ /** Approximate height in pixels of one suggestion row in the scrollable list. */
19
+ static ITEM_HEIGHT = 44;
20
+
21
+ /** Numbers each bar's list, so several bars on one page each name their own (ARIA ids). */
22
+ static _count = 0;
23
+
24
+ /**
25
+ * @param {HTMLElement|string} container host element or its DOM id
26
+ * @param {object} [options] all `AskView` options plus UI options:
27
+ * @param {Document} [options.document] DOM document (defaults to `globalThis.document`)
28
+ * @param {string} [options.placeholder='Ask a question…'] input placeholder
29
+ * @param {string} [options.buttonLabel='Run'] submit button label
30
+ * @param {string} [options.ariaLabel='Question'] input aria-label
31
+ * @param {boolean} [options.showPredictControl=true] whether to show the configurable "Predict next word" selector
32
+ * @param {string} [options.predictLabel='Predict next word:'] label for the predict selector
33
+ * @param {boolean} [options.autoRun=false] whether to run `onAsk` immediately upon mounting
34
+ * @param {object} [options.resultEngine] optional `RenderEngine` instance whose `resultView()` displays `{ rows, columns, complete }` returned by `onAsk`
35
+ */
36
+ constructor(container, options = {}) {
37
+ this.options = options;
38
+ this.doc = options.document ?? container?.ownerDocument ?? globalThis.document ?? null;
39
+ this.container = typeof container === 'string'
40
+ ? this.doc?.getElementById?.(container) ?? null
41
+ : container ?? null;
42
+ this.resultEngine = options.resultEngine ?? null;
43
+
44
+ const userOnAsk = options.onAsk;
45
+ this.listId = `re-ask-list-${++AskBar._count}`;
46
+ this._blurTimer = null;
47
+ // An answer (`{ rows, columns, complete }`), or a promise of one, is shown by resultEngine.
48
+ const show = (res) => {
49
+ if (res && typeof res === 'object' && Array.isArray(res.rows) && this.resultEngine?.resultView) {
50
+ this.resultEngine.resultView(res.rows, {
51
+ columns: res.columns,
52
+ complete: res.complete,
53
+ pageSize: options.pageSize ?? 10,
54
+ });
55
+ }
56
+ return res;
57
+ };
58
+ this.view = options.view instanceof AskView ? options.view : new AskView({
59
+ ...options,
60
+ onAsk: (question, view) => {
61
+ const res = userOnAsk?.(question, view, this);
62
+ return res && typeof res.then === 'function' ? Promise.resolve(res).then(show) : show(res);
63
+ },
64
+ onChange: (view) => {
65
+ this._syncFromView();
66
+ options.onChange?.(view, this);
67
+ },
68
+ });
69
+
70
+ this.root = null;
71
+ this.inputEl = null;
72
+ this.suggestEl = null;
73
+ this.predictSelectEl = null;
74
+ this.samplesEl = null;
75
+
76
+ if (this.container && this.doc) {
77
+ this.render();
78
+ if (options.autoRun && this.view.value) {
79
+ this.view.submit(this.view.value);
80
+ }
81
+ }
82
+ }
83
+
84
+ /** Injects the component's scoped stylesheet once per document, build-free and theme-aware. */
85
+ static _injectStyles(doc) {
86
+ if (!doc?.head || doc.getElementById?.('re-ask-bar-styles')) return;
87
+ const style = doc.createElement('style');
88
+ style.id = 're-ask-bar-styles';
89
+ style.textContent = `
90
+ .re-ask-bar { display: flex; flex-direction: column; gap: 6px; font: 14px/1.45 system-ui, sans-serif; color: var(--ws-fg, var(--fg, #1d1d1f)); }
91
+ .re-ask-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 0; }
92
+ .re-ask-input-wrapper { position: relative; flex: 1 1 260px; min-width: 180px; }
93
+ .re-ask-input { width: 100%; box-sizing: border-box; padding: 7px 10px; font: inherit; color: inherit; background: var(--ws-panel-bg, var(--bg, #fff)); border: 1px solid var(--ws-border, var(--line, #ccc)); border-radius: 6px; }
94
+ .re-ask-input:focus { outline: 2px solid var(--ws-accent, var(--accent, #2b5fd9)); outline-offset: -1px; }
95
+ .re-ask-submit { padding: 7px 14px; font: inherit; font-weight: 500; color: #fff; background: var(--ws-accent, var(--accent, #2b5fd9)); border: 0; border-radius: 6px; cursor: pointer; }
96
+ .re-ask-submit:hover { filter: brightness(1.06); }
97
+ .re-ask-predict-ctrl { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ws-muted, var(--muted, #666)); }
98
+ .re-ask-predict-select { padding: 5px 7px; font: inherit; color: var(--ws-fg, var(--fg, #1d1d1f)); background: var(--ws-panel-bg, var(--bg, #fff)); border: 1px solid var(--ws-border, var(--line, #ccc)); border-radius: 5px; }
99
+ .re-ask-suggest-box {
100
+ position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 250;
101
+ background: var(--ws-panel-bg, var(--bg, #fff));
102
+ border: 1px solid var(--ws-border, var(--line, #ccc));
103
+ border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.18);
104
+ overflow-y: auto; display: flex; flex-direction: column;
105
+ }
106
+ .re-ask-suggest-box[hidden] { display: none !important; }
107
+ .re-ask-suggest-item {
108
+ display: flex; align-items: center; justify-content: space-between;
109
+ padding: 7px 10px; cursor: pointer; font-size: 13px; gap: 8px; user-select: none;
110
+ box-sizing: border-box; min-height: 36px;
111
+ }
112
+ .re-ask-suggest-item:hover, .re-ask-suggest-item.active {
113
+ background: var(--ws-accent, var(--accent, #2b5fd9)); color: #fff;
114
+ }
115
+ .re-ask-suggest-main { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
116
+ .re-ask-suggest-text { font-family: ui-monospace, monospace; font-size: 13px; }
117
+ .re-ask-suggest-sense { font-size: 11px; color: var(--ws-muted, var(--muted, #6b6b70)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
118
+ .re-ask-suggest-item:hover .re-ask-suggest-sense,
119
+ .re-ask-suggest-item.active .re-ask-suggest-sense { color: rgba(255, 255, 255, 0.85); }
120
+ .re-ask-suggest-badges { display: flex; gap: 4px; align-items: center; flex-shrink: 0; }
121
+ .re-ask-suggest-badge {
122
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
123
+ padding: 2px 6px; border-radius: 4px;
124
+ background: var(--ws-border, var(--line, #e0e0e4));
125
+ color: var(--ws-muted, var(--muted, #555)); flex-shrink: 0;
126
+ }
127
+ .re-ask-suggest-item:hover .re-ask-suggest-badge,
128
+ .re-ask-suggest-item.active .re-ask-suggest-badge {
129
+ background: rgba(255, 255, 255, 0.25); color: #fff;
130
+ }
131
+ .re-ask-samples { font-size: 12px; color: var(--ws-muted, var(--muted, #666)); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
132
+ .re-ask-sample-pill {
133
+ font-family: ui-monospace, monospace; font-size: 11.5px;
134
+ padding: 2px 6px; border-radius: 4px; cursor: pointer;
135
+ background: rgba(127,127,127,0.12); border: 1px solid var(--ws-border, var(--line, #ddd));
136
+ color: var(--ws-fg, var(--fg, #222));
137
+ }
138
+ .re-ask-sample-pill:hover { border-color: var(--ws-accent, var(--accent, #2b5fd9)); }
139
+ `;
140
+ doc.head.appendChild(style);
141
+ }
142
+
143
+ /** Builds the AskBar DOM inside `this.container`. */
144
+ render() {
145
+ if (!this.container || !this.doc) return this;
146
+ AskBar._injectStyles(this.doc);
147
+
148
+ const root = this.doc.createElement('div');
149
+ root.className = 're-ask-bar';
150
+
151
+ const form = this.doc.createElement('form');
152
+ form.className = 're-ask-form';
153
+
154
+ const wrapper = this.doc.createElement('div');
155
+ wrapper.className = 're-ask-input-wrapper';
156
+
157
+ const input = this.doc.createElement('input');
158
+ input.className = 're-ask-input';
159
+ input.type = 'text';
160
+ input.value = this.view.value;
161
+ input.placeholder = this.options.placeholder ?? 'Ask a question…';
162
+ input.setAttribute('aria-label', this.options.ariaLabel ?? 'Question');
163
+ input.setAttribute('autocomplete', 'off');
164
+ input.setAttribute('spellcheck', 'false');
165
+ // A combobox over its list of completions (WAI-ARIA 1.2), so a screen reader follows ↑/↓.
166
+ input.setAttribute('role', 'combobox');
167
+ input.setAttribute('aria-autocomplete', 'list');
168
+ input.setAttribute('aria-controls', this.listId);
169
+ input.setAttribute('aria-expanded', 'false');
170
+
171
+ const suggestBox = this.doc.createElement('div');
172
+ suggestBox.className = 're-ask-suggest-box';
173
+ suggestBox.id = this.listId;
174
+ suggestBox.hidden = true;
175
+ suggestBox.setAttribute('role', 'listbox');
176
+ suggestBox.setAttribute('aria-label', 'Suggested completions');
177
+
178
+ wrapper.appendChild(input);
179
+ wrapper.appendChild(suggestBox);
180
+ form.appendChild(wrapper);
181
+
182
+ const submitBtn = this.doc.createElement('button');
183
+ submitBtn.type = 'submit';
184
+ submitBtn.className = 're-ask-submit';
185
+ submitBtn.textContent = this.options.buttonLabel ?? 'Run';
186
+ form.appendChild(submitBtn);
187
+
188
+ if (this.options.showPredictControl !== false) {
189
+ const ctrlLabel = this.doc.createElement('label');
190
+ ctrlLabel.className = 're-ask-predict-ctrl';
191
+ ctrlLabel.textContent = this.options.predictLabel ?? 'Predict next word:';
192
+
193
+ const select = this.doc.createElement('select');
194
+ select.className = 're-ask-predict-select';
195
+ select.setAttribute('aria-label', 'Predict next word in scrollable list');
196
+ for (const choice of this.view.predictChoices) {
197
+ const n = Number(choice);
198
+ const text = n <= 0 ? 'Off' : `${n} visible (scrollable)`;
199
+ const opt = this.doc.createElement('option');
200
+ opt.value = String(n);
201
+ opt.textContent = text;
202
+ if (n === this.view.predictCount) opt.selected = true;
203
+ select.appendChild(opt);
204
+ }
205
+ select.addEventListener('change', () => {
206
+ this.view.setPredictCount(Number(select.value));
207
+ });
208
+ ctrlLabel.appendChild(select);
209
+ form.appendChild(ctrlLabel);
210
+ this.predictSelectEl = select;
211
+ }
212
+
213
+ root.appendChild(form);
214
+
215
+ if (this.view.samples.length > 0) {
216
+ const samplesRow = this.doc.createElement('div');
217
+ samplesRow.className = 're-ask-samples';
218
+ const prefix = this.doc.createElement('span');
219
+ prefix.textContent = 'Try:';
220
+ samplesRow.appendChild(prefix);
221
+ for (const q of this.view.samples) {
222
+ const pill = this.doc.createElement('code');
223
+ pill.className = 're-ask-sample-pill';
224
+ pill.textContent = q;
225
+ pill.addEventListener('click', () => {
226
+ this.view.submit(q);
227
+ });
228
+ samplesRow.appendChild(pill);
229
+ }
230
+ root.appendChild(samplesRow);
231
+ this.samplesEl = samplesRow;
232
+ }
233
+
234
+ this.root = root;
235
+ this.inputEl = input;
236
+ this.suggestEl = suggestBox;
237
+ this._wireEvents(form, input);
238
+ this.container.replaceChildren ? this.container.replaceChildren(root) : (this.container.innerHTML = '', this.container.appendChild(root));
239
+ this._syncFromView();
240
+ return this;
241
+ }
242
+
243
+ _wireEvents(form, input) {
244
+ input.addEventListener('input', () => {
245
+ this.view.setInput(input.value, { suggest: true });
246
+ });
247
+ input.addEventListener('focus', () => {
248
+ if (this.view.predict) this.view.refresh();
249
+ });
250
+ input.addEventListener('blur', () => {
251
+ this._blurTimer = setTimeout(() => { this._blurTimer = null; this.view.close(); }, 150);
252
+ });
253
+ input.addEventListener('keydown', (e) => {
254
+ if (!this.view.open || this.view.items.length === 0) {
255
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
256
+ this.view.refresh();
257
+ }
258
+ return;
259
+ }
260
+ if (e.key === 'ArrowDown') {
261
+ e.preventDefault();
262
+ this.view.move(1);
263
+ } else if (e.key === 'ArrowUp') {
264
+ e.preventDefault();
265
+ this.view.move(-1);
266
+ } else if (e.key === 'Tab') {
267
+ e.preventDefault();
268
+ this.view.accept(this.view.selectedIndex >= 0 ? this.view.selectedIndex : 0);
269
+ this.inputEl?.focus?.();
270
+ } else if (e.key === 'Enter' && this.view.selectedIndex >= 0) {
271
+ e.preventDefault();
272
+ this.view.accept(this.view.selectedIndex);
273
+ this.inputEl?.focus?.();
274
+ } else if (e.key === 'Escape') {
275
+ this.view.close();
276
+ }
277
+ });
278
+ form.addEventListener('submit', (e) => {
279
+ e.preventDefault();
280
+ this.view.submit(input.value);
281
+ });
282
+ }
283
+
284
+ /** Swaps where suggestions come from (`AskView.setSuggest`): another model or ontology. */
285
+ setSuggest(suggest) {
286
+ this.view.setSuggest(suggest);
287
+ return this;
288
+ }
289
+
290
+ /** Sets the question shown, without suggesting. */
291
+ setValue(text) {
292
+ this.view.setInput(text, { suggest: false });
293
+ return this;
294
+ }
295
+
296
+ /** Puts the cursor in the question. */
297
+ focus() {
298
+ this.inputEl?.focus?.();
299
+ return this;
300
+ }
301
+
302
+ /** Takes the bar out of its container; a pending close is cancelled. */
303
+ destroy() {
304
+ if (this._blurTimer !== null) clearTimeout(this._blurTimer);
305
+ this._blurTimer = null;
306
+ this.root?.remove?.();
307
+ this.root = this.inputEl = this.suggestEl = this.predictSelectEl = this.samplesEl = null;
308
+ }
309
+
310
+ _syncFromView() {
311
+ if (!this.inputEl || !this.suggestEl) return;
312
+ if (this.inputEl.value !== this.view.value) {
313
+ this.inputEl.value = this.view.value;
314
+ }
315
+ if (this.predictSelectEl) {
316
+ const val = String(this.view.predictCount);
317
+ if (this.predictSelectEl.value !== val) this.predictSelectEl.value = val;
318
+ }
319
+
320
+ const active = this.view.selectedIndex;
321
+ if (active >= 0 && this.view.open) this.inputEl.setAttribute('aria-activedescendant', `${this.listId}-${active}`);
322
+ else this.inputEl.removeAttribute?.('aria-activedescendant');
323
+
324
+ if (!this.view.open || !this.view.predict || this.view.items.length === 0) {
325
+ this.inputEl.setAttribute('aria-expanded', 'false');
326
+ this.suggestEl.hidden = true;
327
+ if (this.suggestEl.replaceChildren) this.suggestEl.replaceChildren();
328
+ return;
329
+ }
330
+
331
+ this.suggestEl.style.maxHeight = `${Math.max(1, this.view.visibleCount) * AskBar.ITEM_HEIGHT}px`;
332
+ if (this.suggestEl.replaceChildren) this.suggestEl.replaceChildren();
333
+
334
+ this.view.items.forEach((item, idx) => {
335
+ const row = this.doc.createElement('div');
336
+ row.className = `re-ask-suggest-item${idx === this.view.selectedIndex ? ' active' : ''}`;
337
+ row.dataset.index = String(idx);
338
+ row.id = `${this.listId}-${idx}`;
339
+ row.setAttribute('role', 'option');
340
+ row.setAttribute('aria-selected', String(idx === this.view.selectedIndex));
341
+
342
+ const mainCol = this.doc.createElement('div');
343
+ mainCol.className = 're-ask-suggest-main';
344
+
345
+ const textSpan = this.doc.createElement('span');
346
+ textSpan.className = 're-ask-suggest-text';
347
+ textSpan.textContent = item.display;
348
+ mainCol.appendChild(textSpan);
349
+
350
+ if (item.sense) {
351
+ const senseSpan = this.doc.createElement('span');
352
+ senseSpan.className = 're-ask-suggest-sense';
353
+ senseSpan.textContent = `${item.sense}${item.reference ? ` → ${item.reference}` : ''}`;
354
+ mainCol.appendChild(senseSpan);
355
+ }
356
+
357
+ const badges = this.doc.createElement('div');
358
+ badges.className = 're-ask-suggest-badges';
359
+
360
+ if (item.relation && item.relation !== 'canonical' && item.relation !== 'function') {
361
+ const relBadge = this.doc.createElement('span');
362
+ relBadge.className = 're-ask-suggest-badge';
363
+ relBadge.textContent = item.relation;
364
+ badges.appendChild(relBadge);
365
+ }
366
+ if (item.kind) {
367
+ const kindBadge = this.doc.createElement('span');
368
+ kindBadge.className = 're-ask-suggest-badge';
369
+ kindBadge.textContent = item.kind;
370
+ badges.appendChild(kindBadge);
371
+ }
372
+
373
+ row.appendChild(mainCol);
374
+ row.appendChild(badges);
375
+ row.addEventListener('mousedown', (e) => {
376
+ e.preventDefault();
377
+ this.view.accept(idx);
378
+ this.inputEl?.focus?.();
379
+ });
380
+ this.suggestEl.appendChild(row);
381
+ if (idx === this.view.selectedIndex && typeof row.scrollIntoView === 'function') {
382
+ row.scrollIntoView({ block: 'nearest' });
383
+ }
384
+ });
385
+ this.suggestEl.hidden = false;
386
+ this.inputEl.setAttribute('aria-expanded', 'true');
387
+ }
388
+ }