@peter.prib/renderengine 0.15.0 → 0.15.1
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/dist/renderengine-workspace.umd.js +251 -0
- package/documentation/REFERENCE.md +170 -2
- package/examples/decision-workbench.html +26 -0
- package/package.json +2 -1
- package/src/workspace/index.js +3 -0
- package/src/workspace/parts/DecisionWorkbenchPart.js +58 -0
- package/src/workspace/parts/ViewsPart.js +196 -0
|
@@ -69500,6 +69500,255 @@ ${cells.map(c => ` <Cell${isHeader ? ' ss:StyleID="Header"' : ''}><Data ss:T
|
|
|
69500
69500
|
}
|
|
69501
69501
|
}
|
|
69502
69502
|
|
|
69503
|
+
/**
|
|
69504
|
+
* ViewsPart — a set of chart views in one part: a tab per view, each a grid of charts and its notes.
|
|
69505
|
+
*
|
|
69506
|
+
* A view is plain data — `{ id, title, charts: [{ title, chartType, data, options? }], notes? }`,
|
|
69507
|
+
* `chartType` a CHART_CATALOG key — so anything that can say what it wants drawn can be shown here
|
|
69508
|
+
* without a page of its own: a report, an analysis, a monitoring summary. The Decision Workbench
|
|
69509
|
+
* (`DecisionWorkbenchPart`) is one; any other producer of views is another, by spec or by subclass.
|
|
69510
|
+
*
|
|
69511
|
+
* Why it is one part, not a perspective per view. A part drops into any layout — a tab, a split, a
|
|
69512
|
+
* dashboard cell, a WorkArea document — beside whatever else the host shows; perspectives belong to
|
|
69513
|
+
* the whole workspace, so a producer that registered its own would take over the host's. Inside, it
|
|
69514
|
+
* composes the parts that already exist (`ChartPart` per chart, `ListPart` for notes) rather than
|
|
69515
|
+
* drawing anything itself, so a chart here behaves as a chart anywhere: same sizing, same redraw.
|
|
69516
|
+
*
|
|
69517
|
+
* Why subclasses read files, not a reader in the spec. A spec stays JSON (the workspace serializes
|
|
69518
|
+
* it), so a function cannot ride in it. The base reads a document that already is `{ views: [...] }`;
|
|
69519
|
+
* a subclass that reads something else overrides `viewsFrom(doc)` (and `accept`, `controls()`,
|
|
69520
|
+
* `controlValues()` for options its reader takes) — one subclass per kind of document.
|
|
69521
|
+
*
|
|
69522
|
+
* Spec: `{ views?, open?: boolean, empty?: string }`. With `open` (default true) a toolbar opens a
|
|
69523
|
+
* file (`accept`); `read(doc)` does the same from code. The views shown are kept in `spec.views`,
|
|
69524
|
+
* so a serialized workspace comes back showing them.
|
|
69525
|
+
*/
|
|
69526
|
+
|
|
69527
|
+
/** Chart categories drawn as small tiles across the top of a view rather than as full charts. */
|
|
69528
|
+
const TILE_CATEGORIES = new Set(['gauge']);
|
|
69529
|
+
|
|
69530
|
+
class ViewsPart extends Part {
|
|
69531
|
+
static type = 'views';
|
|
69532
|
+
|
|
69533
|
+
/** File types the toolbar offers to open. */
|
|
69534
|
+
static accept = '.json';
|
|
69535
|
+
|
|
69536
|
+
constructor(spec = {}) {
|
|
69537
|
+
super(spec);
|
|
69538
|
+
this.views = spec.views ?? [];
|
|
69539
|
+
this.allowOpen = spec.open !== false;
|
|
69540
|
+
this.empty = spec.empty ?? 'Open a file to show its views.';
|
|
69541
|
+
this.active = this.views[0]?.id ?? null;
|
|
69542
|
+
/** The chart and notes parts made for the views shown, torn down when they change. @private */
|
|
69543
|
+
this._children = [];
|
|
69544
|
+
}
|
|
69545
|
+
|
|
69546
|
+
/**
|
|
69547
|
+
* Where each view's charts go: tiles (gauge-like charts) share the top row, the rest take half a
|
|
69548
|
+
* row each, or the whole row when a view has one; notes go underneath. Pure: the layout rule,
|
|
69549
|
+
* kept apart from the DOM so it can be read and tested on its own.
|
|
69550
|
+
* @returns {Array<{id, title, items: Array<{kind: 'chart'|'notes', index?: number, span: number, tile?: boolean}>}>}
|
|
69551
|
+
*/
|
|
69552
|
+
static layout(views) {
|
|
69553
|
+
return views.map((view) => {
|
|
69554
|
+
const charts = view.charts ?? [];
|
|
69555
|
+
const isTile = (chart) => TILE_CATEGORIES.has(CHART_CATALOG.find((c) => c.key === chart.chartType)?.category);
|
|
69556
|
+
const tiles = charts.filter(isTile).length;
|
|
69557
|
+
const others = charts.length - tiles;
|
|
69558
|
+
const items = charts.map((chart, index) => (isTile(chart)
|
|
69559
|
+
? { kind: 'chart', index, tile: true, span: Math.max(1, Math.floor(12 / tiles)) }
|
|
69560
|
+
: { kind: 'chart', index, tile: false, span: others === 1 ? 12 : 6 }));
|
|
69561
|
+
if (view.notes?.length) items.push({ kind: 'notes', span: 12 });
|
|
69562
|
+
return { id: view.id, title: view.title ?? view.id, items };
|
|
69563
|
+
});
|
|
69564
|
+
}
|
|
69565
|
+
|
|
69566
|
+
/** The views a document holds. The base takes `{ views: [...] }`; a subclass reads its own kind of document. */
|
|
69567
|
+
viewsFrom(doc) {
|
|
69568
|
+
if (!doc || !Array.isArray(doc.views)) throw new Error('Not a views document: it has no views list');
|
|
69569
|
+
return doc.views;
|
|
69570
|
+
}
|
|
69571
|
+
|
|
69572
|
+
/** Extra toolbar fields a subclass's reader takes (element specs). */
|
|
69573
|
+
controls() { return []; }
|
|
69574
|
+
|
|
69575
|
+
/** The values of those fields, for `viewsFrom`. */
|
|
69576
|
+
controlValues() { return {}; }
|
|
69577
|
+
|
|
69578
|
+
/** Show the views a document holds; a document that cannot be read is said so, and the views shown stay. */
|
|
69579
|
+
read(doc) {
|
|
69580
|
+
let views;
|
|
69581
|
+
try {
|
|
69582
|
+
views = this.viewsFrom(doc);
|
|
69583
|
+
} catch (err) {
|
|
69584
|
+
this.say(err.message);
|
|
69585
|
+
return false;
|
|
69586
|
+
}
|
|
69587
|
+
this.document = doc;
|
|
69588
|
+
this.show(views);
|
|
69589
|
+
return true;
|
|
69590
|
+
}
|
|
69591
|
+
|
|
69592
|
+
/** Show `views`, replacing those shown. */
|
|
69593
|
+
show(views) {
|
|
69594
|
+
this.views = views;
|
|
69595
|
+
this.spec.views = views;
|
|
69596
|
+
if (!views.some((v) => v.id === this.active)) this.active = views[0]?.id ?? null;
|
|
69597
|
+
if (this.mounted) this.renderViews();
|
|
69598
|
+
}
|
|
69599
|
+
|
|
69600
|
+
say(text) {
|
|
69601
|
+
if (this._message) this._message.textContent = text;
|
|
69602
|
+
}
|
|
69603
|
+
|
|
69604
|
+
render() {
|
|
69605
|
+
Object.assign(this.container.style, { display: 'flex', flexDirection: 'column', height: '100%', minHeight: '0' });
|
|
69606
|
+
if (this.allowOpen) {
|
|
69607
|
+
this.container.appendChild(el({
|
|
69608
|
+
cls: 'ws-views-toolbar',
|
|
69609
|
+
style: { display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap', padding: '6px 8px' },
|
|
69610
|
+
children: [
|
|
69611
|
+
{ tag: 'label', children: [
|
|
69612
|
+
{ tag: 'span', text: 'Open ' },
|
|
69613
|
+
{ tag: 'input', attrs: { type: 'file', accept: this.constructor.accept }, on: { change: (e) => this.openFile(e.target) } },
|
|
69614
|
+
]},
|
|
69615
|
+
...this.controls(),
|
|
69616
|
+
{ tag: 'span', cls: 'ws-views-message', style: { opacity: '0.75' }, ref: (n) => { this._message = n; } },
|
|
69617
|
+
],
|
|
69618
|
+
}));
|
|
69619
|
+
}
|
|
69620
|
+
this._tabs = this.container.appendChild(el({ cls: 'ws-views-tabs', style: { display: 'flex', gap: '4px', padding: '0 8px' } }));
|
|
69621
|
+
this._body = this.container.appendChild(el({ cls: 'ws-views-body', style: { flex: '1 1 auto', minHeight: '0', overflow: 'auto' } }));
|
|
69622
|
+
this.onDispose(() => this.clearChildren());
|
|
69623
|
+
this.renderViews();
|
|
69624
|
+
}
|
|
69625
|
+
|
|
69626
|
+
async openFile(input) {
|
|
69627
|
+
const file = input.files?.[0];
|
|
69628
|
+
if (!file) return;
|
|
69629
|
+
let doc;
|
|
69630
|
+
try {
|
|
69631
|
+
doc = JSON.parse(await file.text());
|
|
69632
|
+
} catch (err) {
|
|
69633
|
+
this.say(`Not JSON: ${err.message}`);
|
|
69634
|
+
return;
|
|
69635
|
+
}
|
|
69636
|
+
this.read(doc);
|
|
69637
|
+
}
|
|
69638
|
+
|
|
69639
|
+
/** Re-read the document shown, as when a control's value changes. */
|
|
69640
|
+
reread() {
|
|
69641
|
+
if (this.document !== undefined) this.read(this.document);
|
|
69642
|
+
}
|
|
69643
|
+
|
|
69644
|
+
clearChildren() {
|
|
69645
|
+
for (const part of this._children) part.dispose?.();
|
|
69646
|
+
this._children = [];
|
|
69647
|
+
}
|
|
69648
|
+
|
|
69649
|
+
renderViews() {
|
|
69650
|
+
this.clearChildren();
|
|
69651
|
+
this._tabs.textContent = '';
|
|
69652
|
+
this._body.textContent = '';
|
|
69653
|
+
if (!this.views.length) {
|
|
69654
|
+
this._body.appendChild(el({ tag: 'p', cls: 'ws-views-empty', style: { padding: '12px', opacity: '0.75' }, text: this.empty }));
|
|
69655
|
+
return;
|
|
69656
|
+
}
|
|
69657
|
+
for (const view of ViewsPart.layout(this.views)) {
|
|
69658
|
+
this._tabs.appendChild(el({
|
|
69659
|
+
tag: 'button', attrs: { type: 'button' }, cls: view.id === this.active ? 'ws-views-tab active' : 'ws-views-tab',
|
|
69660
|
+
style: { fontWeight: view.id === this.active ? '600' : '400' },
|
|
69661
|
+
text: view.title, on: { click: () => { this.active = view.id; this.renderViews(); } },
|
|
69662
|
+
}));
|
|
69663
|
+
}
|
|
69664
|
+
const view = this.views.find((v) => v.id === this.active);
|
|
69665
|
+
const placed = ViewsPart.layout([view])[0];
|
|
69666
|
+
const grid = this._body.appendChild(el({
|
|
69667
|
+
cls: 'ws-views-grid',
|
|
69668
|
+
style: { display: 'grid', gridTemplateColumns: 'repeat(12, 1fr)', gap: '8px', padding: '8px' },
|
|
69669
|
+
}));
|
|
69670
|
+
for (const item of placed.items) {
|
|
69671
|
+
// A cell is a part host, as a layout's pane is (`.ws-part-host`: flex, the part fills it), so a
|
|
69672
|
+
// chart here sizes to its cell exactly as it would to a pane; only the cell's height is its own.
|
|
69673
|
+
const cell = grid.appendChild(el({
|
|
69674
|
+
cls: 'ws-views-cell ws-part-host',
|
|
69675
|
+
style: { gridColumn: `span ${item.span}`, height: item.kind === 'chart' ? (item.tile ? '130px' : '380px') : 'auto', minWidth: '0' },
|
|
69676
|
+
}));
|
|
69677
|
+
const part = item.kind === 'chart'
|
|
69678
|
+
? new ChartPart(this.chartSpec(view, item.index))
|
|
69679
|
+
: new ListPart({ id: `${this.id}-${view.id}-notes`, title: 'Notes', channel: `${this.id}:notes:${view.id}`, items: view.notes });
|
|
69680
|
+
part.mount(cell, this.ctx);
|
|
69681
|
+
this._children.push(part);
|
|
69682
|
+
}
|
|
69683
|
+
}
|
|
69684
|
+
|
|
69685
|
+
/** The ChartPart spec for a view's chart. */
|
|
69686
|
+
chartSpec(view, index) {
|
|
69687
|
+
const chart = view.charts[index];
|
|
69688
|
+
return {
|
|
69689
|
+
id: `${this.id}-${view.id}-${index}`, title: chart.title, chartType: chart.chartType, rendererType: 'svg',
|
|
69690
|
+
options: { ...chart.options, title: chart.title, data: chart.data },
|
|
69691
|
+
};
|
|
69692
|
+
}
|
|
69693
|
+
}
|
|
69694
|
+
|
|
69695
|
+
/**
|
|
69696
|
+
* DecisionWorkbenchPart — the Decision Workbench as one part: a SADIS workbench document
|
|
69697
|
+
* (decisionengine's `{ kind: 'sadis-workbench', report, plan?, before? }`) shown as its views.
|
|
69698
|
+
*
|
|
69699
|
+
* Everything about showing views — tabs, the chart grid, notes, opening a file, being embeddable in
|
|
69700
|
+
* any layout — is `ViewsPart`'s; this adds only what differs for this kind of document: reading it
|
|
69701
|
+
* through `DecisionWorkbench` (which turns decisionengine's JSON into views), and the calendar its
|
|
69702
|
+
* delivery plan is drawn on (when period 1 starts, how many days a period is). That split is why it
|
|
69703
|
+
* is a subclass: a second kind of document is a second subclass, not a flag on one class.
|
|
69704
|
+
*
|
|
69705
|
+
* Spec: `ViewsPart`'s, plus `document` (a workbench document to show at once), `start` and
|
|
69706
|
+
* `periodDays` (the calendar; default the report's as-of time and 14 days).
|
|
69707
|
+
*/
|
|
69708
|
+
|
|
69709
|
+
class DecisionWorkbenchPart extends ViewsPart {
|
|
69710
|
+
static type = 'decision-workbench';
|
|
69711
|
+
|
|
69712
|
+
constructor(spec = {}) {
|
|
69713
|
+
super({ empty: 'Open a workbench file: decisionengine\'s SADIS Explorer downloads one ("Download for the Decision Workbench").', ...spec });
|
|
69714
|
+
this.calendar = { start: spec.start, periodDays: spec.periodDays ?? 14 };
|
|
69715
|
+
if (spec.document !== undefined) this.read(spec.document);
|
|
69716
|
+
}
|
|
69717
|
+
|
|
69718
|
+
viewsFrom(doc) {
|
|
69719
|
+
return DecisionWorkbench.from(doc, this.controlValues()).views().map((view) => view.toJSON());
|
|
69720
|
+
}
|
|
69721
|
+
|
|
69722
|
+
controls() {
|
|
69723
|
+
const field = { padding: '2px 4px', font: 'inherit' };
|
|
69724
|
+
return [
|
|
69725
|
+
{ tag: 'label', children: [
|
|
69726
|
+
{ tag: 'span', text: 'Period 1 starts ' },
|
|
69727
|
+
{ tag: 'input', attrs: { type: 'date', value: this.calendar.start ?? '' }, style: field,
|
|
69728
|
+
on: { change: (e) => { this.calendar.start = e.target.value || undefined; this.reread(); } } },
|
|
69729
|
+
]},
|
|
69730
|
+
{ tag: 'label', children: [
|
|
69731
|
+
{ tag: 'span', text: 'Days per period ' },
|
|
69732
|
+
{ tag: 'input', attrs: { type: 'number', min: '1', value: String(this.calendar.periodDays) }, style: { ...field, width: '4em' },
|
|
69733
|
+
on: { change: (e) => { this.calendar.periodDays = Number(e.target.value) || 14; this.reread(); } } },
|
|
69734
|
+
]},
|
|
69735
|
+
];
|
|
69736
|
+
}
|
|
69737
|
+
|
|
69738
|
+
controlValues() {
|
|
69739
|
+
return { start: this.calendar.start, periodDays: this.calendar.periodDays };
|
|
69740
|
+
}
|
|
69741
|
+
|
|
69742
|
+
read(doc) {
|
|
69743
|
+
const ok = super.read(doc);
|
|
69744
|
+
if (ok) {
|
|
69745
|
+
const asOf = doc.report.asOf?.businessTime?.slice(0, 10);
|
|
69746
|
+
this.say(`${doc.report.config}: ${doc.report.entries.length} candidates${asOf ? `, as of ${asOf}` : ''}`);
|
|
69747
|
+
}
|
|
69748
|
+
return ok;
|
|
69749
|
+
}
|
|
69750
|
+
}
|
|
69751
|
+
|
|
69503
69752
|
/** Each page's own title, as it was before any model's file name went in front of it (several models share one page). */
|
|
69504
69753
|
const PAGE_TITLES = new WeakMap();
|
|
69505
69754
|
|
|
@@ -72764,6 +73013,7 @@ ${cells.map(c => ` <Cell${isHeader ? ' ss:StyleID="Header"' : ''}><Data ss:T
|
|
|
72764
73013
|
exports.DataStoreTemplate = DataStoreTemplate;
|
|
72765
73014
|
exports.DataType = DataType;
|
|
72766
73015
|
exports.DecisionWorkbench = DecisionWorkbench;
|
|
73016
|
+
exports.DecisionWorkbenchPart = DecisionWorkbenchPart;
|
|
72767
73017
|
exports.DeleteEdgeCommand = DeleteEdgeCommand;
|
|
72768
73018
|
exports.DeleteFromModelCommand = DeleteFromModelCommand;
|
|
72769
73019
|
exports.DeleteWaypointCommand = DeleteWaypointCommand;
|
|
@@ -73011,6 +73261,7 @@ ${cells.map(c => ` <Cell${isHeader ? ' ss:StyleID="Header"' : ''}><Data ss:T
|
|
|
73011
73261
|
exports.ViewSetModel = ViewSetModel;
|
|
73012
73262
|
exports.ViewTabStrip = ViewTabStrip;
|
|
73013
73263
|
exports.ViewpointRule = ViewpointRule;
|
|
73264
|
+
exports.ViewsPart = ViewsPart;
|
|
73014
73265
|
exports.WORKBENCH_VIEWS = WORKBENCH_VIEWS;
|
|
73015
73266
|
exports.WORKSPACE_CSS = WORKSPACE_CSS;
|
|
73016
73267
|
exports.WORKSPACE_SCHEMA_VERSION = WORKSPACE_SCHEMA_VERSION;
|
|
@@ -2640,6 +2640,8 @@ end. Waypoints and style are kept, and that end's side pin resets to automatic.
|
|
|
2640
2640
|
empty canvas, or on the shape at the other end, cancels. This is one undo step
|
|
2641
2641
|
(`ReconnectEdgeCommand`).
|
|
2642
2642
|
|
|
2643
|
+
**Container nesting and hierarchy.** Shapes such as `PackageTemplate`, `SwimlaneTemplate`, and `BoxTemplate` are containers (`isContainer = true`). When an element is dropped inside a container (`nestsOnDrop(parentId, childId)`), it is parented via `NestElementCommand` (setting `child.parentId`). Moving a container recursively moves all nested descendants (`moversFor(templateId)`). Containers can automatically expand their bounds to fit their contents via `fitNesting(changes, { padding })`. Ancestor loops are prevented (`isAncestor(childId, parentId)`).
|
|
2644
|
+
|
|
2643
2645
|
**Line style levels.** A line's style resolves through `EdgeStyleCascade`: the line itself,
|
|
2644
2646
|
then each level in `editor.edgeStyleLevels` (most specific first, `[{ key, label, style }]`),
|
|
2645
2647
|
then the schema default. A value a level doesn't set is inherited. `EDGE_STYLE_PROPERTIES` is the
|
|
@@ -3235,8 +3237,20 @@ saying why. The delivery plan's periods are put on a calendar: period 1 starts a
|
|
|
3235
3237
|
the report's as-of business time, else today), each `periodDays` long (default 14). Each view is a
|
|
3236
3238
|
`WorkbenchView` subclass (`WORKBENCH_VIEWS`); a new view is a new subclass.
|
|
3237
3239
|
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
+
In a workspace the workbench is one part, `DecisionWorkbenchPart` (type `decision-workbench`), a
|
|
3241
|
+
`ViewsPart` that reads workbench documents: a tab per view, its charts and notes, a toolbar that opens a
|
|
3242
|
+
file and sets the calendar.
|
|
3243
|
+
|
|
3244
|
+
```js
|
|
3245
|
+
const { registerBuiltinParts, ChartPart, DecisionWorkbenchPart } = RenderEngineWorkspace;
|
|
3246
|
+
ws.registerParts([ChartPart, DecisionWorkbenchPart]);
|
|
3247
|
+
ws.addParts([{ id: 'workbench', type: 'decision-workbench', document /* optional */, start, periodDays }]);
|
|
3248
|
+
```
|
|
3249
|
+
|
|
3250
|
+
`ViewsPart` (type `views`) is the general part underneath: any `{ views: [{ id, title, charts, notes }] }`
|
|
3251
|
+
document, or views given in its spec, shown the same way — see `documentation/WORKSPACE_ARCHITECTURE.md`
|
|
3252
|
+
for why it is one part and why a new kind of document is a subclass. `examples/decision-workbench.html`
|
|
3253
|
+
is a thin host of the workbench part (it is in the package).
|
|
3240
3254
|
|
|
3241
3255
|
## Navigation menus
|
|
3242
3256
|
|
|
@@ -3349,6 +3363,160 @@ const bar = RenderEngine.askBar(document.getElementById('ask-bar'), {
|
|
|
3349
3363
|
- **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.
|
|
3350
3364
|
- **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.
|
|
3351
3365
|
|
|
3366
|
+
## Navigator & Relationship Explorer (`NavigatorModel` / `NavigatorPanel`)
|
|
3367
|
+
|
|
3368
|
+
`NavigatorModel` (`src/charting/modelTree/NavigatorModel.js`) and `NavigatorPanel` (`src/charting/modelTree/NavigatorPanel.js`) provide interactive graph traversal and relationship exploration for models. It enables navigating through connected elements step-by-step to arbitrary depth with cycle detection, navigation history (back/forward), direction filters, path-finding, and transitive impact analysis.
|
|
3369
|
+
|
|
3370
|
+
```javascript
|
|
3371
|
+
import { NavigatorModel, NavigatorPanel, buildNavigatorPanel } from '@peter.prib/renderengine';
|
|
3372
|
+
|
|
3373
|
+
// 1. Create a pure, DOM-free traversal model:
|
|
3374
|
+
const nav = new NavigatorModel(archimateModel, {
|
|
3375
|
+
rootId: 'billing-app',
|
|
3376
|
+
direction: 'both', // 'both' | 'outgoing' | 'incoming'
|
|
3377
|
+
allowedKinds: ['serving-relationship', 'composition-relationship'],
|
|
3378
|
+
});
|
|
3379
|
+
|
|
3380
|
+
nav.focus('crm-app');
|
|
3381
|
+
nav.canGoBack; // true
|
|
3382
|
+
nav.back(); // returns to 'billing-app'
|
|
3383
|
+
|
|
3384
|
+
// Find all paths between two elements (up to maxDepth):
|
|
3385
|
+
const paths = nav.findPaths('billing-app', 'ledger-db', 5);
|
|
3386
|
+
|
|
3387
|
+
// Trace transitive downstream or upstream impact:
|
|
3388
|
+
const impact = nav.findImpact('billing-app', { maxDepth: 4, direction: 'outgoing' });
|
|
3389
|
+
|
|
3390
|
+
// 2. Build or render the Navigator UI panel:
|
|
3391
|
+
const panel = new NavigatorPanel({
|
|
3392
|
+
model: archimateModel,
|
|
3393
|
+
navigator: nav,
|
|
3394
|
+
onSelectElement: (elId) => editor.selectElement(elId),
|
|
3395
|
+
onOpenView: (viewId) => editor.openView(viewId),
|
|
3396
|
+
});
|
|
3397
|
+
```
|
|
3398
|
+
|
|
3399
|
+
- **DOM-free engine:** `NavigatorModel` operates completely without DOM dependencies, making it directly testable in Node.js and reusable across diverse UI shells.
|
|
3400
|
+
- **Cycle detection and path tracking:** Tracks visited nodes along active branches to prevent infinite recursion in cyclic relationship graphs, annotating cycles in tree outputs.
|
|
3401
|
+
- **Directional filters:** Traversal can follow outgoing relationships, incoming relationships, or bidirectional connections (`setDirection('outgoing' | 'incoming' | 'both')`).
|
|
3402
|
+
- **History stack:** Maintains full forward and backward navigation stacks (`focus(id)`, `back()`, `forward()`, `canGoBack`, `canGoForward`).
|
|
3403
|
+
- **Impact and path finding:** Built-in BFS/DFS algorithms find connecting pathways (`findPaths(fromId, toId, maxDepth)`) and downstream dependency cascades (`findImpact(elementId, { maxDepth, direction })`).
|
|
3404
|
+
|
|
3405
|
+
## Templated Reports (`TemplatedReport`)
|
|
3406
|
+
|
|
3407
|
+
`TemplatedReport` (`src/charting/report/TemplatedReport.js`) provides multi-format, template-driven report generation matching Archi's Jasper-style reporting capabilities. It extracts model metadata, viewpoints, diagrams (SVG/PNG), elements, properties, and relationships into document formats.
|
|
3408
|
+
|
|
3409
|
+
```javascript
|
|
3410
|
+
import { TemplatedReport, REPORT_TEMPLATES } from '@peter.prib/renderengine';
|
|
3411
|
+
|
|
3412
|
+
const report = new TemplatedReport(archimateModel, {
|
|
3413
|
+
title: 'Enterprise Architecture Overview',
|
|
3414
|
+
template: REPORT_TEMPLATES.DETAILED, // 'executive' | 'detailed' | 'tabular'
|
|
3415
|
+
pictures: { 'view-1': 'data:image/svg+xml;utf8,...' },
|
|
3416
|
+
});
|
|
3417
|
+
|
|
3418
|
+
// Generate various formats:
|
|
3419
|
+
const htmlDoc = report.toHtml({ printable: true }); // PDF-ready HTML with @media print CSS
|
|
3420
|
+
const wordDoc = report.toWord(); // Microsoft Word (.doc HTML format)
|
|
3421
|
+
const excelXml = report.toExcel(); // Excel SpreadsheetML (.xml)
|
|
3422
|
+
const csvData = report.toCsv('elements'); // RFC 4180 CSV ('elements' | 'relationships')
|
|
3423
|
+
const markdown = report.toMarkdown(); // GitHub-flavored Markdown
|
|
3424
|
+
```
|
|
3425
|
+
|
|
3426
|
+
- **Output formats:** Generates printable PDF-ready HTML with page-break controls, Word-compatible HTML documents (`.doc`), Excel SpreadsheetML workbooks (`.xml`) with styled headers and filters, RFC 4180 CSV tables, and GitHub-flavored Markdown (`.md`).
|
|
3427
|
+
- **Built-in templates:** Includes `REPORT_TEMPLATES.EXECUTIVE` (high-level summary with diagrams), `REPORT_TEMPLATES.DETAILED` (full catalog of views, elements, relationships, and user properties), and `REPORT_TEMPLATES.TABULAR` (matrix-focused presentation).
|
|
3428
|
+
- **Embedded diagram pictures:** Accepts rendered SVG data URIs or base64 raster images in the `pictures` dictionary keyed by view ID to embed high-resolution diagrams directly into generated documents.
|
|
3429
|
+
|
|
3430
|
+
## Sketch Views & Palettes (`SketchView` / `sketchBoxKinds`)
|
|
3431
|
+
|
|
3432
|
+
`SketchView` (`src/charting/diagrams/SketchView.js`) implements freeform sketching views (corresponding to Archi's `archimate:SketchModel`). It supports sticky notes, stick-figure actors, groups, freehand lines, and customizable palettes on grid or lined canvas backgrounds.
|
|
3433
|
+
|
|
3434
|
+
```javascript
|
|
3435
|
+
import { SketchView, SKETCH_BOX_KINDS, DEFAULT_SKETCH_PALETTE } from '@peter.prib/renderengine';
|
|
3436
|
+
|
|
3437
|
+
const sketch = new SketchView(model, {
|
|
3438
|
+
name: 'Brainstorming Session',
|
|
3439
|
+
background: SketchView.BACKGROUNDS.GRID, // NONE (0), GRID (1), LINES (2)
|
|
3440
|
+
palette: DEFAULT_SKETCH_PALETTE,
|
|
3441
|
+
});
|
|
3442
|
+
|
|
3443
|
+
// Custom palette management:
|
|
3444
|
+
sketch.addPaletteItem({ id: 'custom-note', name: 'Critical Note', fill: '#ffcccc' });
|
|
3445
|
+
const jsonConfig = sketch.exportPalette();
|
|
3446
|
+
sketch.importPalette(jsonConfig);
|
|
3447
|
+
```
|
|
3448
|
+
|
|
3449
|
+
- **Sketch box types:** Native support for sticky notes (`sketch-model-sticky`) and stick figures (`sketch-model-actor`) via `SKETCH_BOX_KINDS`.
|
|
3450
|
+
- **Background styles:** Configurable canvas backgrounds including plain (`NONE`), grid paper (`GRID`), and lined notebook paper (`LINES`).
|
|
3451
|
+
- **Palette customization:** Dynamically add, remove, reset, export, and import custom sticky colors and presets as JSON configurations.
|
|
3452
|
+
- **Archi XML compatibility:** Full bi-directional fidelity with `.archimate` sketch model XML representations (`archimate:SketchModel`, `archimate:Sticky`, `archimate:Actor`).
|
|
3453
|
+
|
|
3454
|
+
## Cross-Model Data Lineage & ArchiMate Linking
|
|
3455
|
+
|
|
3456
|
+
`ModelMapping` (`src/charting/datamodel/ModelMapping.js`), `DataLineage` (`src/charting/datamodel/DataLineage.js`), and `ArchimateDataLink` (`src/charting/datamodel/ArchimateDataLink.js`) extend the data modeling subsystem (`src/charting/datamodel/`) with enterprise cross-model data lineage and architectural traceability aligned with DMBOK2 and TOGAF.
|
|
3457
|
+
|
|
3458
|
+
```javascript
|
|
3459
|
+
import { DataModel, ModelMapping, DataLineage, ArchimateDataLink } from '@peter.prib/renderengine';
|
|
3460
|
+
|
|
3461
|
+
// 1. Cross-model mapping with attribute-level transformation rules:
|
|
3462
|
+
const mapping = new ModelMapping({
|
|
3463
|
+
name: 'Core Banking to EDW',
|
|
3464
|
+
sourceModel: 'OLTP-Core',
|
|
3465
|
+
targetModel: 'EDW-Analytical',
|
|
3466
|
+
entityMappings: [{ sourceEntityId: 'account', targetEntityId: 'dim_account', rule: 'SCD Type 2' }],
|
|
3467
|
+
attributeMappings: [
|
|
3468
|
+
{ sourceEntityId: 'account', sourceAttribute: 'bal', targetEntityId: 'dim_account', targetAttribute: 'current_balance', transformation: 'ROUND(bal, 2)' }
|
|
3469
|
+
],
|
|
3470
|
+
});
|
|
3471
|
+
|
|
3472
|
+
dataModel.addModelMapping(mapping);
|
|
3473
|
+
|
|
3474
|
+
// 2. Trace end-to-end lineage and downstream impact:
|
|
3475
|
+
const lineage = dataModel.crossModelLineage();
|
|
3476
|
+
const upstreamHops = lineage.traceLineage('dim_account', 'current_balance'); // Upstream provenance
|
|
3477
|
+
const downstreamImpact = lineage.traceImpact('account', 'bal'); // Downstream impact
|
|
3478
|
+
|
|
3479
|
+
// 3. Connect Data Model entities to ArchiMate enterprise architecture:
|
|
3480
|
+
const link = new ArchimateDataLink({
|
|
3481
|
+
dataModelEntityId: 'account',
|
|
3482
|
+
level: 'conceptual',
|
|
3483
|
+
archimateElementId: 'business-obj-account',
|
|
3484
|
+
archimateType: 'business-object',
|
|
3485
|
+
});
|
|
3486
|
+
dataModel.addArchimateLink(link);
|
|
3487
|
+
```
|
|
3488
|
+
|
|
3489
|
+
- **DMBOK2 integration:** Enables tracking provenance across conceptual, logical, and physical models, including disparate source and target data stores.
|
|
3490
|
+
- **Multi-hop traversal:** Traces multi-hop transformations forwards (`traceImpact`) and backwards (`traceLineage`) across complex data pipelines.
|
|
3491
|
+
- **ArchiMate alignment:** Bridges data model entities to ArchiMate Business Objects (Business Layer), Data Objects (Application Layer), and Artifacts (Technology Layer).
|
|
3492
|
+
|
|
3493
|
+
## Multi-View Chart Dashboards (`ViewsPart` / `DecisionWorkbenchPart`)
|
|
3494
|
+
|
|
3495
|
+
`ViewsPart` (`src/workspace/parts/ViewsPart.js`) and `DecisionWorkbenchPart` (`src/workspace/parts/DecisionWorkbenchPart.js`) render multi-tab dashboards of coordinated charts and note cards inside the `<ws-workspace>` application shell.
|
|
3496
|
+
|
|
3497
|
+
```javascript
|
|
3498
|
+
// Declarative view specification for a workspace layout node:
|
|
3499
|
+
const layout = {
|
|
3500
|
+
type: 'part',
|
|
3501
|
+
part: 'views',
|
|
3502
|
+
views: [
|
|
3503
|
+
{
|
|
3504
|
+
id: 'executive-summary',
|
|
3505
|
+
title: 'Executive Summary',
|
|
3506
|
+
charts: [
|
|
3507
|
+
{ title: 'System Health', chartType: 'gauge dial', data: [{ score: 94 }], options: { min: 0, max: 100 } },
|
|
3508
|
+
{ title: 'Revenue Trend', chartType: 'area', data: [...] }
|
|
3509
|
+
],
|
|
3510
|
+
notes: ['All latency thresholds met', 'Quarterly SLA target achieved']
|
|
3511
|
+
}
|
|
3512
|
+
]
|
|
3513
|
+
};
|
|
3514
|
+
```
|
|
3515
|
+
|
|
3516
|
+
- **Self-contained workbench part:** Embeds cleanly into tabs, split panes, or WorkArea documents alongside diagram modellers and data tables without hijacking global workspace perspective state.
|
|
3517
|
+
- **Catalog-driven composition:** Composes existing `ChartPart`s driven by `CHART_CATALOG` keys alongside `ListPart` note cards. Small gauge charts automatically tile along the top banner.
|
|
3518
|
+
- **Decision Workbench:** Specialized `DecisionWorkbenchPart` reads SADIS analysis reports and optimization plans (`viewsFrom(doc)`), projecting trade-off curves, capability radars, and decision matrices directly into tabs.
|
|
3519
|
+
|
|
3352
3520
|
## Where to go next
|
|
3353
3521
|
|
|
3354
3522
|
- **`ARCHITECTURE.md`** — module layout, the chart-type catalog mechanics, how to add a new chart type.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head><meta charset="utf-8"><title>Decision Workbench</title></head>
|
|
4
|
+
<body style="margin:0;height:100vh;display:flex;font:14px system-ui">
|
|
5
|
+
<!--
|
|
6
|
+
The Decision Workbench: decisionengine's SADIS recommendation, drawn. A thin host, as data-model.html is:
|
|
7
|
+
everything it does is DecisionWorkbenchPart's (reading a workbench file, the calendar) and ViewsPart's
|
|
8
|
+
(a tab per view, its charts and notes, opening a file). Open a workbench file — decisionengine's SADIS
|
|
9
|
+
Explorer downloads one ("Download for the Decision Workbench"). Runs straight off disk.
|
|
10
|
+
-->
|
|
11
|
+
<script src="../dist/renderengine-workspace.umd.js"></script>
|
|
12
|
+
<script>
|
|
13
|
+
const { el, registerBuiltinParts, ChartPart, DecisionWorkbenchPart } = window.RenderEngineWorkspace;
|
|
14
|
+
const ws = document.body.appendChild(el({
|
|
15
|
+
tag: 'ws-workspace',
|
|
16
|
+
attrs: { label: 'Decision Workbench', theme: 'light' },
|
|
17
|
+
style: { flex: '1 1 auto', minHeight: '0' },
|
|
18
|
+
}));
|
|
19
|
+
registerBuiltinParts(ws);
|
|
20
|
+
ws.registerParts([ChartPart, DecisionWorkbenchPart]);
|
|
21
|
+
ws.addParts([{ id: 'workbench', type: 'decision-workbench', title: 'Decision Workbench' }]);
|
|
22
|
+
ws.registerPerspective('Decision Workbench', { type: 'part', partId: 'workbench' });
|
|
23
|
+
ws.activatePerspective('Decision Workbench');
|
|
24
|
+
</script>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peter.prib/renderengine",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.1",
|
|
4
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",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"examples/archimate.html",
|
|
24
24
|
"examples/diagram-editor.html",
|
|
25
25
|
"examples/data-model.html",
|
|
26
|
+
"examples/decision-workbench.html",
|
|
26
27
|
"README.md",
|
|
27
28
|
"documentation/REFERENCE.md",
|
|
28
29
|
"LICENSE"
|
package/src/workspace/index.js
CHANGED
|
@@ -56,6 +56,9 @@ export {
|
|
|
56
56
|
LIGHT_THEME, DARK_THEME, MIDNIGHT_THEME, SEPIA_THEME,
|
|
57
57
|
} from './themes.js';
|
|
58
58
|
export { ChartPart } from './parts/ChartPart.js';
|
|
59
|
+
// Chart views in one part (they use ChartPart, so they sit with it, outside BUILTIN_PARTS).
|
|
60
|
+
export { ViewsPart } from './parts/ViewsPart.js';
|
|
61
|
+
export { DecisionWorkbenchPart } from './parts/DecisionWorkbenchPart.js';
|
|
59
62
|
export { DataModelPart } from './parts/DataModelPart.js';
|
|
60
63
|
export { RequirementsPart } from './parts/RequirementsPart.js';
|
|
61
64
|
export { ModelHosted } from './parts/ModelHosted.js';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DecisionWorkbenchPart — the Decision Workbench as one part: a SADIS workbench document
|
|
3
|
+
* (decisionengine's `{ kind: 'sadis-workbench', report, plan?, before? }`) shown as its views.
|
|
4
|
+
*
|
|
5
|
+
* Everything about showing views — tabs, the chart grid, notes, opening a file, being embeddable in
|
|
6
|
+
* any layout — is `ViewsPart`'s; this adds only what differs for this kind of document: reading it
|
|
7
|
+
* through `DecisionWorkbench` (which turns decisionengine's JSON into views), and the calendar its
|
|
8
|
+
* delivery plan is drawn on (when period 1 starts, how many days a period is). That split is why it
|
|
9
|
+
* is a subclass: a second kind of document is a second subclass, not a flag on one class.
|
|
10
|
+
*
|
|
11
|
+
* Spec: `ViewsPart`'s, plus `document` (a workbench document to show at once), `start` and
|
|
12
|
+
* `periodDays` (the calendar; default the report's as-of time and 14 days).
|
|
13
|
+
*/
|
|
14
|
+
import { ViewsPart } from './ViewsPart.js';
|
|
15
|
+
import { DecisionWorkbench } from '../../charting/decision/DecisionWorkbench.js';
|
|
16
|
+
|
|
17
|
+
export class DecisionWorkbenchPart extends ViewsPart {
|
|
18
|
+
static type = 'decision-workbench';
|
|
19
|
+
|
|
20
|
+
constructor(spec = {}) {
|
|
21
|
+
super({ empty: 'Open a workbench file: decisionengine\'s SADIS Explorer downloads one ("Download for the Decision Workbench").', ...spec });
|
|
22
|
+
this.calendar = { start: spec.start, periodDays: spec.periodDays ?? 14 };
|
|
23
|
+
if (spec.document !== undefined) this.read(spec.document);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
viewsFrom(doc) {
|
|
27
|
+
return DecisionWorkbench.from(doc, this.controlValues()).views().map((view) => view.toJSON());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
controls() {
|
|
31
|
+
const field = { padding: '2px 4px', font: 'inherit' };
|
|
32
|
+
return [
|
|
33
|
+
{ tag: 'label', children: [
|
|
34
|
+
{ tag: 'span', text: 'Period 1 starts ' },
|
|
35
|
+
{ tag: 'input', attrs: { type: 'date', value: this.calendar.start ?? '' }, style: field,
|
|
36
|
+
on: { change: (e) => { this.calendar.start = e.target.value || undefined; this.reread(); } } },
|
|
37
|
+
]},
|
|
38
|
+
{ tag: 'label', children: [
|
|
39
|
+
{ tag: 'span', text: 'Days per period ' },
|
|
40
|
+
{ tag: 'input', attrs: { type: 'number', min: '1', value: String(this.calendar.periodDays) }, style: { ...field, width: '4em' },
|
|
41
|
+
on: { change: (e) => { this.calendar.periodDays = Number(e.target.value) || 14; this.reread(); } } },
|
|
42
|
+
]},
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
controlValues() {
|
|
47
|
+
return { start: this.calendar.start, periodDays: this.calendar.periodDays };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
read(doc) {
|
|
51
|
+
const ok = super.read(doc);
|
|
52
|
+
if (ok) {
|
|
53
|
+
const asOf = doc.report.asOf?.businessTime?.slice(0, 10);
|
|
54
|
+
this.say(`${doc.report.config}: ${doc.report.entries.length} candidates${asOf ? `, as of ${asOf}` : ''}`);
|
|
55
|
+
}
|
|
56
|
+
return ok;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ViewsPart — a set of chart views in one part: a tab per view, each a grid of charts and its notes.
|
|
3
|
+
*
|
|
4
|
+
* A view is plain data — `{ id, title, charts: [{ title, chartType, data, options? }], notes? }`,
|
|
5
|
+
* `chartType` a CHART_CATALOG key — so anything that can say what it wants drawn can be shown here
|
|
6
|
+
* without a page of its own: a report, an analysis, a monitoring summary. The Decision Workbench
|
|
7
|
+
* (`DecisionWorkbenchPart`) is one; any other producer of views is another, by spec or by subclass.
|
|
8
|
+
*
|
|
9
|
+
* Why it is one part, not a perspective per view. A part drops into any layout — a tab, a split, a
|
|
10
|
+
* dashboard cell, a WorkArea document — beside whatever else the host shows; perspectives belong to
|
|
11
|
+
* the whole workspace, so a producer that registered its own would take over the host's. Inside, it
|
|
12
|
+
* composes the parts that already exist (`ChartPart` per chart, `ListPart` for notes) rather than
|
|
13
|
+
* drawing anything itself, so a chart here behaves as a chart anywhere: same sizing, same redraw.
|
|
14
|
+
*
|
|
15
|
+
* Why subclasses read files, not a reader in the spec. A spec stays JSON (the workspace serializes
|
|
16
|
+
* it), so a function cannot ride in it. The base reads a document that already is `{ views: [...] }`;
|
|
17
|
+
* a subclass that reads something else overrides `viewsFrom(doc)` (and `accept`, `controls()`,
|
|
18
|
+
* `controlValues()` for options its reader takes) — one subclass per kind of document.
|
|
19
|
+
*
|
|
20
|
+
* Spec: `{ views?, open?: boolean, empty?: string }`. With `open` (default true) a toolbar opens a
|
|
21
|
+
* file (`accept`); `read(doc)` does the same from code. The views shown are kept in `spec.views`,
|
|
22
|
+
* so a serialized workspace comes back showing them.
|
|
23
|
+
*/
|
|
24
|
+
import { Part } from '../Part.js';
|
|
25
|
+
import { el } from '../dom.js';
|
|
26
|
+
import { ChartPart } from './ChartPart.js';
|
|
27
|
+
import { ListPart } from './ListPart.js';
|
|
28
|
+
import { CHART_CATALOG } from '../../chartCatalog.js';
|
|
29
|
+
|
|
30
|
+
/** Chart categories drawn as small tiles across the top of a view rather than as full charts. */
|
|
31
|
+
const TILE_CATEGORIES = new Set(['gauge']);
|
|
32
|
+
|
|
33
|
+
export class ViewsPart extends Part {
|
|
34
|
+
static type = 'views';
|
|
35
|
+
|
|
36
|
+
/** File types the toolbar offers to open. */
|
|
37
|
+
static accept = '.json';
|
|
38
|
+
|
|
39
|
+
constructor(spec = {}) {
|
|
40
|
+
super(spec);
|
|
41
|
+
this.views = spec.views ?? [];
|
|
42
|
+
this.allowOpen = spec.open !== false;
|
|
43
|
+
this.empty = spec.empty ?? 'Open a file to show its views.';
|
|
44
|
+
this.active = this.views[0]?.id ?? null;
|
|
45
|
+
/** The chart and notes parts made for the views shown, torn down when they change. @private */
|
|
46
|
+
this._children = [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Where each view's charts go: tiles (gauge-like charts) share the top row, the rest take half a
|
|
51
|
+
* row each, or the whole row when a view has one; notes go underneath. Pure: the layout rule,
|
|
52
|
+
* kept apart from the DOM so it can be read and tested on its own.
|
|
53
|
+
* @returns {Array<{id, title, items: Array<{kind: 'chart'|'notes', index?: number, span: number, tile?: boolean}>}>}
|
|
54
|
+
*/
|
|
55
|
+
static layout(views) {
|
|
56
|
+
return views.map((view) => {
|
|
57
|
+
const charts = view.charts ?? [];
|
|
58
|
+
const isTile = (chart) => TILE_CATEGORIES.has(CHART_CATALOG.find((c) => c.key === chart.chartType)?.category);
|
|
59
|
+
const tiles = charts.filter(isTile).length;
|
|
60
|
+
const others = charts.length - tiles;
|
|
61
|
+
const items = charts.map((chart, index) => (isTile(chart)
|
|
62
|
+
? { kind: 'chart', index, tile: true, span: Math.max(1, Math.floor(12 / tiles)) }
|
|
63
|
+
: { kind: 'chart', index, tile: false, span: others === 1 ? 12 : 6 }));
|
|
64
|
+
if (view.notes?.length) items.push({ kind: 'notes', span: 12 });
|
|
65
|
+
return { id: view.id, title: view.title ?? view.id, items };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The views a document holds. The base takes `{ views: [...] }`; a subclass reads its own kind of document. */
|
|
70
|
+
viewsFrom(doc) {
|
|
71
|
+
if (!doc || !Array.isArray(doc.views)) throw new Error('Not a views document: it has no views list');
|
|
72
|
+
return doc.views;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Extra toolbar fields a subclass's reader takes (element specs). */
|
|
76
|
+
controls() { return []; }
|
|
77
|
+
|
|
78
|
+
/** The values of those fields, for `viewsFrom`. */
|
|
79
|
+
controlValues() { return {}; }
|
|
80
|
+
|
|
81
|
+
/** Show the views a document holds; a document that cannot be read is said so, and the views shown stay. */
|
|
82
|
+
read(doc) {
|
|
83
|
+
let views;
|
|
84
|
+
try {
|
|
85
|
+
views = this.viewsFrom(doc);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
this.say(err.message);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
this.document = doc;
|
|
91
|
+
this.show(views);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Show `views`, replacing those shown. */
|
|
96
|
+
show(views) {
|
|
97
|
+
this.views = views;
|
|
98
|
+
this.spec.views = views;
|
|
99
|
+
if (!views.some((v) => v.id === this.active)) this.active = views[0]?.id ?? null;
|
|
100
|
+
if (this.mounted) this.renderViews();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
say(text) {
|
|
104
|
+
if (this._message) this._message.textContent = text;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
render() {
|
|
108
|
+
Object.assign(this.container.style, { display: 'flex', flexDirection: 'column', height: '100%', minHeight: '0' });
|
|
109
|
+
if (this.allowOpen) {
|
|
110
|
+
this.container.appendChild(el({
|
|
111
|
+
cls: 'ws-views-toolbar',
|
|
112
|
+
style: { display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap', padding: '6px 8px' },
|
|
113
|
+
children: [
|
|
114
|
+
{ tag: 'label', children: [
|
|
115
|
+
{ tag: 'span', text: 'Open ' },
|
|
116
|
+
{ tag: 'input', attrs: { type: 'file', accept: this.constructor.accept }, on: { change: (e) => this.openFile(e.target) } },
|
|
117
|
+
]},
|
|
118
|
+
...this.controls(),
|
|
119
|
+
{ tag: 'span', cls: 'ws-views-message', style: { opacity: '0.75' }, ref: (n) => { this._message = n; } },
|
|
120
|
+
],
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
this._tabs = this.container.appendChild(el({ cls: 'ws-views-tabs', style: { display: 'flex', gap: '4px', padding: '0 8px' } }));
|
|
124
|
+
this._body = this.container.appendChild(el({ cls: 'ws-views-body', style: { flex: '1 1 auto', minHeight: '0', overflow: 'auto' } }));
|
|
125
|
+
this.onDispose(() => this.clearChildren());
|
|
126
|
+
this.renderViews();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async openFile(input) {
|
|
130
|
+
const file = input.files?.[0];
|
|
131
|
+
if (!file) return;
|
|
132
|
+
let doc;
|
|
133
|
+
try {
|
|
134
|
+
doc = JSON.parse(await file.text());
|
|
135
|
+
} catch (err) {
|
|
136
|
+
this.say(`Not JSON: ${err.message}`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.read(doc);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Re-read the document shown, as when a control's value changes. */
|
|
143
|
+
reread() {
|
|
144
|
+
if (this.document !== undefined) this.read(this.document);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
clearChildren() {
|
|
148
|
+
for (const part of this._children) part.dispose?.();
|
|
149
|
+
this._children = [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
renderViews() {
|
|
153
|
+
this.clearChildren();
|
|
154
|
+
this._tabs.textContent = '';
|
|
155
|
+
this._body.textContent = '';
|
|
156
|
+
if (!this.views.length) {
|
|
157
|
+
this._body.appendChild(el({ tag: 'p', cls: 'ws-views-empty', style: { padding: '12px', opacity: '0.75' }, text: this.empty }));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
for (const view of ViewsPart.layout(this.views)) {
|
|
161
|
+
this._tabs.appendChild(el({
|
|
162
|
+
tag: 'button', attrs: { type: 'button' }, cls: view.id === this.active ? 'ws-views-tab active' : 'ws-views-tab',
|
|
163
|
+
style: { fontWeight: view.id === this.active ? '600' : '400' },
|
|
164
|
+
text: view.title, on: { click: () => { this.active = view.id; this.renderViews(); } },
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
const view = this.views.find((v) => v.id === this.active);
|
|
168
|
+
const placed = ViewsPart.layout([view])[0];
|
|
169
|
+
const grid = this._body.appendChild(el({
|
|
170
|
+
cls: 'ws-views-grid',
|
|
171
|
+
style: { display: 'grid', gridTemplateColumns: 'repeat(12, 1fr)', gap: '8px', padding: '8px' },
|
|
172
|
+
}));
|
|
173
|
+
for (const item of placed.items) {
|
|
174
|
+
// A cell is a part host, as a layout's pane is (`.ws-part-host`: flex, the part fills it), so a
|
|
175
|
+
// chart here sizes to its cell exactly as it would to a pane; only the cell's height is its own.
|
|
176
|
+
const cell = grid.appendChild(el({
|
|
177
|
+
cls: 'ws-views-cell ws-part-host',
|
|
178
|
+
style: { gridColumn: `span ${item.span}`, height: item.kind === 'chart' ? (item.tile ? '130px' : '380px') : 'auto', minWidth: '0' },
|
|
179
|
+
}));
|
|
180
|
+
const part = item.kind === 'chart'
|
|
181
|
+
? new ChartPart(this.chartSpec(view, item.index))
|
|
182
|
+
: new ListPart({ id: `${this.id}-${view.id}-notes`, title: 'Notes', channel: `${this.id}:notes:${view.id}`, items: view.notes });
|
|
183
|
+
part.mount(cell, this.ctx);
|
|
184
|
+
this._children.push(part);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The ChartPart spec for a view's chart. */
|
|
189
|
+
chartSpec(view, index) {
|
|
190
|
+
const chart = view.charts[index];
|
|
191
|
+
return {
|
|
192
|
+
id: `${this.id}-${view.id}-${index}`, title: chart.title, chartType: chart.chartType, rendererType: 'svg',
|
|
193
|
+
options: { ...chart.options, title: chart.title, data: chart.data },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|