@peter.prib/renderengine 0.15.1 → 0.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/renderengine-workspace.umd.js +6485 -3992
  2. package/dist/renderengine.umd.js +6251 -3877
  3. package/documentation/REFERENCE.md +384 -28
  4. package/package.json +1 -1
  5. package/src/archimateArchiFormat.js +114 -10
  6. package/src/archimateArchive.js +27 -4
  7. package/src/archimateExchangeFormat.js +12 -4
  8. package/src/archimateSpecializations.js +11 -1
  9. package/src/chartCatalog.js +6 -4
  10. package/src/charting/ArchimateDiagramEditor.js +7 -109
  11. package/src/charting/DiagramTypeRegistry.js +20 -1
  12. package/src/charting/DiagramViewPropertiesDialog.js +54 -10
  13. package/src/charting/Edge.js +22 -0
  14. package/src/charting/Legend.js +1 -2
  15. package/src/charting/Statistics.js +14 -0
  16. package/src/charting/archimate/ArchimateCanvasEditor.js +21 -5
  17. package/src/charting/archimate/ViewGroupTemplate.js +5 -2
  18. package/src/charting/charts/BarChart.js +59 -8
  19. package/src/charting/charts/HexbinChart.js +15 -0
  20. package/src/charting/charts/LollipopChart.js +42 -7
  21. package/src/charting/decision/DecisionWorkbench.js +71 -4
  22. package/src/charting/diagramModel/DiagramJsonFileFormat.js +32 -0
  23. package/src/charting/diagramModel/DiagramModel.js +39 -0
  24. package/src/charting/diagramModel/DiagramView.js +102 -61
  25. package/src/charting/diagramModel/index.js +11 -10
  26. package/src/charting/diagrams/AddGroupToViewCommand.js +35 -0
  27. package/src/charting/diagrams/ArchimateModel.js +53 -13
  28. package/src/charting/diagrams/GraphDiagram3D.js +15 -0
  29. package/src/charting/diagrams/NestInstanceCommand.js +2 -1
  30. package/src/charting/graph/GraphSource.js +72 -0
  31. package/src/charting/modelTree/NavigatorModel.js +22 -8
  32. package/src/charting/report/TemplatedReport.js +27 -377
  33. package/src/charting/report/ViewsReport.js +118 -0
  34. package/src/charting/report/reportWriters.js +496 -0
  35. package/src/charting/templates/C4BoundaryTemplate.js +34 -0
  36. package/src/charting/templates/C4ComponentTemplate.js +30 -0
  37. package/src/charting/templates/C4ContainerTemplate.js +49 -0
  38. package/src/charting/templates/C4PersonTemplate.js +35 -0
  39. package/src/charting/templates/C4SystemTemplate.js +32 -0
  40. package/src/charting/templates/C4Template.js +95 -0
  41. package/src/charting/templates/index.js +6 -0
  42. package/src/charting/templates/templateRegistry.js +77 -0
  43. package/src/compute/ComputeBackend.js +132 -0
  44. package/src/compute/CpuBackend.js +209 -0
  45. package/src/compute/WebGpuBackend.js +630 -0
  46. package/src/compute/index.js +5 -0
  47. package/src/compute/selectBackend.js +85 -0
  48. package/src/index.js +3403 -3370
  49. package/src/kernelExpr.js +80 -5
  50. package/src/workspace/parts/ArchimateEditorPart.js +2 -2
  51. package/src/workspace/parts/DiagramEditorPart.js +146 -131
  52. package/src/workspace/parts/ViewsPart.js +106 -0
package/src/kernelExpr.js CHANGED
@@ -194,13 +194,25 @@ function _parsePrimary(s) {
194
194
  throw new Error(`parseKernelExpr: unexpected '${t.value}' at position ${t.pos}`);
195
195
  }
196
196
 
197
- // ── JavaScript emission ──────────────────────────────────────────────────────
197
+ // ── JavaScript and WGSL emission ─────────────────────────────────────────────
198
198
 
199
- function _emitJs(node) {
199
+ function _wgslNumber(v) {
200
+ if (!Number.isFinite(v)) return '0.0';
201
+ const str = `${v}`;
202
+ return str.includes('.') || str.includes('e') || str.includes('E') ? str : `${str}.0`;
203
+ }
204
+
205
+ /**
206
+ * Emit the AST as a JavaScript scalar expression.
207
+ * @param {object} node
208
+ * @param {(name: string) => string} [ref]
209
+ * @returns {string}
210
+ */
211
+ export function emitJs(node, ref = (name) => name) {
200
212
  const go = (n) => {
201
213
  switch (n.kind) {
202
214
  case 'literal': return `${n.value}`;
203
- case 'param': return n.name;
215
+ case 'param': return ref(n.name);
204
216
  case 'neg': return `(-${go(n.operand)})`;
205
217
  case 'not': return `(${go(n.operand)} === 0 ? 1 : 0)`;
206
218
  case 'cond': return `((${go(n.cond)}) !== 0 ? ${go(n.then)} : ${go(n.else)})`;
@@ -216,12 +228,75 @@ function _emitJs(node) {
216
228
  const args = n.args.map(go).join(', ');
217
229
  return `${FN_NAMES[n.name]}(${args})`;
218
230
  }
219
- default: throw new Error(`_emitJs: unknown node '${n.kind}'`);
231
+ default: throw new Error(`emitJs: unknown node '${n.kind}'`);
232
+ }
233
+ };
234
+ return go(node);
235
+ }
236
+
237
+ /**
238
+ * Emit the AST as a WGSL f32 scalar expression.
239
+ * Comparisons and booleans evaluate to 1.0 or 0.0 via select().
240
+ * @param {object} node
241
+ * @param {(name: string) => string} [ref]
242
+ * @returns {string}
243
+ */
244
+ export function emitWgsl(node, ref = (name) => name) {
245
+ const go = (n) => {
246
+ switch (n.kind) {
247
+ case 'literal': return _wgslNumber(n.value);
248
+ case 'param': return ref(n.name);
249
+ case 'neg': return `(-${go(n.operand)})`;
250
+ case 'not': return `select(0.0, 1.0, ${go(n.operand)} == 0.0)`;
251
+ case 'cond': return `select(${go(n.else)}, ${go(n.then)}, (${go(n.cond)}) != 0.0)`;
252
+ case 'binary': {
253
+ const l = go(n.left), r = go(n.right);
254
+ if (n.op === '&&')
255
+ return `select(0.0, 1.0, ((${l}) != 0.0) && ((${r}) != 0.0))`;
256
+ if (n.op === '||')
257
+ return `select(0.0, 1.0, ((${l}) != 0.0) || ((${r}) != 0.0))`;
258
+ if (['<', '<=', '>', '>=', '==', '!='].includes(n.op))
259
+ return `select(0.0, 1.0, ${l} ${n.op} ${r})`;
260
+ if (n.op === '%')
261
+ return `(${l} - ${r} * trunc(${l} / ${r}))`;
262
+ return `(${l} ${n.op} ${r})`;
263
+ }
264
+ case 'call': {
265
+ const args = n.args.map(go).join(', ');
266
+ return `${n.name}(${args})`;
267
+ }
268
+ default: throw new Error(`emitWgsl: unknown node '${n.kind}'`);
220
269
  }
221
270
  };
222
271
  return go(node);
223
272
  }
224
273
 
274
+ /**
275
+ * Generate a complete WGSL compute shader executing the kernel across arrays.
276
+ * @param {object} node
277
+ * @param {string[]} params
278
+ * @param {object} [opts]
279
+ * @param {number} [opts.workgroupSize=64]
280
+ * @returns {string}
281
+ */
282
+ export function wgslKernelModule(node, params, { workgroupSize = 64 } = {}) {
283
+ const bindings = params.map((p, i) =>
284
+ `@group(0) @binding(${i}) var<storage, read> ${p}_in : array<f32>;`).join('\n');
285
+ const outBinding = params.length;
286
+
287
+ return `${bindings}
288
+ @group(0) @binding(${outBinding}) var<storage, read_write> out_buf : array<f32>;
289
+ @group(0) @binding(${outBinding + 1}) var<uniform> count : vec4<u32>;
290
+
291
+ @compute @workgroup_size(${workgroupSize})
292
+ fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
293
+ let i = gid.x;
294
+ if (i >= count.x) { return; }
295
+ out_buf[i] = ${emitWgsl(node, (name) => `${name}_in[i]`)};
296
+ }
297
+ `;
298
+ }
299
+
225
300
  /**
226
301
  * Compile a formula to a callable JavaScript function — `fn(...paramValues)
227
302
  * -> number`. Built with `new Function` from an expression THIS module
@@ -236,7 +311,7 @@ function _emitJs(node) {
236
311
  export function compileFormula(source, params) {
237
312
  const { ast } = parseKernelExpr(source, params);
238
313
  const __clamp = (x, lo, hi) => Math.min(Math.max(x, lo), hi);
239
- const body = `return ${_emitJs(ast)};`;
314
+ const body = `return ${emitJs(ast)};`;
240
315
  // eslint-disable-next-line no-new-func
241
316
  const fn = new Function('__clamp', ...params, body);
242
317
  return (...values) => fn(__clamp, ...values);
@@ -11,8 +11,8 @@ const DEFAULT_FEATURES = Object.freeze({
11
11
  });
12
12
 
13
13
  /**
14
- * ArchimateEditorPart — the ArchiMate modeller as a workbench part: toolbar,
15
- * model tree, one tab per open view and the canvas, filling its pane.
14
+ * ArchimateEditorPart — legacy GraphDiagram-based ArchiMate editor part.
15
+ * @deprecated Use ArchimateCanvasPart for ArchiMate editing.
16
16
  *
17
17
  * Spec (JSON): `elements`, `relationships`, `views` (as ArchimateModel takes
18
18
  * them), `features` (toolbar controls, see DEFAULT_FEATURES), `analysis`
@@ -1,131 +1,146 @@
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
+ viewSettings: () => view.viewSettings ?? {},
102
+ });
103
+ if (previous) {
104
+ this.editor.showGrid = previous.showGrid;
105
+ this.editor.gridSize = previous.gridSize;
106
+ this.editor.showExportArea = previous.showExportArea;
107
+ this.editor.exportOptions = previous.exportOptions;
108
+ }
109
+ this.editor.initPaletteForDiagramType(palette, model.templateType);
110
+ this.menuBar?.dispose();
111
+ this.menuBar = new MenuBar({
112
+ container: menuBarEl,
113
+ editor: this.editor,
114
+ items: [this.fileMenu(), this.editMenu(), ...this.hostMenus(), ...this.editor.menuBarItems()],
115
+ }).render();
116
+ this.editor.render();
117
+ }
118
+
119
+ /** A diagram's line-style levels, most specific first: the diagram, the model, the tool. */
120
+ edgeStyleLevels(view) {
121
+ return [
122
+ { key: 'view', label: 'Diagram', style: view.edgeStyle },
123
+ { key: 'model', label: 'Model', style: this.model.edgeStyle },
124
+ { key: 'tool', label: 'Tool', style: this.toolEdgeStyle },
125
+ ];
126
+ }
127
+
128
+ /** A diagram's properties: name, documentation and its line-style defaults. */
129
+ showViewProperties(view) {
130
+ return new DiagramViewPropertiesDialog({
131
+ model: this.model,
132
+ view,
133
+ document: this.document,
134
+ cascade: new EdgeStyleCascade(this.edgeStyleLevels(view)),
135
+ // The editor shows the current diagram; a style or appearance change there needs a redraw.
136
+ onStyleChange: () => { if (view === this.model.currentView) this.editor?.render(); },
137
+ onSettingsChange: () => { if (view === this.model.currentView) this.editor?.render(); },
138
+ }).open();
139
+ }
140
+
141
+ dispose() {
142
+ this.menuBar?.dispose();
143
+ this._unsubscribe();
144
+ super.dispose();
145
+ }
146
+ }
@@ -26,6 +26,8 @@ import { el } from '../dom.js';
26
26
  import { ChartPart } from './ChartPart.js';
27
27
  import { ListPart } from './ListPart.js';
28
28
  import { CHART_CATALOG } from '../../chartCatalog.js';
29
+ import { ViewsReport } from '../../charting/report/ViewsReport.js';
30
+ import { downloadBlob } from '../../exportImage.js';
29
31
 
30
32
  /** Chart categories drawn as small tiles across the top of a view rather than as full charts. */
31
33
  const TILE_CATEGORIES = new Set(['gauge']);
@@ -40,6 +42,7 @@ export class ViewsPart extends Part {
40
42
  super(spec);
41
43
  this.views = spec.views ?? [];
42
44
  this.allowOpen = spec.open !== false;
45
+ this.allowExport = spec.export !== false;
43
46
  this.empty = spec.empty ?? 'Open a file to show its views.';
44
47
  this.active = this.views[0]?.id ?? null;
45
48
  /** The chart and notes parts made for the views shown, torn down when they change. @private */
@@ -116,6 +119,27 @@ export class ViewsPart extends Part {
116
119
  { tag: 'input', attrs: { type: 'file', accept: this.constructor.accept }, on: { change: (e) => this.openFile(e.target) } },
117
120
  ]},
118
121
  ...this.controls(),
122
+ ...(this.allowExport ? [{
123
+ tag: 'span',
124
+ cls: 'ws-views-export-group',
125
+ style: { display: 'inline-flex', alignItems: 'center', gap: '4px' },
126
+ children: [
127
+ { tag: 'button', attrs: { type: 'button' }, cls: 'ws-views-export-btn', text: 'Export report', on: { click: () => this.exportReport(this._exportFormatSelect?.value || 'html') } },
128
+ {
129
+ tag: 'select',
130
+ cls: 'ws-views-export-format',
131
+ style: { padding: '2px 4px', fontSize: '12px' },
132
+ ref: (s) => { this._exportFormatSelect = s; },
133
+ children: [
134
+ { tag: 'option', attrs: { value: 'html' }, text: 'HTML' },
135
+ { tag: 'option', attrs: { value: 'word' }, text: 'Word' },
136
+ { tag: 'option', attrs: { value: 'excel' }, text: 'Excel' },
137
+ { tag: 'option', attrs: { value: 'markdown' }, text: 'Markdown' },
138
+ { tag: 'option', attrs: { value: 'csv' }, text: 'CSV' },
139
+ ],
140
+ },
141
+ ],
142
+ }] : []),
119
143
  { tag: 'span', cls: 'ws-views-message', style: { opacity: '0.75' }, ref: (n) => { this._message = n; } },
120
144
  ],
121
145
  }));
@@ -193,4 +217,86 @@ export class ViewsPart extends Part {
193
217
  options: { ...chart.options, title: chart.title, data: chart.data },
194
218
  };
195
219
  }
220
+
221
+ /** Collect SVG markup of currently mounted/drawn charts. */
222
+ collectPictures() {
223
+ const pictures = {};
224
+ const activeView = this.views.find((v) => v.id === this.active);
225
+ if (!activeView) return pictures;
226
+ for (let i = 0; i < this._children.length; i++) {
227
+ const child = this._children[i];
228
+ if (child instanceof ChartPart) {
229
+ const svg = child.canvasHost?.querySelector?.('svg')?.outerHTML
230
+ || child.engine?.renderer?.svg?.outerHTML
231
+ || '';
232
+ if (svg) {
233
+ pictures[`${activeView.id}:${i}`] = svg;
234
+ const chart = activeView.charts?.[i];
235
+ if (chart?.title) pictures[chart.title] = svg;
236
+ }
237
+ }
238
+ }
239
+ return pictures;
240
+ }
241
+
242
+ /** Create a ViewsReport for this part's views. */
243
+ createReport(options = {}) {
244
+ const pictures = { ...this.collectPictures(), ...(options.pictures || {}) };
245
+ const title = options.title || this.spec.title || (typeof this.document === 'object' ? (this.document.title || this.document.name || this.document.report?.config) : null) || 'Views Report';
246
+ return new ViewsReport(this.views, { title, pictures, ...options });
247
+ }
248
+
249
+ /** Export report to HTML, Word, Excel, Markdown or CSV, and trigger download when in DOM. */
250
+ exportReport(format = 'html', options = {}) {
251
+ const report = this.createReport(options);
252
+ const baseName = (report.title || 'views-report').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'report';
253
+ let data = '';
254
+ let mimeType = 'text/plain';
255
+ let fileName = `${baseName}.txt`;
256
+
257
+ switch (format.toLowerCase()) {
258
+ case 'word':
259
+ case 'doc':
260
+ case 'docx':
261
+ data = report.toWord();
262
+ mimeType = 'application/msword';
263
+ fileName = `${baseName}.doc`;
264
+ break;
265
+ case 'excel':
266
+ case 'xls':
267
+ case 'xlsx':
268
+ data = report.toExcel();
269
+ mimeType = 'application/vnd.ms-excel';
270
+ fileName = `${baseName}.xml`;
271
+ break;
272
+ case 'csv':
273
+ data = report.toCsv(options.table || 'views');
274
+ mimeType = 'text/csv';
275
+ fileName = `${baseName}-${options.table || 'views'}.csv`;
276
+ break;
277
+ case 'markdown':
278
+ case 'md':
279
+ data = report.toMarkdown();
280
+ mimeType = 'text/markdown';
281
+ fileName = `${baseName}.md`;
282
+ break;
283
+ case 'html':
284
+ default:
285
+ data = report.toHtml(options);
286
+ mimeType = 'text/html';
287
+ fileName = `${baseName}.html`;
288
+ break;
289
+ }
290
+
291
+ const doc = this.container?.ownerDocument ?? globalThis.document;
292
+ const BlobClass = doc?.defaultView?.Blob ?? globalThis.Blob;
293
+ if (BlobClass && typeof downloadBlob === 'function') {
294
+ try {
295
+ downloadBlob(new BlobClass([data], { type: mimeType }), fileName);
296
+ } catch {
297
+ // Ignore download failure in headless / mock test environments
298
+ }
299
+ }
300
+ return { data, mimeType, fileName };
301
+ }
196
302
  }