@peter.prib/renderengine 0.12.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 (67) hide show
  1. package/README.md +454 -296
  2. package/dist/renderengine-workspace.umd.js +4122 -1167
  3. package/dist/renderengine.umd.js +4103 -1164
  4. package/documentation/REFERENCE.md +96 -6
  5. package/package.json +8 -3
  6. package/src/chartCatalog.js +50 -3
  7. package/src/charting/Axis.js +47 -5
  8. package/src/charting/Chart3D.js +4 -2
  9. package/src/charting/DiagramEditor.js +239 -34
  10. package/src/charting/Hierarchy.js +28 -0
  11. package/src/charting/ModelFileFormat.js +47 -0
  12. package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
  13. package/src/charting/archimate/deleteFromModel.js +57 -0
  14. package/src/charting/archimate/elementMenuItems.js +3 -1
  15. package/src/charting/archimate/heatMaps.js +169 -0
  16. package/src/charting/archimate/index.js +1 -0
  17. package/src/charting/ask/AskBar.js +388 -0
  18. package/src/charting/ask/AskView.js +400 -0
  19. package/src/charting/charts/AreaChart.js +20 -3
  20. package/src/charting/charts/BarChart.js +5 -5
  21. package/src/charting/charts/BarChart3D.js +2 -2
  22. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  23. package/src/charting/charts/BarChartBase.js +21 -3
  24. package/src/charting/charts/BoxPlotChart.js +2 -2
  25. package/src/charting/charts/BubbleChart.js +4 -1
  26. package/src/charting/charts/ChordDiagram.js +4 -4
  27. package/src/charting/charts/DensityChart.js +1 -1
  28. package/src/charting/charts/FunnelChart.js +1 -1
  29. package/src/charting/charts/GroupedBarChart.js +7 -9
  30. package/src/charting/charts/HeatmapChart.js +4 -1
  31. package/src/charting/charts/HistogramChart.js +2 -2
  32. package/src/charting/charts/IcicleChart.js +47 -0
  33. package/src/charting/charts/LollipopChart.js +104 -0
  34. package/src/charting/charts/MarimekkoChart.js +75 -0
  35. package/src/charting/charts/OutlineChart.js +10 -8
  36. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  37. package/src/charting/charts/ParetoChart.js +92 -0
  38. package/src/charting/charts/PipeChart.js +5 -2
  39. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  40. package/src/charting/charts/RadarChart.js +5 -4
  41. package/src/charting/charts/RadialBarChart.js +2 -2
  42. package/src/charting/charts/SankeyDiagram.js +4 -4
  43. package/src/charting/charts/ScatterChart.js +6 -3
  44. package/src/charting/charts/StackedBarChart.js +82 -11
  45. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
  46. package/src/charting/charts/StreamGraphChart.js +8 -0
  47. package/src/charting/charts/SunburstChart.js +2 -2
  48. package/src/charting/charts/TableChart.js +18 -2
  49. package/src/charting/charts/TreemapChart.js +4 -4
  50. package/src/charting/charts/ViolinChart.js +2 -2
  51. package/src/charting/charts/WaterfallChart.js +2 -2
  52. package/src/charting/charts/WordCloudChart.js +2 -2
  53. package/src/charting/diagrams/ArchimateModel.js +33 -0
  54. package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
  55. package/src/charting/diagrams/EdgeGeometry.js +16 -0
  56. package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
  57. package/src/charting/pivot/PivotChart.js +205 -13
  58. package/src/charting/pivot/PivotGridChart.js +1 -1
  59. package/src/charting/pivot/PivotView.js +555 -23
  60. package/src/charting/table/TableView.js +10 -1
  61. package/src/charting/table/TableViewChart.js +1 -1
  62. package/src/charting/templates/Template.js +10 -0
  63. package/src/exportImage.js +45 -18
  64. package/src/index.js +115 -6
  65. package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
  66. package/src/workspace/parts/DiagramEditorPart.js +2 -0
  67. package/src/workspace/parts/ModelHosted.js +15 -4
@@ -0,0 +1,57 @@
1
+ import { FormDialog } from '../FormDialog.js';
2
+ import { typeTitle } from './archimateNames.js';
3
+
4
+ /** At most this many views or relationships are named; the rest are counted. */
5
+ const LISTED = 10;
6
+
7
+ /**
8
+ * What deleting elements from the model takes with it, in words, for the
9
+ * confirmation: the views they are on (and how many boxes), and the
10
+ * relationships that go too (ArchimateModel#usage).
11
+ * @param {ArchimateModel} model
12
+ * @param {Array<string>} elementIds
13
+ * @returns {string}
14
+ */
15
+ export function deleteFromModelMessage(model, elementIds) {
16
+ const elements = elementIds.map(id => model.getElement(id)).filter(Boolean);
17
+ const name = (el) => `"${el.name || el.id}"`;
18
+ const { views, relationships } = model.usage(elementIds);
19
+ const list = (items) => [...items.slice(0, LISTED).map(s => ` • ${s}`), ...(items.length > LISTED ? [` • and ${items.length - LISTED} more`] : [])];
20
+ const end = (id) => { const c = model.getElement(id) ?? model.getRelationship(id); return c ? `${c.name || typeTitle(c.type ?? c.kind ?? '') || c.id}` : id; };
21
+ const lines = [
22
+ elements.length === 1 ? `${name(elements[0])} (${typeTitle(elements[0].type ?? '')}) goes from the model.` : `These ${elements.length} elements go from the model: ${elements.map(name).join(', ')}.`,
23
+ '',
24
+ views.length
25
+ ? `Used in ${views.length} view${views.length === 1 ? '' : 's'}, where ${elements.length === 1 ? 'its' : 'their'} boxes go:`
26
+ : 'Used in no view.',
27
+ ...list(views.map(({ view, boxes }) => `${view.name || view.id}${boxes > 1 ? ` (${boxes} boxes)` : ''}`)),
28
+ '',
29
+ relationships.length
30
+ ? `${relationships.length} relationship${relationships.length === 1 ? '' : 's'} go${relationships.length === 1 ? 'es' : ''} too:`
31
+ : 'No relationships.',
32
+ ...list(relationships.map(r => `${end(r.source)} → ${end(r.target)} (${typeTitle(r.kind ?? r.type ?? '') || 'relationship'}${r.name ? `: ${r.name}` : ''})`)),
33
+ '',
34
+ 'Undo puts them all back.',
35
+ ];
36
+ return lines.join('\n');
37
+ }
38
+
39
+ /**
40
+ * Ask before deleting elements from the model: a dialog saying where they are
41
+ * used and what goes with them; **Delete from model** runs `onConfirm()`.
42
+ * @returns {FormDialog|null} null when none of the ids is an element
43
+ */
44
+ export function askDeleteFromModel(model, elementIds, { onConfirm, document: doc = globalThis.document } = {}) {
45
+ const ids = elementIds.filter(id => model.getElement(id));
46
+ if (!ids.length) return null;
47
+ const dialog = new FormDialog({
48
+ title: ids.length === 1 ? 'Delete from model' : `Delete ${ids.length} elements from model`,
49
+ description: deleteFromModelMessage(model, ids),
50
+ fields: [],
51
+ submitLabel: 'Delete from model',
52
+ document: doc,
53
+ onApply: () => onConfirm(ids),
54
+ });
55
+ dialog.open();
56
+ return dialog;
57
+ }
@@ -11,9 +11,10 @@
11
11
  * @param {(element) => void} host.editProperties
12
12
  * @param {(view, instanceId) => void} host.showIn - Open `view` with that box selected
13
13
  * @param {(element) => void} [host.addToView] - Show it on `view` (offered when it is not there)
14
+ * @param {(element) => void} [host.deleteFromModel] - Delete it from the model, after saying where it is used
14
15
  * @returns {Array<Object>} menu items
15
16
  */
16
- export function elementMenuItems(element, { model, view = null, editProperties, showIn, addToView = null }) {
17
+ export function elementMenuItems(element, { model, view = null, editProperties, showIn, addToView = null, deleteFromModel = null }) {
17
18
  const placed = model.views()
18
19
  .map(v => ({ view: v, instance: v.elementInstancesFor?.(element.id)?.[0] }))
19
20
  .filter(({ instance }) => instance);
@@ -28,5 +29,6 @@ export function elementMenuItems(element, { model, view = null, editProperties,
28
29
  : { label: 'Show in View (on none)', disabled: true, action: () => {} },
29
30
  ...(addToView && view && !onThisView ? [{ label: `Add to "${view.name || 'this view'}"`, action: () => addToView(element) }] : []),
30
31
  { label: 'Generate View For This', action: () => model.generateView([element.id]) },
32
+ ...(deleteFromModel ? [{ separator: true }, { label: '🗑 Delete from model…', danger: true, action: () => deleteFromModel(element) }] : []),
31
33
  ];
32
34
  }
@@ -0,0 +1,169 @@
1
+ import { compileFormula } from '../../kernelExpr.js';
2
+
3
+ /**
4
+ * Heat maps an ArchiMate view can be coloured by beyond its elements' own
5
+ * fields (ArchimateCanvasEditor#colourKeys): what change a deliverable or work
6
+ * package makes to each element, and a formula over the elements' numeric
7
+ * properties. Pure: no editor, no DOM.
8
+ */
9
+
10
+ /** The change heat map's colours. */
11
+ export const CHANGE_COLOURS = Object.freeze({ Create: '#FDD835', Update: '#1E88E5', Delete: '#D81B60' });
12
+
13
+ /** Element types that make changes: their relationships to an element say what they do to it. */
14
+ export const CHANGE_MAKERS = Object.freeze(['deliverable', 'work-package']);
15
+
16
+ /** The words for each change, synonyms included (verbs as base forms; their -s, -ed, -ing forms match too). */
17
+ export const CHANGE_WORDS = Object.freeze({
18
+ Delete: ['delete', 'remove', 'retire', 'decommission', 'drop', 'sunset', 'terminate', 'eliminate', 'discontinue', 'deprecate', 'withdraw', 'phase out', 'shut down', 'switch off'],
19
+ Create: ['create', 'add', 'new', 'introduce', 'build', 'built', 'establish', 'implement', 'insert', 'provision', 'set up', 'launch', 'develop', 'onboard'],
20
+ Update: ['update', 'change', 'modify', 'amend', 'enhance', 'upgrade', 'improve', 'revise', 'extend', 'migrate', 'refactor', 'replace', 'rework', 'adapt', 'alter', 'patch'],
21
+ });
22
+
23
+ /** Which change wins when an element is given more than one: the one with most consequence. */
24
+ const PRECEDENCE = ['Delete', 'Create', 'Update'];
25
+
26
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
+
28
+ /**
29
+ * A word and its inflections: retire → retires, retired, retiring; drop →
30
+ * dropped, dropping; modify → modifies, modified, modifying.
31
+ */
32
+ function forms(word) {
33
+ const [verb, ...rest] = word.split(' ');
34
+ const tail = rest.length ? `\\s+${rest.map(escape).join('\\s+')}` : '';
35
+ const stem = verb.endsWith('e') ? verb.slice(0, -1) : verb;
36
+ const doubled = /[^aeiou][aeiou][bdgmnpt]$/.test(verb) ? `|${escape(verb)}${verb.at(-1)}(?:ed|ing)` : '';
37
+ const y = /[^aeiou]y$/.test(verb) ? `|${escape(verb.slice(0, -1))}i(?:es|ed)` : '';
38
+ return `(?:${escape(verb)}(?:s|es|d|ed)?|${escape(stem)}(?:ed|ing|es)${doubled}${y})${tail}`;
39
+ }
40
+
41
+ const PATTERNS = Object.fromEntries(Object.entries(CHANGE_WORDS)
42
+ .map(([change, words]) => [change, new RegExp(`\\b(?:${words.map(forms).join('|')})\\b`, 'i')]));
43
+
44
+ /**
45
+ * The change a piece of text names ('Create', 'Update' or 'Delete'), or null.
46
+ * Where it names more than one, the one with most consequence (delete, then
47
+ * create, then update).
48
+ */
49
+ export function changeAction(text) {
50
+ if (!text) return null;
51
+ return PRECEDENCE.find(change => PATTERNS[change].test(String(text))) ?? null;
52
+ }
53
+
54
+ /**
55
+ * The change one relationship says a deliverable or work package makes: its
56
+ * name, or, when it has none, the name of the deliverable or work package at
57
+ * one of its ends (`toward`: the end that is not the element changed, when
58
+ * known). Null when neither end makes changes, or nothing is said.
59
+ * @returns {'Create'|'Update'|'Delete'|null}
60
+ */
61
+ export function relationshipChange(model, rel, toward = null) {
62
+ const ends = toward ? [toward] : [rel.source, rel.target];
63
+ const maker = ends.map(id => model.getElement(id)).find(e => e && CHANGE_MAKERS.includes(e.type));
64
+ if (!maker) return null;
65
+ return changeAction((rel.name ?? '').trim() ? rel.name : maker.name);
66
+ }
67
+
68
+ /**
69
+ * What change the deliverables and work packages related to an element make
70
+ * to it: for each relationship between them (either way), relationshipChange.
71
+ * Where they differ, the one with most consequence. Null when none says.
72
+ * @param {ArchimateModel} model
73
+ * @param {string} elementId
74
+ * @returns {'Create'|'Update'|'Delete'|null}
75
+ */
76
+ export function elementChange(model, elementId) {
77
+ const found = new Set();
78
+ for (const rel of model.relationships()) {
79
+ const other = rel.source === elementId ? rel.target : rel.target === elementId ? rel.source : null;
80
+ const change = other && relationshipChange(model, rel, other);
81
+ if (change) found.add(change);
82
+ }
83
+ return PRECEDENCE.find(change => found.has(change)) ?? null;
84
+ }
85
+
86
+ /** Whether a model has anything that makes changes (a deliverable or work package). */
87
+ export function hasChangeMakers(model) {
88
+ return model.elements().some(e => CHANGE_MAKERS.includes(e.type));
89
+ }
90
+
91
+ /**
92
+ * The names a formula can use for the properties: each property name as an
93
+ * identifier (anything not a letter, digit or _ becomes _; a leading digit gets
94
+ * a _ before it), mapped to the property it stands for.
95
+ * @param {Array<Object>} elements
96
+ * @returns {Map<string, string>} identifier -> property name
97
+ */
98
+ export function propertyParams(elements) {
99
+ const params = new Map();
100
+ for (const e of elements) {
101
+ for (const p of e.properties ?? []) {
102
+ const name = p.name ?? p.key;
103
+ if (!name) continue;
104
+ let id = String(name).replace(/[^A-Za-z0-9_]/g, '_');
105
+ if (/^\d/.test(id)) id = `_${id}`;
106
+ if (!params.has(id)) params.set(id, name);
107
+ }
108
+ }
109
+ return params;
110
+ }
111
+
112
+ /**
113
+ * A formula's result for each element (by id): the formula over the
114
+ * element's numeric properties, by their propertyParams names. An element
115
+ * missing a property the formula uses, or whose value is not a number, or
116
+ * whose result is not a finite number, has none.
117
+ * Throws the formula's own error when it does not parse (an unknown name, say).
118
+ * @returns {Map<string, number>}
119
+ */
120
+ export function expressionValues(elements, formula) {
121
+ const params = propertyParams(elements);
122
+ const names = [...params.keys()];
123
+ if (!names.length) throw new Error('No element here has a property to use');
124
+ const fn = compileFormula(formula, names);
125
+ const used = names.filter(n => new RegExp(`\\b${n}\\b`).test(formula));
126
+ const results = new Map();
127
+ for (const e of elements) {
128
+ const valueOf = (id) => {
129
+ const p = (e.properties ?? []).find(q => (q.name ?? q.key) === params.get(id));
130
+ const n = p === undefined || p.value === null || p.value === '' ? NaN : Number(p.value);
131
+ return n;
132
+ };
133
+ if (used.some(id => !Number.isFinite(valueOf(id)))) continue;
134
+ const result = fn(...names.map(id => { const v = valueOf(id); return Number.isFinite(v) ? v : 0; }));
135
+ if (Number.isFinite(result)) results.set(e.id, result);
136
+ }
137
+ return results;
138
+ }
139
+
140
+ /** A number as a label: integers as they are, others to at most 3 decimals. */
141
+ const show = (n) => (Number.isInteger(n) ? String(n) : String(Number(n.toFixed(3))));
142
+
143
+ /**
144
+ * Labels for a formula's results: each distinct result its own, in order,
145
+ * when there are at most `distinct` of them; else `bands` equal-width bands
146
+ * from the smallest to the largest ("10 – 20"), the last taking in the largest;
147
+ * a band no result falls in is left out.
148
+ * @param {Iterable<number>} numbers
149
+ * @returns {{labels: string[], labelOf: (n: number) => string}}
150
+ */
151
+ export function resultBands(numbers, { distinct = 8, bands = 5 } = {}) {
152
+ const values = [...new Set(numbers)].sort((a, b) => a - b);
153
+ if (values.length <= distinct) return { labels: values.map(show), labelOf: show };
154
+ const lo = values[0], hi = values.at(-1), width = (hi - lo) / bands;
155
+ const all = Array.from({ length: bands }, (_, i) => `${show(lo + i * width)} – ${show(i === bands - 1 ? hi : lo + (i + 1) * width)}`);
156
+ const labelOf = (n) => all[Math.min(bands - 1, Math.floor((n - lo) / width))];
157
+ const used = new Set(values.map(labelOf));
158
+ return { labels: all.filter(l => used.has(l)), labelOf };
159
+ }
160
+
161
+ /** `count` colours from light to dark (a sequential scale, light yellow to dark red), for ordered values. */
162
+ export function sequentialColours(count) {
163
+ const from = [255, 247, 188], to = [189, 0, 38];
164
+ const hex = (c) => Math.round(c).toString(16).padStart(2, '0');
165
+ return Array.from({ length: count }, (_, i) => {
166
+ const t = count === 1 ? 1 : i / (count - 1);
167
+ return `#${from.map((f, k) => hex(f + (to[k] - f) * t)).join('')}`;
168
+ });
169
+ }
@@ -15,3 +15,4 @@ export { ViewGroupTemplate, GROUP_FILL } from './ViewGroupTemplate.js';
15
15
  export { CustomViewpoints, CUSTOM_VIEWPOINTS_STORE } from './CustomViewpoints.js';
16
16
  export { specializationCatalogDialog } from './specializationCatalogDialog.js';
17
17
  export { elementTypeChoices } from './elementTypeChoices.js';
18
+ export { askDeleteFromModel, deleteFromModelMessage } from './deleteFromModel.js';
@@ -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
+ }