@peter.prib/renderengine 0.15.0 → 0.15.2

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.
@@ -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
+ }
@@ -1,131 +1,144 @@
1
- import { render } from '../dom.js';
2
- import { EditorShell } from '../EditorShell.js';
3
- import { EditorPart } from './EditorPart.js';
4
- import { DiagramEditor } from '../../charting/DiagramEditor.js';
5
- import { MenuBar } from '../../charting/MenuBar.js';
6
- import { DiagramModel } from '../../charting/diagramModel/index.js';
7
- import { LocalJsonStore } from '../../charting/storage/LocalJsonStore.js';
8
- import { EdgeStyleCascade } from '../../charting/edgeStyle/index.js';
9
- import { DiagramViewPropertiesDialog } from '../../charting/DiagramViewPropertiesDialog.js';
10
-
11
- const SVG_NS = 'http://www.w3.org/2000/svg';
12
-
13
- /**
14
- * DiagramEditorPart — a complete diagram editor as a workspace part: menu bar,
15
- * palette, one tab per diagram and the canvas, over a DiagramModel.
16
- *
17
- * Spec (JSON): `templateType` (palette category), `tabOrientation`
18
- * ('horizontal'|'vertical'), `diagramTitle` (first diagram), `toolStyleKey`
19
- * (localStorage key for the tool-level line style; `null` keeps it in memory).
20
- *
21
- * Line styles cascade line → diagram (`view.edgeStyle`) → model
22
- * (`model.edgeStyle`) → tool. Each diagram keeps its own undo history, so undo
23
- * survives switching tabs. The diagrams' menu (new, duplicate, rename, delete)
24
- * comes from EditorPart. Like ChartPart it needs the engine, so it is not in
25
- * BUILTIN_PARTS: register it explicitly.
26
- */
27
- export class DiagramEditorPart extends EditorPart {
28
- static type = 'diagram-editor';
29
- static viewNoun = 'diagram';
30
-
31
- constructor(spec = {}) {
32
- super(spec);
33
- this.tabOrientation = spec.tabOrientation ?? 'horizontal';
34
- // The editor is rebuilt with the canvas on every render, which follows every model change.
35
- this._unsubscribe = this.model.onChange(() => { if (this.mounted) this.render(); });
36
- }
37
-
38
- createModel(spec) {
39
- const store = spec.toolStyleKey === null ? null
40
- : new LocalJsonStore(spec.toolStyleKey ?? 'renderengine.diagramEditor.toolEdgeStyle');
41
- this.toolEdgeStyle = store?.load({}) ?? {};
42
- const model = new DiagramModel({
43
- name: spec.name ?? '',
44
- templateType: spec.templateType ?? 'generic',
45
- // Tool-level style edits go through the diagram's undo history; persist whenever it moves.
46
- historyOptions: { onChange: () => store?.save(this.toolEdgeStyle) },
47
- });
48
- model.createView(spec.diagramTitle ?? 'Diagram 1');
49
- return model;
50
- }
51
-
52
- render() {
53
- const model = this.model;
54
- const view = model.currentView;
55
- const doc = this.document;
56
- const canvasId = `diagram-canvas-${this.id}`;
57
-
58
- const canvas = doc.createElementNS(SVG_NS, 'svg');
59
- canvas.id = canvasId;
60
- canvas.setAttribute('viewBox', '0 0 800 600');
61
- canvas.style.cssText = 'flex: 1; width: 100%; height: 100%; display: block; background: #fff; border: 2px solid #333; box-sizing: border-box;';
62
- const elements = doc.createElementNS(SVG_NS, 'g');
63
- elements.id = 'elements';
64
- canvas.appendChild(elements);
65
-
66
- const tabs = this.viewTabs({ orientation: this.tabOrientation });
67
-
68
- // The shared workbench layout: toolbar, palette at the side, tabs + canvas in the main area.
69
- const { toolbar: menuBarEl, side: palette, main } = new EditorShell({ document: doc, sideWidth: '200px', layout: this.layout, labels: { side: 'Palette' } }).mount(this.container);
70
- palette.classList.add('ws-diagram-palette');
71
- Object.assign(palette.style, { padding: '8px', fontSize: '12px', display: 'flex', flexDirection: 'column', overflowY: 'hidden' });
72
- main.style.flexDirection = this.tabOrientation === 'vertical' ? 'row' : 'column';
73
- render(main, [tabs, { style: { flex: '1', display: 'flex', flexDirection: 'column', minHeight: '0' }, children: [canvas] }], doc);
74
-
75
- // The editor is bound to the view's own maps and history: nothing to save back on switch.
76
- const previous = this.editor;
77
- previous?.dispose();
78
- this.editor = new DiagramEditor({
79
- diagramContainerId: canvasId,
80
- model,
81
- templateType: model.templateType,
82
- onTemplateTypeChange: (type) => model.setTemplateType(type),
83
- templates: view.templates,
84
- edges: view.edges,
85
- history: view.history,
86
- arrangement: view.arrangement,
87
- edgeStyleLevels: this.edgeStyleLevels(view),
88
- });
89
- if (previous) {
90
- this.editor.showGrid = previous.showGrid;
91
- this.editor.gridSize = previous.gridSize;
92
- this.editor.showExportArea = previous.showExportArea;
93
- this.editor.exportOptions = previous.exportOptions;
94
- }
95
- this.editor.initPaletteForDiagramType(palette, model.templateType);
96
- this.menuBar?.dispose();
97
- this.menuBar = new MenuBar({
98
- container: menuBarEl,
99
- editor: this.editor,
100
- items: [this.fileMenu(), this.editMenu(), ...this.hostMenus(), ...this.editor.menuBarItems()],
101
- }).render();
102
- this.editor.render();
103
- }
104
-
105
- /** A diagram's line-style levels, most specific first: the diagram, the model, the tool. */
106
- edgeStyleLevels(view) {
107
- return [
108
- { key: 'view', label: 'Diagram', style: view.edgeStyle },
109
- { key: 'model', label: 'Model', style: this.model.edgeStyle },
110
- { key: 'tool', label: 'Tool', style: this.toolEdgeStyle },
111
- ];
112
- }
113
-
114
- /** A diagram's properties: name, documentation and its line-style defaults. */
115
- showViewProperties(view) {
116
- return new DiagramViewPropertiesDialog({
117
- model: this.model,
118
- view,
119
- document: this.document,
120
- cascade: new EdgeStyleCascade(this.edgeStyleLevels(view)),
121
- // The editor shows the current diagram; a style change there needs a redraw.
122
- onStyleChange: () => { if (view === this.model.currentView) this.editor?.render(); },
123
- }).open();
124
- }
125
-
126
- dispose() {
127
- this.menuBar?.dispose();
128
- this._unsubscribe();
129
- super.dispose();
130
- }
131
- }
1
+ import { render } from '../dom.js';
2
+ import { EditorShell } from '../EditorShell.js';
3
+ import { EditorPart } from './EditorPart.js';
4
+ import { DiagramEditor } from '../../charting/DiagramEditor.js';
5
+ import { MenuBar } from '../../charting/MenuBar.js';
6
+ import { DiagramModel, DiagramJsonFileFormat } from '../../charting/diagramModel/index.js';
7
+ import { LocalJsonStore } from '../../charting/storage/LocalJsonStore.js';
8
+ import { EdgeStyleCascade } from '../../charting/edgeStyle/index.js';
9
+ import { DiagramViewPropertiesDialog } from '../../charting/DiagramViewPropertiesDialog.js';
10
+
11
+ const SVG_NS = 'http://www.w3.org/2000/svg';
12
+
13
+ /**
14
+ * DiagramEditorPart — a complete diagram editor as a workspace part: menu bar,
15
+ * palette, one tab per diagram and the canvas, over a DiagramModel.
16
+ *
17
+ * Spec (JSON): `templateType` (palette category), `tabOrientation`
18
+ * ('horizontal'|'vertical'), `diagramTitle` (first diagram), `toolStyleKey`
19
+ * (localStorage key for the tool-level line style; `null` keeps it in memory).
20
+ *
21
+ * Line styles cascade line → diagram (`view.edgeStyle`) → model
22
+ * (`model.edgeStyle`) → tool. Each diagram keeps its own undo history, so undo
23
+ * survives switching tabs. The diagrams' menu (new, duplicate, rename, delete)
24
+ * comes from EditorPart. Like ChartPart it needs the engine, so it is not in
25
+ * BUILTIN_PARTS: register it explicitly.
26
+ */
27
+ export class DiagramEditorPart extends EditorPart {
28
+ static type = 'diagram-editor';
29
+ static viewNoun = 'diagram';
30
+
31
+ constructor(spec = {}) {
32
+ super(spec);
33
+ this.tabOrientation = spec.tabOrientation ?? 'horizontal';
34
+ // The editor is rebuilt with the canvas on every render, which follows every model change.
35
+ this._unsubscribe = this.model.onChange(() => { if (this.mounted) this.render(); });
36
+ }
37
+
38
+ createModel(spec) {
39
+ if (spec.model instanceof DiagramModel) return spec.model;
40
+ const store = spec.toolStyleKey === null ? null
41
+ : new LocalJsonStore(spec.toolStyleKey ?? 'renderengine.diagramEditor.toolEdgeStyle');
42
+ this.toolEdgeStyle = store?.load({}) ?? {};
43
+ const historyOptions = { onChange: () => store?.save(this.toolEdgeStyle) };
44
+ if (spec.views) {
45
+ return DiagramModel.fromJSON(spec, historyOptions);
46
+ }
47
+ const model = new DiagramModel({
48
+ name: spec.name ?? '',
49
+ templateType: spec.templateType ?? 'generic',
50
+ // Tool-level style edits go through the diagram's undo history; persist whenever it moves.
51
+ historyOptions,
52
+ });
53
+ model.createView(spec.diagramTitle ?? 'Diagram 1');
54
+ return model;
55
+ }
56
+
57
+ fileFormats() {
58
+ return this.spec.fileFormats ? [new DiagramJsonFileFormat()] : [];
59
+ }
60
+
61
+ saveFormat() {
62
+ return this.fileFormats()[0];
63
+ }
64
+
65
+ render() {
66
+ const model = this.model;
67
+ const view = model.currentView;
68
+ const doc = this.document;
69
+ const canvasId = `diagram-canvas-${this.id}`;
70
+
71
+ const canvas = doc.createElementNS(SVG_NS, 'svg');
72
+ canvas.id = canvasId;
73
+ canvas.setAttribute('viewBox', '0 0 800 600');
74
+ canvas.style.cssText = 'flex: 1; width: 100%; height: 100%; display: block; background: #fff; border: 2px solid #333; box-sizing: border-box;';
75
+ const elements = doc.createElementNS(SVG_NS, 'g');
76
+ elements.id = 'elements';
77
+ canvas.appendChild(elements);
78
+
79
+ const tabs = this.viewTabs({ orientation: this.tabOrientation });
80
+
81
+ // The shared workbench layout: toolbar, palette at the side, tabs + canvas in the main area.
82
+ const { toolbar: menuBarEl, side: palette, main } = new EditorShell({ document: doc, sideWidth: '200px', layout: this.layout, labels: { side: 'Palette' } }).mount(this.container);
83
+ palette.classList.add('ws-diagram-palette');
84
+ Object.assign(palette.style, { padding: '8px', fontSize: '12px', display: 'flex', flexDirection: 'column', overflowY: 'hidden' });
85
+ main.style.flexDirection = this.tabOrientation === 'vertical' ? 'row' : 'column';
86
+ render(main, [tabs, { style: { flex: '1', display: 'flex', flexDirection: 'column', minHeight: '0' }, children: [canvas] }], doc);
87
+
88
+ // The editor is bound to the view's own maps and history: nothing to save back on switch.
89
+ const previous = this.editor;
90
+ previous?.dispose();
91
+ this.editor = new DiagramEditor({
92
+ diagramContainerId: canvasId,
93
+ model,
94
+ templateType: model.templateType,
95
+ onTemplateTypeChange: (type) => model.setTemplateType(type),
96
+ templates: view.templates,
97
+ edges: view.edges,
98
+ history: view.history,
99
+ arrangement: view.arrangement,
100
+ edgeStyleLevels: this.edgeStyleLevels(view),
101
+ });
102
+ if (previous) {
103
+ this.editor.showGrid = previous.showGrid;
104
+ this.editor.gridSize = previous.gridSize;
105
+ this.editor.showExportArea = previous.showExportArea;
106
+ this.editor.exportOptions = previous.exportOptions;
107
+ }
108
+ this.editor.initPaletteForDiagramType(palette, model.templateType);
109
+ this.menuBar?.dispose();
110
+ this.menuBar = new MenuBar({
111
+ container: menuBarEl,
112
+ editor: this.editor,
113
+ items: [this.fileMenu(), this.editMenu(), ...this.hostMenus(), ...this.editor.menuBarItems()],
114
+ }).render();
115
+ this.editor.render();
116
+ }
117
+
118
+ /** A diagram's line-style levels, most specific first: the diagram, the model, the tool. */
119
+ edgeStyleLevels(view) {
120
+ return [
121
+ { key: 'view', label: 'Diagram', style: view.edgeStyle },
122
+ { key: 'model', label: 'Model', style: this.model.edgeStyle },
123
+ { key: 'tool', label: 'Tool', style: this.toolEdgeStyle },
124
+ ];
125
+ }
126
+
127
+ /** A diagram's properties: name, documentation and its line-style defaults. */
128
+ showViewProperties(view) {
129
+ return new DiagramViewPropertiesDialog({
130
+ model: this.model,
131
+ view,
132
+ document: this.document,
133
+ cascade: new EdgeStyleCascade(this.edgeStyleLevels(view)),
134
+ // The editor shows the current diagram; a style change there needs a redraw.
135
+ onStyleChange: () => { if (view === this.model.currentView) this.editor?.render(); },
136
+ }).open();
137
+ }
138
+
139
+ dispose() {
140
+ this.menuBar?.dispose();
141
+ this._unsubscribe();
142
+ super.dispose();
143
+ }
144
+ }
@@ -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
+ }