@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
@@ -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
  }
@@ -1034,15 +1034,28 @@ export class ArchimateCanvasEditor extends DiagramEditor {
1034
1034
  return element ? treeLayerOf(element) : 'other';
1035
1035
  }
1036
1036
 
1037
- /** An element's box holds any box but a group; on a canvas, a block holds boxes. */
1037
+ /** An element's box holds any box; a group holds any box or nested group; on a canvas, a block holds boxes. */
1038
1038
  canHold(parentId, childId) {
1039
+ if (!parentId || !childId || parentId === childId) return false;
1039
1040
  const parent = this.templates.get(parentId), child = this.templates.get(childId);
1040
- if (!parent?.instance || !child?.instance || parent instanceof ViewGroupTemplate || child instanceof ViewGroupTemplate) return false;
1041
+ if (!parent || !child) return false;
1042
+ if (child instanceof ViewGroupTemplate && parent instanceof ViewGroupTemplate) {
1043
+ return !this.isAncestor(childId, parentId);
1044
+ }
1045
+ if (parent instanceof ViewGroupTemplate) {
1046
+ return !this.isAncestor(childId, parentId);
1047
+ }
1048
+ if (child instanceof ViewGroupTemplate) return false;
1049
+ if (!parent.instance || !child.instance) return false;
1041
1050
  return !!this._elementOf(parentId) || parent.instance.content?.archiVisualType === 'canvas-model-block';
1042
1051
  }
1043
1052
 
1044
- /** An element dropped on another nests there when a relationship a nesting stands for (NESTING_KINDS) is allowed between them. */
1053
+ /** An element dropped on another nests there when a relationship a nesting stands for (NESTING_KINDS) is allowed between them, or when dropped into a group. */
1045
1054
  nestsOnDrop(parentId, childId) {
1055
+ const parent = this.templates.get(parentId);
1056
+ if (parent instanceof ViewGroupTemplate) {
1057
+ return this.canHold(parentId, childId);
1058
+ }
1046
1059
  if (!this._elementOf(parentId) || !this._elementOf(childId)) return false;
1047
1060
  return this.allowedKinds(parentId, childId).some(kind => NESTING_KINDS.includes(kind));
1048
1061
  }
@@ -1050,13 +1063,16 @@ export class ArchimateCanvasEditor extends DiagramEditor {
1050
1063
  nestCommand(childId, parentId) {
1051
1064
  const child = this.templates.get(childId)?.instance;
1052
1065
  if (!child) return null;
1053
- return new NestInstanceCommand(child, parentId ? this.templates.get(parentId)?.instance ?? null : null);
1066
+ const parentTemplate = parentId ? this.templates.get(parentId) : null;
1067
+ const parentTarget = parentTemplate?.instance ?? (parentTemplate instanceof ViewGroupTemplate ? parentTemplate : null);
1068
+ return new NestInstanceCommand(child, parentTarget);
1054
1069
  }
1055
1070
 
1056
- /** After nesting an element in another: offer the relationship the nesting stands for (nestingKinds). */
1071
+ /** After nesting an element in another: offer the relationship the nesting stands for (nestingKinds). Groups have no relationship. */
1057
1072
  nested(changes) {
1058
1073
  let chain = Promise.resolve();
1059
1074
  for (const { childId, parentId } of changes) {
1075
+ if (this.templates.get(parentId) instanceof ViewGroupTemplate) continue;
1060
1076
  chain = chain.then(() => this.offerNestingRelationship(parentId, childId)); // taken out: nothing to offer
1061
1077
  }
1062
1078
  return chain;
@@ -47,8 +47,11 @@ export class ViewGroupTemplate extends GlyphTemplate {
47
47
  return this.view.model.getFolder(this.id);
48
48
  }
49
49
 
50
- get name() { return this.folder?.name ?? 'Group'; }
51
- set name(value) { if (this.folder) this.folder.name = value || 'Group'; }
50
+ get name() { return this.folder?.name ?? this.layout?.name ?? 'Group'; }
51
+ set name(value) {
52
+ if (this.folder) this.folder.name = value || 'Group';
53
+ if (this.layout) this.layout.name = value || 'Group';
54
+ }
52
55
 
53
56
  // The layout keeps the top-left corner; a template is placed by its centre.
54
57
  get x() { return this.layout.x + this.width / 2; }
@@ -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,7 @@ export class ReplayView extends WorkbenchView {
245
308
  }
246
309
  }
247
310
 
248
- export const WORKBENCH_VIEWS = Object.freeze([ExecutiveView, ArchitectureView, DeliveryView, PortfolioView, ReplayView]);
311
+ export const WORKBENCH_VIEWS = Object.freeze([ExecutiveView, ArchitectureView, ExploreView, DeliveryView, PortfolioView, ReplayView]);
249
312
 
250
313
  export class DecisionWorkbench {
251
314
  static KIND = KIND;
@@ -261,6 +324,9 @@ export class DecisionWorkbench {
261
324
  throw new Error('A plan, when given, is decisionengine\'s plan.toJSON(): periods and value');
262
325
  }
263
326
  if (doc.before !== undefined && !isReport(doc.before)) throw new Error('An earlier report, when given, is a report.toJSON() too');
327
+ if (doc.graph !== undefined && !(doc.graph && Array.isArray(doc.graph.nodes) && Array.isArray(doc.graph.edges))) {
328
+ throw new Error('A graph, when given, is the scored portfolio as a graph document: { nodes, edges }');
329
+ }
264
330
  return new DecisionWorkbench(doc, calendar);
265
331
  }
266
332
 
@@ -269,7 +335,7 @@ export class DecisionWorkbench {
269
335
  * @param {{start?: string|number, periodDays?: number}} [calendar] - when period 1 starts (default: the report's
270
336
  * as-of business time, else today) and how long a period is (default 14 days), for the delivery plan's dates
271
337
  */
272
- constructor({ report, plan = null, before = null }, { start, periodDays = 14 } = {}) {
338
+ constructor({ report, plan = null, before = null, graph = null }, { start, periodDays = 14 } = {}) {
273
339
  const from = start ?? report.asOf?.businessTime ?? Date.now();
274
340
  const ms = typeof from === 'number' ? from : Date.parse(from);
275
341
  if (!Number.isFinite(ms)) throw new Error(`The calendar's start is ${JSON.stringify(start)}, not a date`);
@@ -278,6 +344,7 @@ export class DecisionWorkbench {
278
344
  this.report = report;
279
345
  this.plan = plan;
280
346
  this.before = before;
347
+ this.graph = graph;
281
348
  this.entries = [...report.entries].sort((a, b) => a.rank - b.rank);
282
349
  this.byId = new Map(this.entries.map((e) => [e.id, e]));
283
350
  }
@@ -0,0 +1,32 @@
1
+ import { ModelFileFormat } from '../ModelFileFormat.js';
2
+ import { DiagramModel } from './DiagramModel.js';
3
+
4
+ /**
5
+ * DiagramJsonFileFormat — a diagram model as a file (`.diagram.json` or `.json`):
6
+ * its name, palette category, line style defaults, views with templates, edges,
7
+ * waypoints and arrangements (DiagramModel#toJSON), read back by DiagramModel.fromJSON.
8
+ */
9
+ export class DiagramJsonFileFormat extends ModelFileFormat {
10
+ get label() { return '.diagram.json'; }
11
+ get accept() { return '.diagram.json,.json'; }
12
+ get fileName() { return 'diagrams.diagram.json'; }
13
+ get featureName() { return 'DiagramJson'; }
14
+
15
+ /** JSON with a diagram model's shape: templateType and views array, or views array of diagrams. */
16
+ recognizes(text) {
17
+ try {
18
+ const json = JSON.parse(text);
19
+ return !!json && typeof json === 'object' && (Array.isArray(json.views) || typeof json.templateType === 'string');
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ export(model) {
26
+ return { data: JSON.stringify(model.toJSON(), null, 2), mimeType: 'application/json' };
27
+ }
28
+
29
+ import(text) {
30
+ return DiagramModel.fromJSON(JSON.parse(text));
31
+ }
32
+ }
@@ -36,4 +36,43 @@ export class DiagramModel extends ViewSetModel {
36
36
  setTemplateType(templateType) {
37
37
  this.templateType = templateType;
38
38
  }
39
+
40
+ /** The diagram model as plain JSON: its name, templateType, line style defaults, active view and views. */
41
+ toJSON() {
42
+ return {
43
+ version: 1,
44
+ name: this.name,
45
+ templateType: this.templateType,
46
+ edgeStyle: JSON.parse(JSON.stringify(this.edgeStyle ?? {})),
47
+ currentViewId: this.currentViewId,
48
+ views: this.views().map(v => v.toJSON()),
49
+ };
50
+ }
51
+
52
+ /** Reconstruct a DiagramModel from plain JSON. */
53
+ static fromJSON(json, historyOptions = {}) {
54
+ if (!json) throw new TypeError('DiagramModel.fromJSON requires a JSON object');
55
+ const model = new DiagramModel({
56
+ name: json.name ?? '',
57
+ templateType: json.templateType ?? 'generic',
58
+ edgeStyle: json.edgeStyle ?? {},
59
+ historyOptions,
60
+ });
61
+ const views = (json.views ?? []).map(vData => DiagramView.fromJSON(vData, model.historyOptions));
62
+ for (const view of views) {
63
+ model._insertView(model._adopt(view), { current: view.id === json.currentViewId });
64
+ }
65
+ if (json.currentViewId && model._views.has(json.currentViewId)) {
66
+ model.currentViewId = json.currentViewId;
67
+ } else if (model._views.size > 0 && !model.currentViewId) {
68
+ model.currentViewId = model._views.keys().next().value;
69
+ }
70
+ const maxNum = Math.max(0, ...model.views().map(v => {
71
+ const m = String(v.id).match(/\d+$/);
72
+ return m ? parseInt(m[0], 10) : 0;
73
+ }));
74
+ model._nextId = maxNum + 1;
75
+ model.markSaved();
76
+ return model;
77
+ }
39
78
  }