@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.
- package/dist/renderengine-workspace.umd.js +6030 -3766
- package/dist/renderengine.umd.js +5930 -3772
- package/documentation/REFERENCE.md +37 -3
- package/package.json +1 -1
- package/src/archimateArchiFormat.js +114 -10
- package/src/archimateArchive.js +27 -4
- package/src/archimateExchangeFormat.js +12 -4
- package/src/archimateSpecializations.js +11 -1
- package/src/chartCatalog.js +6 -4
- package/src/charting/ArchimateDiagramEditor.js +5 -108
- package/src/charting/DiagramTypeRegistry.js +20 -1
- package/src/charting/DiagramViewPropertiesDialog.js +54 -10
- package/src/charting/Legend.js +1 -2
- package/src/charting/Statistics.js +14 -0
- package/src/charting/charts/BarChart.js +59 -8
- package/src/charting/charts/HexbinChart.js +15 -0
- package/src/charting/charts/LollipopChart.js +42 -7
- package/src/charting/decision/DecisionWorkbench.js +137 -4
- package/src/charting/diagramModel/DiagramView.js +7 -2
- package/src/charting/diagrams/GraphDiagram3D.js +15 -0
- package/src/charting/graph/GraphSource.js +72 -0
- package/src/charting/modelTree/NavigatorModel.js +22 -8
- package/src/charting/report/TemplatedReport.js +27 -377
- package/src/charting/report/ViewsReport.js +118 -0
- package/src/charting/report/reportWriters.js +496 -0
- package/src/charting/templates/C4BoundaryTemplate.js +34 -0
- package/src/charting/templates/C4ComponentTemplate.js +30 -0
- package/src/charting/templates/C4ContainerTemplate.js +49 -0
- package/src/charting/templates/C4PersonTemplate.js +35 -0
- package/src/charting/templates/C4SystemTemplate.js +32 -0
- package/src/charting/templates/C4Template.js +95 -0
- package/src/charting/templates/index.js +6 -0
- package/src/charting/templates/templateRegistry.js +7 -0
- package/src/compute/ComputeBackend.js +132 -0
- package/src/compute/CpuBackend.js +209 -0
- package/src/compute/WebGpuBackend.js +630 -0
- package/src/compute/index.js +5 -0
- package/src/compute/selectBackend.js +85 -0
- package/src/index.js +3403 -3371
- package/src/kernelExpr.js +80 -5
- package/src/workspace/parts/DiagramEditorPart.js +3 -1
- 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
|
|
9
|
-
* (diagram → model → tool)
|
|
10
|
-
*
|
|
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
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
this.
|
|
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
|
}
|
package/src/charting/Legend.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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 (
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|