@peter.prib/renderengine 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/renderengine-workspace.umd.js +6030 -3766
  2. package/dist/renderengine.umd.js +5930 -3772
  3. package/documentation/REFERENCE.md +37 -3
  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 +5 -108
  11. package/src/charting/DiagramTypeRegistry.js +20 -1
  12. package/src/charting/DiagramViewPropertiesDialog.js +54 -10
  13. package/src/charting/Legend.js +1 -2
  14. package/src/charting/Statistics.js +14 -0
  15. package/src/charting/charts/BarChart.js +59 -8
  16. package/src/charting/charts/HexbinChart.js +15 -0
  17. package/src/charting/charts/LollipopChart.js +42 -7
  18. package/src/charting/decision/DecisionWorkbench.js +137 -4
  19. package/src/charting/diagramModel/DiagramView.js +7 -2
  20. package/src/charting/diagrams/GraphDiagram3D.js +15 -0
  21. package/src/charting/graph/GraphSource.js +72 -0
  22. package/src/charting/modelTree/NavigatorModel.js +22 -8
  23. package/src/charting/report/TemplatedReport.js +27 -377
  24. package/src/charting/report/ViewsReport.js +118 -0
  25. package/src/charting/report/reportWriters.js +496 -0
  26. package/src/charting/templates/C4BoundaryTemplate.js +34 -0
  27. package/src/charting/templates/C4ComponentTemplate.js +30 -0
  28. package/src/charting/templates/C4ContainerTemplate.js +49 -0
  29. package/src/charting/templates/C4PersonTemplate.js +35 -0
  30. package/src/charting/templates/C4SystemTemplate.js +32 -0
  31. package/src/charting/templates/C4Template.js +95 -0
  32. package/src/charting/templates/index.js +6 -0
  33. package/src/charting/templates/templateRegistry.js +7 -0
  34. package/src/compute/ComputeBackend.js +132 -0
  35. package/src/compute/CpuBackend.js +209 -0
  36. package/src/compute/WebGpuBackend.js +630 -0
  37. package/src/compute/index.js +5 -0
  38. package/src/compute/selectBackend.js +85 -0
  39. package/src/index.js +3403 -3371
  40. package/src/kernelExpr.js +80 -5
  41. package/src/workspace/parts/DiagramEditorPart.js +3 -1
  42. package/src/workspace/parts/ViewsPart.js +106 -0
@@ -2,26 +2,33 @@ import { ViewPropertiesDialog } from './ViewPropertiesDialog.js';
2
2
  import { inheritableFields, readInheritableFields } from './SettingsCascade.js';
3
3
  import { EDGE_STYLE_PROPERTIES } from './edgeStyle/EdgeStyleSchema.js';
4
4
  import { SetStyleCommand } from './edgeStyle/SetStyleCommand.js';
5
+ import { CompositeCommand } from './CommandHistory.js';
6
+
7
+ const TITLE_POSITIONS = [['none', 'None'], ['top', 'Top'], ['bottom', 'Bottom']];
8
+ const WATERMARK_ROTATIONS = [['0°', 'Horizontal (0°)'], ['-45°', 'Diagonal (-45°)'], ['-90°', 'Vertical (-90°)']];
9
+ const WATERMARK_REPEATS = [['none', 'Center (single)'], ['repeat', 'Repeated grid'], ['tile', 'Tiled pattern']];
5
10
 
6
11
  /**
7
12
  * DiagramViewPropertiesDialog — a template-editor diagram's properties plus
8
- * its line-style defaults: the diagram level of the line-style cascade
9
- * (diagram → model → tool), each property inheriting unless set. The style
10
- * change is one step on the model's undo history. Used by
11
- * DiagramEditorPart.
13
+ * its line-style defaults and visual appearance (background, title band, watermark):
14
+ * the diagram level of the line-style cascade (diagram → model → tool) and
15
+ * the diagram appearance settings. Changes are undoable on the diagram's history.
16
+ * Used by DiagramEditorPart.
12
17
  */
13
18
  export class DiagramViewPropertiesDialog extends ViewPropertiesDialog {
14
19
  /**
15
20
  * @param {Object} options - As ViewPropertiesDialog, plus:
16
21
  * @param {EdgeStyleCascade} options.cascade - The diagram's line-style levels, the diagram's own keyed `levelKey`
17
22
  * @param {string} [options.levelKey] - Which level is this diagram's
18
- * @param {() => void} [options.onStyleChange] - After the style changes (e.g. redraw)
23
+ * @param {() => void} [options.onStyleChange] - After the line style changes
24
+ * @param {() => void} [options.onSettingsChange] - After the appearance settings change
19
25
  */
20
- constructor({ cascade, levelKey = 'view', onStyleChange = () => {}, ...rest }) {
26
+ constructor({ cascade, levelKey = 'view', onStyleChange = () => {}, onSettingsChange = () => {}, ...rest }) {
21
27
  super(rest);
22
28
  this.cascade = cascade;
23
29
  this.levelKey = levelKey;
24
30
  this.onStyleChange = onStyleChange;
31
+ this.onSettingsChange = onSettingsChange;
25
32
  }
26
33
 
27
34
  /** The diagram's own line style (its level's style object). */
@@ -30,17 +37,54 @@ export class DiagramViewPropertiesDialog extends ViewPropertiesDialog {
30
37
  }
31
38
 
32
39
  sections() {
40
+ const ownSettings = this.view.viewSettings ?? {};
33
41
  return [
34
42
  { type: 'heading', label: 'Line style' },
35
43
  ...inheritableFields(EDGE_STYLE_PROPERTIES, this.style, this.cascade.inheritedBy(this.levelKey)),
44
+ { type: 'heading', label: 'Appearance' },
45
+ { key: 'background', label: 'Background', type: 'color', value: ownSettings.background || '#ffffff' },
46
+ { key: 'backgroundImage', label: 'Background image', type: 'text', value: ownSettings.backgroundImage ?? '', placeholder: 'URL (optional)' },
47
+ { key: 'bandTitle', label: 'Title band', type: 'text', value: ownSettings.title ?? '', placeholder: 'Text shown above or below the diagram' },
48
+ { key: 'titlePosition', label: 'Title position', type: 'select', value: ownSettings.titlePosition || 'none', choices: TITLE_POSITIONS },
49
+ { key: 'watermark', label: 'Watermark', type: 'text', value: ownSettings.watermark ?? '', placeholder: 'CONFIDENTIAL, DRAFT, ...' },
50
+ { key: 'watermarkRotation', label: 'Watermark rotation', type: 'select', value: ownSettings.watermarkRotation || '-45°', choices: WATERMARK_ROTATIONS },
51
+ { key: 'watermarkOpacity', label: 'Watermark opacity', type: 'number', min: 0, max: 100, step: 5, value: ownSettings.watermarkOpacity ?? 20 },
52
+ { key: 'watermarkRepeat', label: 'Watermark repeat', type: 'select', value: ownSettings.watermarkRepeat || 'none', choices: WATERMARK_REPEATS },
36
53
  ];
37
54
  }
38
55
 
39
56
  applySections(values) {
57
+ // Line style
40
58
  const patch = readInheritableFields(EDGE_STYLE_PROPERTIES, values);
41
- const changed = Object.entries(patch).some(([k, v]) => this.style[k] !== v);
42
- if (!changed) return;
43
- this.view.history.execute(new SetStyleCommand(this.style, patch, 'Diagram line style'));
44
- this.onStyleChange();
59
+ const styleChanged = Object.entries(patch).some(([k, v]) => this.style[k] !== v);
60
+
61
+ // Appearance
62
+ const ownSettings = this.view.viewSettings ?? (this.view.viewSettings = {});
63
+ const appearancePatch = {};
64
+ if ('background' in values) appearancePatch.background = values.background;
65
+ if ('backgroundImage' in values) appearancePatch.backgroundImage = values.backgroundImage?.trim() || undefined;
66
+ if ('bandTitle' in values) appearancePatch.title = values.bandTitle?.trim() || undefined;
67
+ if ('titlePosition' in values) appearancePatch.titlePosition = values.titlePosition && values.titlePosition !== 'none' ? values.titlePosition : undefined;
68
+ if ('watermark' in values) appearancePatch.watermark = values.watermark?.trim() || undefined;
69
+ if ('watermarkRotation' in values) appearancePatch.watermarkRotation = values.watermarkRotation && values.watermarkRotation !== '-45°' ? values.watermarkRotation : undefined;
70
+ if ('watermarkOpacity' in values) appearancePatch.watermarkOpacity = values.watermarkOpacity != null && Number(values.watermarkOpacity) !== 20 ? Number(values.watermarkOpacity) : undefined;
71
+ if ('watermarkRepeat' in values) appearancePatch.watermarkRepeat = values.watermarkRepeat && values.watermarkRepeat !== 'none' ? values.watermarkRepeat : undefined;
72
+
73
+ const appearanceChanged = Object.entries(appearancePatch).some(([k, v]) => ownSettings[k] !== v);
74
+
75
+ if (styleChanged && appearanceChanged) {
76
+ this.view.history.execute(new CompositeCommand('Diagram properties', [
77
+ new SetStyleCommand(this.style, patch, 'Diagram line style'),
78
+ new SetStyleCommand(ownSettings, appearancePatch, 'Diagram appearance'),
79
+ ]));
80
+ this.onStyleChange();
81
+ this.onSettingsChange();
82
+ } else if (styleChanged) {
83
+ this.view.history.execute(new SetStyleCommand(this.style, patch, 'Diagram line style'));
84
+ this.onStyleChange();
85
+ } else if (appearanceChanged) {
86
+ this.view.history.execute(new SetStyleCommand(ownSettings, appearancePatch, 'Diagram appearance'));
87
+ this.onSettingsChange();
88
+ }
45
89
  }
46
90
  }
@@ -90,9 +90,8 @@ export class Legend {
90
90
  const hidden = !!item.hidden;
91
91
  const color = hidden ? '#ccc' : item.color;
92
92
  const textColor = hidden ? '#ccc' : '#333';
93
- const attrs = {};
93
+ const attrs = { legendItem: true };
94
94
  if (item.dataIndex !== undefined) {
95
- attrs.legendItem = true;
96
95
  attrs.dataIndex = item.dataIndex;
97
96
  }
98
97
 
@@ -131,4 +131,18 @@ export class Statistics {
131
131
  }
132
132
  return points;
133
133
  }
134
+
135
+ /**
136
+ * Asynchronous Gaussian-kernel density estimate, using GPU compute backend when available.
137
+ * @param {number[]} values
138
+ * @param {object} [options]
139
+ * @param {import('../compute/ComputeBackend.js').ComputeBackend} [options.backend]
140
+ * @returns {Promise<Array<{x: number, density: number}>>}
141
+ */
142
+ static async kernelDensityEstimateAsync(values, options = {}) {
143
+ if (options.backend) {
144
+ return options.backend.kernelDensityEstimateAsync(values, options);
145
+ }
146
+ return Statistics.kernelDensityEstimate(values, options);
147
+ }
134
148
  }
@@ -3,6 +3,7 @@ import { Legend } from '../Legend.js';
3
3
  import { DataFilter } from '../DataFilter.js';
4
4
  import { BarChartBase } from './BarChartBase.js';
5
5
  import { Annotations } from '../Annotations.js';
6
+ import { ChartData } from '../ChartData.js';
6
7
 
7
8
  /**
8
9
  * BarChart — vertical bar chart with optional legend and hidden items.
@@ -15,6 +16,32 @@ export class BarChart extends BarChartBase {
15
16
  this._dataFilter = dataFilter;
16
17
  }
17
18
 
19
+ _setup() {
20
+ const data = this.data;
21
+ const options = this.options;
22
+ this._catField = options.colorCol
23
+ ?? (Array.isArray(data) && data.some(d => d?.category != null) ? 'category'
24
+ : Array.isArray(data) && data.some(d => d?.group != null) ? 'group' : null);
25
+ super._setup();
26
+ this._catColors = new Map();
27
+ if (this._catField && Array.isArray(data)) {
28
+ for (const d of data) {
29
+ const cat = ChartData.readColorCol(d, this._catField);
30
+ if (cat != null && !this._catColors.has(cat)) {
31
+ this._catColors.set(cat, this._palette.colorAt(this._catColors.size));
32
+ }
33
+ }
34
+ }
35
+ const showCatLegend = Boolean(this._catField && this._catColors.size > 0 && options.legend !== false && options.showLegend !== false);
36
+ if (showCatLegend && options.width === undefined) {
37
+ const legendItems = [...this._catColors].map(([cat, color]) => ({ label: String(cat), color }));
38
+ this._plotBox.width = Legend.plotWidthBeside(this._plotBox, this.renderer.width, {
39
+ items: legendItems,
40
+ itemHeight: 20, markerSize: 10,
41
+ });
42
+ }
43
+ }
44
+
18
45
  _computeMaxValue() {
19
46
  const data = this.data;
20
47
  const rawMax = Math.max(...data.map(d => d.value)) || 1;
@@ -42,16 +69,27 @@ export class BarChart extends BarChartBase {
42
69
  const { bandWidth, margin } = this._bandScale(data.length);
43
70
  const textAnchor = this.renderer.centerTextAnchor;
44
71
 
72
+ const colorFor = (d, i) => {
73
+ if (this._catField) {
74
+ const cat = ChartData.readColorCol(d, this._catField);
75
+ if (cat != null && this._catColors.has(cat)) {
76
+ return this._catColors.get(cat);
77
+ }
78
+ }
79
+ return colors ? this._palette.colorAt(i) : barColor;
80
+ };
81
+
45
82
  if (this._isHorizontal()) {
46
83
  data.forEach((d, i) => {
47
84
  if (this._dataFilter.has(i)) return;
48
- const color = colors ? this._palette.colorAt(i) : barColor;
85
+ const color = colorFor(d, i);
49
86
  const bar = this._barSpan(d.value);
50
87
  const barY = y + i * (bandWidth + margin);
88
+ const series = this._catField ? ChartData.readColorCol(d, this._catField) : null;
51
89
 
52
90
  this._datum(this.renderer.rect(bar.top, barY, bar.height, bandWidth, color, null, {
53
91
  bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}`,
54
- }), { label: d.label });
92
+ }), { label: d.label, series: series != null ? String(series) : null });
55
93
 
56
94
  // Category label on left
57
95
  this.renderer.text(d.label, x - 8, barY + bandWidth / 2, {
@@ -73,11 +111,12 @@ export class BarChart extends BarChartBase {
73
111
  return; // Skip rendering the bar if it's hidden
74
112
  }
75
113
 
76
- const color = colors ? this._palette.colorAt(i) : barColor;
114
+ const color = colorFor(d, i);
77
115
  const bar = this._barSpan(d.value);
78
116
  const barX = x + i * (bandWidth + margin);
117
+ const series = this._catField ? ChartData.readColorCol(d, this._catField) : null;
79
118
 
80
- this._datum(this.renderer.rect(barX, bar.top, bandWidth, bar.height, color, null, { bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}` }), { label: d.label });
119
+ this._datum(this.renderer.rect(barX, bar.top, bandWidth, bar.height, color, null, { bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}` }), { label: d.label, series: series != null ? String(series) : null });
81
120
  // The value just past the bar's end: above a bar going up, below one going down.
82
121
  this.renderer.text(String(d.value), barX + bandWidth / 2, d.value < 0 ? bar.end + 12 : bar.end - 5, { textAnchor, fontSize: '10px', fill: '#333' });
83
122
  if (i % labelStep === 0) this.renderer.text(d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
@@ -88,9 +127,19 @@ export class BarChart extends BarChartBase {
88
127
  const data = this.data;
89
128
  const options = this.options;
90
129
  const { x, y, width } = this._plotBox;
91
- const { colors, showLegend = false } = options;
130
+ const colors = options.colors;
131
+ const showCatLegend = Boolean(this._catField && this._catColors.size > 0 && options.legend !== false && options.showLegend !== false);
92
132
 
93
- if (showLegend && colors) {
133
+ if (showCatLegend) {
134
+ const legendItems = [...this._catColors].map(([cat, color]) => ({
135
+ label: String(cat),
136
+ color,
137
+ }));
138
+ Legend.beside({ x, y, width }, this.renderer.width, {
139
+ items: legendItems,
140
+ itemHeight: 20, markerSize: 10,
141
+ }).draw(this.renderer);
142
+ } else if ((options.showLegend || options.legend) && colors) {
94
143
  const legendItems = data.map((d, i) => ({
95
144
  label: d.label,
96
145
  color: this._palette.colorAt(i),
@@ -111,7 +160,9 @@ export class BarChart extends BarChartBase {
111
160
  */
112
161
  onClick(event, hitShape) {
113
162
  if (!hitShape || !hitShape.legendItem) return;
114
- this._dataFilter.toggle(hitShape.dataIndex);
115
- this.render();
163
+ if (hitShape.dataIndex != null) {
164
+ this._dataFilter.toggle(hitShape.dataIndex);
165
+ this.render();
166
+ }
116
167
  }
117
168
  }
@@ -72,6 +72,21 @@ export class HexbinChart extends Chart {
72
72
  return bins;
73
73
  }
74
74
 
75
+ /**
76
+ * Asynchronous 2D hexagonal binning, offloading to GPU when backend provided.
77
+ * @param {Array<{px: number, py: number, [key: string]: any}>} points
78
+ * @param {number} r
79
+ * @param {object} [options]
80
+ * @param {import('../../compute/ComputeBackend.js').ComputeBackend} [options.backend]
81
+ * @returns {Promise<Map<string, {cx: number, cy: number, count: number, points: Array}>>}
82
+ */
83
+ static async binPointsAsync(points, r, options = {}) {
84
+ if (options.backend) {
85
+ return options.backend.binHexbinsAsync(points, r, options);
86
+ }
87
+ return HexbinChart.binPoints(points, r);
88
+ }
89
+
75
90
  _render() {
76
91
  const data = this.data;
77
92
  if (!Array.isArray(data) || !data.length) return;
@@ -1,6 +1,7 @@
1
1
  import { Axis } from '../Axis.js';
2
2
  import { Legend } from '../Legend.js';
3
3
  import { BarChartBase } from './BarChartBase.js';
4
+ import { ChartData } from '../ChartData.js';
4
5
 
5
6
  /**
6
7
  * LollipopChart — stem-and-dot alternative to a bar chart for high-cardinality or multi-series
@@ -29,13 +30,34 @@ export class LollipopChart extends BarChartBase {
29
30
  _setup() {
30
31
  this._norm = LollipopChart._normalize(this.data);
31
32
  super._setup();
32
- const showLegend = this.options.legend ?? this._norm.isMulti;
33
- if (showLegend && this._norm.isMulti && this.options.width === undefined) {
33
+ this._catField = !this._norm.isMulti
34
+ ? (this.options.colorCol
35
+ ?? (Array.isArray(this.data) && this.data.some(d => d?.category != null) ? 'category'
36
+ : Array.isArray(this.data) && this.data.some(d => d?.group != null) ? 'group' : null))
37
+ : null;
38
+ this._catColors = new Map();
39
+ if (this._catField && Array.isArray(this.data)) {
40
+ for (const d of this.data) {
41
+ const cat = ChartData.readColorCol(d, this._catField);
42
+ if (cat != null && !this._catColors.has(cat)) {
43
+ this._catColors.set(cat, this._palette.colorAt(this._catColors.size));
44
+ }
45
+ }
46
+ }
47
+ const hasCatLegend = Boolean(this._catField && this._catColors.size > 0);
48
+ const showLegend = this.options.legend ?? (this._norm.isMulti || hasCatLegend);
49
+ if (showLegend && (this._norm.isMulti || hasCatLegend) && this.options.width === undefined) {
34
50
  this._plotBox.width = Legend.plotWidthBeside(this._plotBox, this.renderer.width, this._legendOptions());
35
51
  }
36
52
  }
37
53
 
38
54
  _legendOptions() {
55
+ if (this._catField && this._catColors.size > 0) {
56
+ return {
57
+ items: [...this._catColors].map(([cat, color]) => ({ label: String(cat), color })),
58
+ itemHeight: 20, markerSize: 10, marker: 'circle',
59
+ };
60
+ }
39
61
  return {
40
62
  items: this._norm.series.map((s, i) => ({ label: s.name, color: this._palette.colorAt(i), dataIndex: i })),
41
63
  itemHeight: 20, markerSize: 10,
@@ -74,18 +96,30 @@ export class LollipopChart extends BarChartBase {
74
96
  ? bandX + bandWidth / 2
75
97
  : bandX + ((sIdx + 0.5) / seriesCount) * bandWidth;
76
98
  const span = this._barSpan(pt.value);
77
- const col = isMulti || colors ? this._palette.colorAt(sIdx) : singleColor;
99
+
100
+ let col;
101
+ let catVal = null;
102
+ if (!isMulti && this._catField) {
103
+ catVal = ChartData.readColorCol(pt, this._catField);
104
+ col = catVal != null && this._catColors.has(catVal)
105
+ ? this._catColors.get(catVal)
106
+ : (colors ? this._palette.colorAt(i) : singleColor);
107
+ } else {
108
+ col = isMulti || colors ? this._palette.colorAt(sIdx) : singleColor;
109
+ }
110
+
78
111
  const tip = isMulti ? `${s.name} - ${label}: ${pt.value}` : `${label}: ${pt.value}`;
79
112
 
80
113
  this.renderer.path(`M${cx},${zeroY} L${cx},${span.end}`, 'none', { color: col, width: stemWidth });
114
+ const datumSeries = isMulti ? s.name : (catVal != null ? String(catVal) : null);
81
115
  if (typeof this.renderer.circle === 'function') {
82
116
  this._datum(this.renderer.circle(cx, span.end, radius, col, { color: '#fff', width: 1.5 }, {
83
117
  tooltip: tip,
84
- }), { label, series: isMulti ? s.name : null });
118
+ }), { label, series: datumSeries });
85
119
  } else {
86
120
  this._datum(this.renderer.rect(cx - radius, span.end - radius, radius * 2, radius * 2, col, null, {
87
121
  tooltip: tip,
88
- }), { label, series: isMulti ? s.name : null });
122
+ }), { label, series: datumSeries });
89
123
  }
90
124
  });
91
125
  if (i % labelStep === 0) {
@@ -95,8 +129,9 @@ export class LollipopChart extends BarChartBase {
95
129
  }
96
130
 
97
131
  _afterBars() {
98
- const showLegend = this.options.legend ?? this._norm.isMulti;
99
- if (showLegend && this._norm.isMulti) {
132
+ const hasCatLegend = Boolean(this._catField && this._catColors.size > 0);
133
+ const showLegend = this.options.legend ?? (this._norm.isMulti || hasCatLegend);
134
+ if (showLegend && (this._norm.isMulti || hasCatLegend)) {
100
135
  const { x, y, width } = this._plotBox;
101
136
  Legend.beside({ x, y, width }, this.renderer.width, this._legendOptions()).draw(this.renderer);
102
137
  }
@@ -5,11 +5,12 @@
5
5
  *
6
6
  * It reads decisionengine's JSON, not decisionengine: a workbench document
7
7
  *
8
- * { kind: 'sadis-workbench', report, plan?, before? }
8
+ * { kind: 'sadis-workbench', report, plan?, before?, graph? }
9
9
  *
10
10
  * holds a report's `toJSON()` (the ranked candidates, each with its score's
11
11
  * parts), optionally a delivery plan's `toJSON()` and an earlier report to
12
- * replay against. Each view is a `WorkbenchView` subclass naming the charts it
12
+ * replay against, and the scored portfolio graph (`{ nodes, edges }`) to explore. Each view is a
13
+ * `WorkbenchView` subclass naming the charts it
13
14
  * shows (`{ title, chartType, data, options }`, `chartType` a CHART_CATALOG key)
14
15
  * and notes for what a chart cannot show; a view needing what the document
15
16
  * lacks (a plan, perspectives, an earlier report) is not offered.
@@ -17,7 +18,11 @@
17
18
  * DOM-free: a workspace page puts each chart in a ChartPart.
18
19
  */
19
20
 
21
+ import { NavigatorModel } from '../modelTree/NavigatorModel.js';
22
+
20
23
  const KIND = 'sadis-workbench';
24
+ /** SADIS's relationships that carry value up the basin, and those that make one candidate wait on another. */
25
+ const CONTRIBUTES = ['ENABLES', 'IMPLEMENTS', 'REALIZES', 'SUPPORTS'];
21
26
  const DAY = 24 * 60 * 60 * 1000;
22
27
  const pct = (share) => Math.round(share * 1000) / 10;
23
28
  const round = (n) => Math.round(n * 1000) / 1000;
@@ -115,6 +120,64 @@ export class ArchitectureView extends WorkbenchView {
115
120
  }
116
121
  }
117
122
 
123
+ /**
124
+ * The first candidate's neighbourhood in the portfolio graph: the ways its value goes up to each objective
125
+ * it reaches, and what waits on it. Read with `NavigatorModel` over the graph document (a `DocumentGraph`),
126
+ * the same walker the ArchiMate modeller's Navigator uses; drawn as a graph diagram.
127
+ */
128
+ export class ExploreView extends WorkbenchView {
129
+ static id = 'explore';
130
+ static title = 'Explore';
131
+
132
+ available() { return Boolean(this.workbench.graph); }
133
+
134
+ /** The candidate explored, the navigator, the ways up and what waits on it. */
135
+ explore() {
136
+ const { workbench } = this;
137
+ const top = workbench.entries[0];
138
+ const navigator = NavigatorModel.forGraph(workbench.graph, { rootId: top.id });
139
+ const ways = top.basin.reaches.map((r) => navigator.findPaths(top.id, r.id, { direction: 'outgoing', maxDepth: r.hops, allowedKinds: CONTRIBUTES })[0])
140
+ .filter(Boolean);
141
+ const waiting = [
142
+ navigator.traceImpact(top.id, { direction: 'incoming', allowedKinds: ['DEPENDS_ON'] }),
143
+ navigator.traceImpact(top.id, { direction: 'outgoing', allowedKinds: ['BLOCKS'] }),
144
+ ];
145
+ return { top, navigator, ways, waiting };
146
+ }
147
+
148
+ charts() {
149
+ const { top, navigator, ways, waiting } = this.explore();
150
+ const nodes = new Map([[top.id, navigator.model.getElement(top.id)]]);
151
+ const edges = new Map();
152
+ for (const way of ways) {
153
+ for (const element of way.elements) nodes.set(element.id, element);
154
+ for (const rel of way.relationships) edges.set(rel.id, rel);
155
+ }
156
+ for (const impact of waiting) {
157
+ for (const element of impact.elements) nodes.set(element.id, element);
158
+ for (const rel of impact.relationships) edges.set(rel.id, rel);
159
+ }
160
+ return [{
161
+ title: `${top.name}: its ways to the objectives, and what waits on it`,
162
+ chartType: 'graph',
163
+ data: null,
164
+ options: {
165
+ nodes: [...nodes.values()].filter(Boolean).map((e) => ({ id: e.id, label: e.name, group: e.type })),
166
+ edges: [...edges.values()].map((r) => ({ source: r.source, target: r.target, label: r.kind })),
167
+ },
168
+ }];
169
+ }
170
+
171
+ notes() {
172
+ const { top, ways, waiting } = this.explore();
173
+ const waits = [...new Map(waiting.flatMap((i) => i.elements).map((e) => [e.id, e.name])).values()];
174
+ return [
175
+ ...ways.map((way) => `${way.elements.map((e) => e.name).join(' → ')} (${way.length} ${way.length === 1 ? 'step' : 'steps'})`),
176
+ waits.length ? `Waiting on ${top.name}: ${waits.join(', ')}` : `Nothing waits on ${top.name}`,
177
+ ];
178
+ }
179
+ }
180
+
118
181
  /** The delivery plan: when each candidate is worked on, and how full each period is. */
119
182
  export class DeliveryView extends WorkbenchView {
120
183
  static id = 'delivery';
@@ -245,7 +308,73 @@ export class ReplayView extends WorkbenchView {
245
308
  }
246
309
  }
247
310
 
248
- export const WORKBENCH_VIEWS = Object.freeze([ExecutiveView, ArchitectureView, DeliveryView, PortfolioView, ReplayView]);
311
+ /** Architecture-driven predictions: cost ranges, confidence, and basin layer spend. */
312
+ export class EstimationView extends WorkbenchView {
313
+ static id = 'estimation';
314
+ static title = 'Estimation';
315
+
316
+ available() { return Boolean(this.workbench.report?.estimation); }
317
+
318
+ charts() {
319
+ const est = this.workbench.report?.estimation;
320
+ const entries = est?.entries ?? [];
321
+ if (!entries.length) return [];
322
+
323
+ const charts = [
324
+ {
325
+ title: `Predicted cost range (${est.totals?.cost?.currency ?? 'AUD'}): minimum → maximum`,
326
+ chartType: 'dumbbell',
327
+ data: entries.map((e) => ({
328
+ label: e.name,
329
+ start: e.cost.minimum,
330
+ end: e.cost.maximum,
331
+ })),
332
+ },
333
+ {
334
+ title: 'Confidence score (0–100%)',
335
+ chartType: 'bar',
336
+ data: entries.map((e) => ({
337
+ label: e.name,
338
+ value: e.confidence,
339
+ })),
340
+ },
341
+ ];
342
+
343
+ const byBasin = {};
344
+ for (const e of entries) {
345
+ const label = `L${e.basin?.level ?? 1} ${e.basin?.name ?? 'Basin'}`;
346
+ byBasin[label] = (byBasin[label] ?? 0) + e.cost.expected;
347
+ }
348
+ charts.push({
349
+ title: 'Predicted spend by basin layer',
350
+ chartType: 'pie',
351
+ data: Object.entries(byBasin).map(([label, value]) => ({ label, value })),
352
+ });
353
+
354
+ return charts;
355
+ }
356
+
357
+ notes() {
358
+ const est = this.workbench.report?.estimation;
359
+ if (!est) return [];
360
+ const tot = est.totals;
361
+ const fmt = (n) => `$${Number(n).toLocaleString('en-US')}`;
362
+ const notes = [
363
+ `Total predicted spend: ${fmt(tot.cost.expected)} ${tot.cost.currency} (${fmt(tot.cost.minimum)} – ${fmt(tot.cost.maximum)}) across ${tot.initiativesCount} initiatives`,
364
+ `Total effort: ${tot.effort.days} days (${tot.effort.storyPoints} story points, ${tot.effort.hours} hours) · ${tot.duration.workingDays} working days`,
365
+ ];
366
+
367
+ const recs = (est.entries ?? []).flatMap((e) => e.recommendations ?? []);
368
+ const uniqueRecs = [...new Set(recs)];
369
+ for (const r of uniqueRecs) {
370
+ notes.push(`Optimisation advice: ${r}`);
371
+ }
372
+
373
+ return notes;
374
+ }
375
+ }
376
+
377
+ export const WORKBENCH_VIEWS = Object.freeze([ExecutiveView, ArchitectureView, ExploreView, DeliveryView, PortfolioView, EstimationView, ReplayView]);
249
378
 
250
379
  export class DecisionWorkbench {
251
380
  static KIND = KIND;
@@ -261,6 +390,9 @@ export class DecisionWorkbench {
261
390
  throw new Error('A plan, when given, is decisionengine\'s plan.toJSON(): periods and value');
262
391
  }
263
392
  if (doc.before !== undefined && !isReport(doc.before)) throw new Error('An earlier report, when given, is a report.toJSON() too');
393
+ if (doc.graph !== undefined && !(doc.graph && Array.isArray(doc.graph.nodes) && Array.isArray(doc.graph.edges))) {
394
+ throw new Error('A graph, when given, is the scored portfolio as a graph document: { nodes, edges }');
395
+ }
264
396
  return new DecisionWorkbench(doc, calendar);
265
397
  }
266
398
 
@@ -269,7 +401,7 @@ export class DecisionWorkbench {
269
401
  * @param {{start?: string|number, periodDays?: number}} [calendar] - when period 1 starts (default: the report's
270
402
  * as-of business time, else today) and how long a period is (default 14 days), for the delivery plan's dates
271
403
  */
272
- constructor({ report, plan = null, before = null }, { start, periodDays = 14 } = {}) {
404
+ constructor({ report, plan = null, before = null, graph = null }, { start, periodDays = 14 } = {}) {
273
405
  const from = start ?? report.asOf?.businessTime ?? Date.now();
274
406
  const ms = typeof from === 'number' ? from : Date.parse(from);
275
407
  if (!Number.isFinite(ms)) throw new Error(`The calendar's start is ${JSON.stringify(start)}, not a date`);
@@ -278,6 +410,7 @@ export class DecisionWorkbench {
278
410
  this.report = report;
279
411
  this.plan = plan;
280
412
  this.before = before;
413
+ this.graph = graph;
281
414
  this.entries = [...report.entries].sort((a, b) => a.rank - b.rank);
282
415
  this.byId = new Map(this.entries.map((e) => [e.id, e]));
283
416
  }
@@ -18,11 +18,12 @@ export class DiagramView extends DiagramEditorState {
18
18
  * @param {Map<string, Template>} [options.templates]
19
19
  * @param {Map<string, Edge>} [options.edges]
20
20
  * @param {Object} [options.edgeStyle] - Diagram-level line style defaults
21
+ * @param {Object} [options.viewSettings] - Diagram appearance: background, watermark, title band
21
22
  * @param {Object} [options.historyOptions] - Passed to CommandHistory (e.g. onChange)
22
23
  * @param {string} [options.documentation]
23
24
  * @param {Arrangement} [options.arrangement]
24
25
  */
25
- constructor({ id, title, templates = new Map(), edges = new Map(), edgeStyle = {}, historyOptions = {}, documentation = undefined, arrangement = null }) {
26
+ constructor({ id, title, templates = new Map(), edges = new Map(), edgeStyle = {}, viewSettings = {}, historyOptions = {}, documentation = undefined, arrangement = null }) {
26
27
  super({ historyOptions, arrangement });
27
28
  this.historyOptions = historyOptions;
28
29
  this.id = id;
@@ -31,6 +32,7 @@ export class DiagramView extends DiagramEditorState {
31
32
  this.templates = templates;
32
33
  this.edges = edges;
33
34
  this.edgeStyle = edgeStyle;
35
+ this.viewSettings = viewSettings;
34
36
  }
35
37
 
36
38
  /** The shapes are what stack. */
@@ -52,6 +54,7 @@ export class DiagramView extends DiagramEditorState {
52
54
  })),
53
55
  edges: [...this.edges.values()].map(e => (typeof e.toJSON === 'function' ? e.toJSON() : JSON.parse(JSON.stringify(e)))),
54
56
  edgeStyle: JSON.parse(JSON.stringify(this.edgeStyle ?? {})),
57
+ ...(this.viewSettings && Object.keys(this.viewSettings).length ? { viewSettings: JSON.parse(JSON.stringify(this.viewSettings)) } : {}),
55
58
  ...(this.arrangement && { arrangement: this.arrangement.toJSON() }),
56
59
  };
57
60
  }
@@ -75,6 +78,7 @@ export class DiagramView extends DiagramEditorState {
75
78
  templates,
76
79
  edges,
77
80
  edgeStyle: json.edgeStyle ?? {},
81
+ viewSettings: json.viewSettings ? JSON.parse(JSON.stringify(json.viewSettings)) : {},
78
82
  documentation: json.documentation,
79
83
  historyOptions,
80
84
  arrangement,
@@ -89,9 +93,10 @@ export class DiagramView extends DiagramEditorState {
89
93
  templates: new Map([...this.templates].map(([tid, t]) => [tid, new t.constructor(tid, t.name, t.serialize())])),
90
94
  edges: new Map([...this.edges].map(([eid, e]) => [eid, e.clone()])),
91
95
  edgeStyle: { ...this.edgeStyle },
96
+ viewSettings: { ...(this.viewSettings ?? {}) },
92
97
  documentation: this.documentation,
93
98
  historyOptions,
94
- arrangement: this.arrangement.copy(), // template ids are kept
99
+ arrangement: this.arrangement?.copy() ?? null, // template ids are kept
95
100
  });
96
101
  }
97
102
  }
@@ -149,4 +149,19 @@ export class GraphDiagram3D extends Diagram3D {
149
149
  }
150
150
  return pos;
151
151
  }
152
+
153
+ /**
154
+ * Asynchronous 3D force layout simulation, offloading to GPU when backend provided.
155
+ * @param {Array<{id: string, x?: number, y?: number, z?: number}>} nodes
156
+ * @param {Array<{source: string, target: string}>} edges
157
+ * @param {object} [options]
158
+ * @param {import('../../compute/ComputeBackend.js').ComputeBackend} [options.backend]
159
+ * @returns {Promise<Map<string, {x: number, y: number, z: number, vx: number, vy: number, vz: number}>>}
160
+ */
161
+ static async layoutForce3DAsync(nodes, edges, options = {}) {
162
+ if (options.backend) {
163
+ return options.backend.layoutForce3DAsync(nodes, edges, options);
164
+ }
165
+ return GraphDiagram3D.layoutForce3D(nodes, edges, options);
166
+ }
152
167
  }