@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.
- package/README.md +454 -296
- package/dist/renderengine-workspace.umd.js +4122 -1167
- package/dist/renderengine.umd.js +4103 -1164
- package/documentation/REFERENCE.md +96 -6
- package/package.json +8 -3
- package/src/chartCatalog.js +50 -3
- package/src/charting/Axis.js +47 -5
- package/src/charting/Chart3D.js +4 -2
- package/src/charting/DiagramEditor.js +239 -34
- package/src/charting/Hierarchy.js +28 -0
- package/src/charting/ModelFileFormat.js +47 -0
- package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
- package/src/charting/archimate/deleteFromModel.js +57 -0
- package/src/charting/archimate/elementMenuItems.js +3 -1
- package/src/charting/archimate/heatMaps.js +169 -0
- package/src/charting/archimate/index.js +1 -0
- package/src/charting/ask/AskBar.js +388 -0
- package/src/charting/ask/AskView.js +400 -0
- package/src/charting/charts/AreaChart.js +20 -3
- package/src/charting/charts/BarChart.js +5 -5
- package/src/charting/charts/BarChart3D.js +2 -2
- package/src/charting/charts/BarChart3DOrbit.js +3 -1
- package/src/charting/charts/BarChartBase.js +21 -3
- package/src/charting/charts/BoxPlotChart.js +2 -2
- package/src/charting/charts/BubbleChart.js +4 -1
- package/src/charting/charts/ChordDiagram.js +4 -4
- package/src/charting/charts/DensityChart.js +1 -1
- package/src/charting/charts/FunnelChart.js +1 -1
- package/src/charting/charts/GroupedBarChart.js +7 -9
- package/src/charting/charts/HeatmapChart.js +4 -1
- package/src/charting/charts/HistogramChart.js +2 -2
- package/src/charting/charts/IcicleChart.js +47 -0
- package/src/charting/charts/LollipopChart.js +104 -0
- package/src/charting/charts/MarimekkoChart.js +75 -0
- package/src/charting/charts/OutlineChart.js +10 -8
- package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
- package/src/charting/charts/ParetoChart.js +92 -0
- package/src/charting/charts/PipeChart.js +5 -2
- package/src/charting/charts/PopulationPyramidChart.js +4 -4
- package/src/charting/charts/RadarChart.js +5 -4
- package/src/charting/charts/RadialBarChart.js +2 -2
- package/src/charting/charts/SankeyDiagram.js +4 -4
- package/src/charting/charts/ScatterChart.js +6 -3
- package/src/charting/charts/StackedBarChart.js +82 -11
- package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
- package/src/charting/charts/StreamGraphChart.js +8 -0
- package/src/charting/charts/SunburstChart.js +2 -2
- package/src/charting/charts/TableChart.js +18 -2
- package/src/charting/charts/TreemapChart.js +4 -4
- package/src/charting/charts/ViolinChart.js +2 -2
- package/src/charting/charts/WaterfallChart.js +2 -2
- package/src/charting/charts/WordCloudChart.js +2 -2
- package/src/charting/diagrams/ArchimateModel.js +33 -0
- package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
- package/src/charting/diagrams/EdgeGeometry.js +16 -0
- package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
- package/src/charting/pivot/PivotChart.js +205 -13
- package/src/charting/pivot/PivotGridChart.js +1 -1
- package/src/charting/pivot/PivotView.js +555 -23
- package/src/charting/table/TableView.js +10 -1
- package/src/charting/table/TableViewChart.js +1 -1
- package/src/charting/templates/Template.js +10 -0
- package/src/exportImage.js +45 -18
- package/src/index.js +115 -6
- package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
- package/src/workspace/parts/DiagramEditorPart.js +2 -0
- 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
|
+
}
|