@peter.prib/renderengine 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +454 -302
- package/dist/renderengine-workspace.umd.js +2203 -113
- package/dist/renderengine.umd.js +2203 -113
- package/documentation/REFERENCE.md +30 -1
- package/package.json +8 -3
- package/src/chartCatalog.js +50 -3
- package/src/charting/Chart3D.js +4 -2
- package/src/charting/Hierarchy.js +28 -0
- package/src/charting/ask/AskBar.js +388 -0
- package/src/charting/ask/AskView.js +400 -0
- package/src/charting/charts/AreaChart.js +20 -3
- package/src/charting/charts/BarChart3D.js +2 -2
- package/src/charting/charts/BarChart3DOrbit.js +3 -1
- package/src/charting/charts/BoxPlotChart.js +2 -2
- package/src/charting/charts/BubbleChart.js +4 -1
- package/src/charting/charts/ChordDiagram.js +4 -4
- package/src/charting/charts/DensityChart.js +1 -1
- package/src/charting/charts/FunnelChart.js +1 -1
- package/src/charting/charts/HeatmapChart.js +4 -1
- package/src/charting/charts/HistogramChart.js +2 -2
- package/src/charting/charts/IcicleChart.js +47 -0
- package/src/charting/charts/LollipopChart.js +104 -0
- package/src/charting/charts/MarimekkoChart.js +75 -0
- package/src/charting/charts/OutlineChart.js +10 -8
- package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
- package/src/charting/charts/ParetoChart.js +92 -0
- package/src/charting/charts/PipeChart.js +5 -2
- package/src/charting/charts/PopulationPyramidChart.js +4 -4
- package/src/charting/charts/RadarChart.js +5 -4
- package/src/charting/charts/RadialBarChart.js +2 -2
- package/src/charting/charts/SankeyDiagram.js +4 -4
- package/src/charting/charts/ScatterChart.js +6 -3
- package/src/charting/charts/StackedBarChart.js +82 -11
- package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
- package/src/charting/charts/StreamGraphChart.js +8 -0
- package/src/charting/charts/SunburstChart.js +2 -2
- package/src/charting/charts/TreemapChart.js +4 -4
- package/src/charting/charts/ViolinChart.js +2 -2
- package/src/charting/charts/WaterfallChart.js +2 -2
- package/src/charting/charts/WordCloudChart.js +2 -2
- package/src/charting/pivot/PivotChart.js +205 -13
- package/src/charting/pivot/PivotGridChart.js +1 -1
- package/src/charting/pivot/PivotView.js +544 -21
- package/src/index.js +98 -2
|
@@ -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`
|
|
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.
|
|
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",
|
package/src/chartCatalog.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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[]}>
|
|
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,
|
package/src/charting/Chart3D.js
CHANGED
|
@@ -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
|
+
}
|