@peter.prib/renderengine 0.13.0 → 0.15.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 (124) hide show
  1. package/README.md +480 -302
  2. package/bin/renderengine.mjs +157 -0
  3. package/dist/renderengine-workspace.umd.js +49217 -37484
  4. package/dist/renderengine.umd.js +24899 -13093
  5. package/documentation/REFERENCE.md +68 -1
  6. package/package.json +13 -3
  7. package/src/CanvasRenderer.js +225 -200
  8. package/src/SVGRenderer.js +476 -454
  9. package/src/archimate.js +22 -2
  10. package/src/archimateArchiFormat.js +1735 -1701
  11. package/src/archimateArchive.js +36 -7
  12. package/src/archimateCSV.js +93 -37
  13. package/src/archimateDrawio.js +2 -0
  14. package/src/archimateExchangeFormat.js +3 -1
  15. package/src/chartCatalog.js +269 -3
  16. package/src/charting/Annotations.js +169 -0
  17. package/src/charting/ArchimateDiagramEditor.js +54 -10
  18. package/src/charting/Axis.js +217 -5
  19. package/src/charting/Chart.js +99 -9
  20. package/src/charting/Chart3D.js +7 -2
  21. package/src/charting/ChartBox.js +8 -1
  22. package/src/charting/ColorPalette.js +42 -4
  23. package/src/charting/DiagramEditor.js +174 -39
  24. package/src/charting/DiagramEditorCommands.js +28 -0
  25. package/src/charting/Edge.js +7 -0
  26. package/src/charting/Hierarchy.js +142 -0
  27. package/src/charting/InteractionManager.js +4 -0
  28. package/src/charting/ModelFileFormat.js +5 -1
  29. package/src/charting/SettingsManager.js +35 -0
  30. package/src/charting/ShapePaths.js +82 -10
  31. package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
  32. package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
  33. package/src/charting/ask/AskBar.js +388 -0
  34. package/src/charting/ask/AskView.js +400 -0
  35. package/src/charting/charts/AlluvialDiagram.js +311 -0
  36. package/src/charting/charts/AreaChart.js +69 -15
  37. package/src/charting/charts/BarChart.js +38 -3
  38. package/src/charting/charts/BarChart3D.js +6 -4
  39. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  40. package/src/charting/charts/BarChartBase.js +26 -6
  41. package/src/charting/charts/BeeswarmChart.js +164 -0
  42. package/src/charting/charts/BoxPlotChart.js +25 -3
  43. package/src/charting/charts/BubbleChart.js +4 -1
  44. package/src/charting/charts/CalendarHeatmapChart.js +2 -1
  45. package/src/charting/charts/CandlestickChart.js +172 -4
  46. package/src/charting/charts/ChordDiagram.js +107 -4
  47. package/src/charting/charts/CirclePackChart.js +81 -0
  48. package/src/charting/charts/ComboChart.js +233 -0
  49. package/src/charting/charts/ContourChart.js +310 -0
  50. package/src/charting/charts/DensityChart.js +1 -1
  51. package/src/charting/charts/DonutChart3D.js +13 -0
  52. package/src/charting/charts/DumbbellChart.js +217 -0
  53. package/src/charting/charts/EcdfChart.js +150 -0
  54. package/src/charting/charts/FunnelChart.js +5 -2
  55. package/src/charting/charts/GeoMapChart.js +132 -11
  56. package/src/charting/charts/GroupedBarChart.js +44 -3
  57. package/src/charting/charts/GroupedDistributionChart.js +26 -1
  58. package/src/charting/charts/HeatmapChart.js +6 -3
  59. package/src/charting/charts/HexbinChart.js +137 -0
  60. package/src/charting/charts/HierarchyChart.js +77 -3
  61. package/src/charting/charts/HistogramChart.js +2 -2
  62. package/src/charting/charts/HorizonChart.js +105 -0
  63. package/src/charting/charts/IcicleChart.js +47 -0
  64. package/src/charting/charts/KagiChart.js +167 -0
  65. package/src/charting/charts/LineChart.js +74 -18
  66. package/src/charting/charts/LollipopChart.js +104 -0
  67. package/src/charting/charts/MarimekkoChart.js +75 -0
  68. package/src/charting/charts/OhlcChart.js +79 -0
  69. package/src/charting/charts/OutlineChart.js +10 -8
  70. package/src/charting/charts/ParallelCoordinatesChart.js +11 -3
  71. package/src/charting/charts/ParetoChart.js +92 -0
  72. package/src/charting/charts/PieChart.js +195 -17
  73. package/src/charting/charts/PieChart3D.js +137 -0
  74. package/src/charting/charts/PipeChart.js +5 -2
  75. package/src/charting/charts/PointAndFigureChart.js +198 -0
  76. package/src/charting/charts/PolarAreaChart.js +189 -0
  77. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  78. package/src/charting/charts/RadarChart.js +12 -5
  79. package/src/charting/charts/RadialBarChart.js +6 -3
  80. package/src/charting/charts/RenkoChart.js +178 -0
  81. package/src/charting/charts/RidgelineChart.js +113 -0
  82. package/src/charting/charts/SankeyDiagram.js +129 -6
  83. package/src/charting/charts/ScatterChart.js +19 -3
  84. package/src/charting/charts/ScatterMatrixChart.js +119 -0
  85. package/src/charting/charts/SlopegraphChart.js +205 -0
  86. package/src/charting/charts/SparklineTableChart.js +166 -0
  87. package/src/charting/charts/StackedBarChart.js +125 -11
  88. package/src/charting/charts/StackedBarChart3DOrbit.js +14 -6
  89. package/src/charting/charts/StreamGraphChart.js +18 -3
  90. package/src/charting/charts/SunburstChart.js +28 -8
  91. package/src/charting/charts/SurfaceChart3D.js +157 -0
  92. package/src/charting/charts/TreemapChart.js +31 -11
  93. package/src/charting/charts/ViolinChart.js +20 -2
  94. package/src/charting/charts/WaterfallChart.js +2 -2
  95. package/src/charting/charts/WordCloudChart.js +2 -2
  96. package/src/charting/datamodel/ArchimateDataLink.js +70 -0
  97. package/src/charting/datamodel/DataLineage.js +150 -0
  98. package/src/charting/datamodel/DataModel.js +83 -1
  99. package/src/charting/datamodel/ModelMapping.js +87 -0
  100. package/src/charting/datamodel/SqlDdl.js +110 -85
  101. package/src/charting/datamodel/index.js +3 -0
  102. package/src/charting/decision/DecisionWorkbench.js +295 -0
  103. package/src/charting/diagrams/ArchimateDiagram.js +25 -9
  104. package/src/charting/diagrams/ArchimateModel.js +1382 -1238
  105. package/src/charting/diagrams/GraphDiagram.js +1424 -1350
  106. package/src/charting/diagrams/SketchView.js +121 -0
  107. package/src/charting/diagrams/sketchBoxKinds.js +30 -0
  108. package/src/charting/modelTree/AnalysisPanel.js +101 -23
  109. package/src/charting/modelTree/NavigatorModel.js +410 -0
  110. package/src/charting/modelTree/NavigatorPanel.js +453 -0
  111. package/src/charting/pivot/PivotChart.js +310 -13
  112. package/src/charting/pivot/PivotGridChart.js +322 -34
  113. package/src/charting/pivot/PivotView.js +626 -21
  114. package/src/charting/report/TemplatedReport.js +547 -0
  115. package/src/charting/templates/BoxTemplate.js +28 -27
  116. package/src/charting/templates/PackageTemplate.js +26 -25
  117. package/src/charting/templates/SwimlaneTemplate.js +19 -18
  118. package/src/charting/templates/Template.js +219 -212
  119. package/src/cli.js +146 -0
  120. package/src/index.js +3370 -2899
  121. package/src/workspace/Workspace.js +126 -7
  122. package/src/workspace/archimateServices.js +4 -0
  123. package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
  124. package/src/workspace/styles.js +36 -1
@@ -1,85 +1,110 @@
1
- /** Words SQL keeps for itself that a name may not be left bare as. */
2
- const RESERVED = new Set(['user', 'order', 'group', 'table', 'select', 'from', 'where', 'by', 'to', 'key', 'check', 'default', 'index', 'primary', 'foreign', 'references', 'unique', 'column', 'constraint', 'value', 'values', 'date', 'time', 'timestamp', 'level', 'position', 'role']);
3
-
4
- /**
5
- * SqlDdl — a model's physical level as SQL DDL (DMBOK2's data integration: the
6
- * design handed to a database): a CREATE TABLE per table, its columns with
7
- * their types (`defaultType` where a column has none) and NOT NULL where
8
- * required, its primary key and UNIQUE columns; a FOREIGN KEY per column that
9
- * references another (`references`: a table, or `table.column`, by id or name;
10
- * a table alone: its primary key); and COMMENT ONs for definitions. Names are
11
- * quoted where SQL would not take them bare. ANSI SQL, as PostgreSQL reads it.
12
- */
13
- export class SqlDdl {
14
- /**
15
- * @param {Object} [options]
16
- * @param {string} [options.defaultType] - A column's type when it names none
17
- * @param {boolean} [options.comments] - Whether definitions become COMMENT ONs
18
- */
19
- constructor({ defaultType = 'VARCHAR(255)', comments = true } = {}) {
20
- this.defaultType = defaultType;
21
- this.comments = comments;
22
- }
23
-
24
- /** A name as SQL takes it: bare when it can be, else double-quoted. */
25
- static name(text) {
26
- const s = String(text);
27
- return /^[a-z_][a-z0-9_]*$/.test(s) && !RESERVED.has(s) ? s : `"${s.replace(/"/g, '""')}"`;
28
- }
29
-
30
- /**
31
- * A table's name as SQL takes it: a schema-qualified one (`sales.customer`)
32
- * each part as name() has it, so it names the table in its schema rather
33
- * than one table called "sales.customer".
34
- */
35
- static tableName(text) {
36
- return String(text).split('.').map(SqlDdl.name).join('.');
37
- }
38
-
39
- /** A text as an SQL string literal. */
40
- static literal(text) {
41
- return `'${String(text).replace(/'/g, "''")}'`;
42
- }
43
-
44
- /** The DDL for a model's physical level. @returns {string} */
45
- write(model) {
46
- const schema = model.level('physical');
47
- const tables = schema.entities;
48
- const table = SqlDdl.tableName;
49
- const out = [`-- ${model.name || 'Data model'}: the physical level, ${tables.length} table${tables.length === 1 ? '' : 's'}`, ''];
50
- for (const t of tables) {
51
- const columns = t.attributes ?? [];
52
- const keys = columns.filter(c => c.isPK).map(c => SqlDdl.name(c.name));
53
- const lines = [
54
- ...columns.map(c => ` ${SqlDdl.name(c.name)} ${c.type || this.defaultType}${c.required || c.isPK ? ' NOT NULL' : ''}`),
55
- ...(keys.length ? [` PRIMARY KEY (${keys.join(', ')})`] : []),
56
- ...columns.filter(c => c.isUnique && !c.isPK).map(c => ` UNIQUE (${SqlDdl.name(c.name)})`),
57
- ];
58
- out.push(`CREATE TABLE ${table(t.name)} (`, lines.join(',\n'), ');', '');
59
- }
60
- for (const t of tables) {
61
- for (const c of (t.attributes ?? []).filter(a => a.isFK && a.references)) {
62
- const ref = schema.resolveReference(c.references);
63
- if (!ref) {
64
- out.push(`-- ${t.name}.${c.name} references ${c.references}, which is not a table here`);
65
- continue;
66
- }
67
- const theirs = ref.column ? [ref.column] : (ref.entity.attributes ?? []).filter(a => a.isPK).map(a => a.name);
68
- if (!theirs.length) {
69
- out.push(`-- ${t.name}.${c.name} references ${ref.entity.name}, which has no primary key`);
70
- continue;
71
- }
72
- out.push(`ALTER TABLE ${table(t.name)} ADD CONSTRAINT ${SqlDdl.name(`fk_${t.name}_${c.name}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_'))}`
73
- + ` FOREIGN KEY (${SqlDdl.name(c.name)}) REFERENCES ${table(ref.entity.name)} (${theirs.map(SqlDdl.name).join(', ')});`);
74
- }
75
- }
76
- if (this.comments) {
77
- const notes = tables.flatMap(t => [
78
- ...(t.description ? [`COMMENT ON TABLE ${table(t.name)} IS ${SqlDdl.literal(t.description)};`] : []),
79
- ...(t.attributes ?? []).filter(c => c.description).map(c => `COMMENT ON COLUMN ${table(t.name)}.${SqlDdl.name(c.name)} IS ${SqlDdl.literal(c.description)};`),
80
- ]);
81
- if (notes.length) out.push('', ...notes);
82
- }
83
- return `${out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
84
- }
85
- }
1
+ /** Words SQL keeps for itself that a name may not be left bare as. */
2
+ const RESERVED = new Set(['user', 'order', 'group', 'table', 'select', 'from', 'where', 'by', 'to', 'key', 'check', 'default', 'index', 'primary', 'foreign', 'references', 'unique', 'column', 'constraint', 'value', 'values', 'date', 'time', 'timestamp', 'level', 'position', 'role']);
3
+
4
+ /**
5
+ * SqlDdl — a model's physical level as SQL DDL (DMBOK2's data integration: the
6
+ * design handed to a database): a CREATE TABLE per table, its columns with
7
+ * their types (`defaultType` where a column has none) and NOT NULL where
8
+ * required, its primary key and UNIQUE columns; a FOREIGN KEY per column that
9
+ * references another (`references`: a table, or `table.column`, by id or name;
10
+ * a table alone: its primary key); and COMMENT ONs for definitions. Names are
11
+ * quoted where SQL would not take them bare. ANSI SQL, as PostgreSQL reads it.
12
+ */
13
+ export class SqlDdl {
14
+ /**
15
+ * @param {Object} [options]
16
+ * @param {string} [options.defaultType] - A column's type when it names none
17
+ * @param {boolean} [options.comments] - Whether definitions become COMMENT ONs
18
+ * @param {boolean|Array<string>|Set<string>|Object} [options.external] - Trust foreign keys to tables outside the model
19
+ */
20
+ constructor({ defaultType = 'VARCHAR(255)', comments = true, external = false } = {}) {
21
+ this.defaultType = defaultType;
22
+ this.comments = comments;
23
+ this.external = external;
24
+ }
25
+
26
+ /** A name as SQL takes it: bare when it can be, else double-quoted. */
27
+ static name(text) {
28
+ const s = String(text);
29
+ return /^[a-z_][a-z0-9_]*$/.test(s) && !RESERVED.has(s) ? s : `"${s.replace(/"/g, '""')}"`;
30
+ }
31
+
32
+ /**
33
+ * A table's name as SQL takes it: a schema-qualified one (`sales.customer`)
34
+ * each part as name() has it, so it names the table in its schema rather
35
+ * than one table called "sales.customer".
36
+ */
37
+ static tableName(text) {
38
+ return String(text).split('.').map(SqlDdl.name).join('.');
39
+ }
40
+
41
+ /** A text as an SQL string literal. */
42
+ static literal(text) {
43
+ return `'${String(text).replace(/'/g, "''")}'`;
44
+ }
45
+
46
+ /** The DDL for a model's physical level. @returns {string} */
47
+ write(model, { external = this.external } = {}) {
48
+ const schema = model.level('physical');
49
+ const tables = schema.entities;
50
+ const table = SqlDdl.tableName;
51
+ const out = [`-- ${model.name || 'Data model'}: the physical level, ${tables.length} table${tables.length === 1 ? '' : 's'}`, ''];
52
+ for (const t of tables) {
53
+ const columns = t.attributes ?? [];
54
+ const keys = columns.filter(c => c.isPK).map(c => SqlDdl.name(c.name));
55
+ const lines = [
56
+ ...columns.map(c => ` ${SqlDdl.name(c.name)} ${c.type || this.defaultType}${c.required || c.isPK ? ' NOT NULL' : ''}`),
57
+ ...(keys.length ? [` PRIMARY KEY (${keys.join(', ')})`] : []),
58
+ ...columns.filter(c => c.isUnique && !c.isPK).map(c => ` UNIQUE (${SqlDdl.name(c.name)})`),
59
+ ];
60
+ out.push(`CREATE TABLE ${table(t.name)} (`, lines.join(',\n'), ');', '');
61
+ }
62
+
63
+ const isExternalTrusted = (tableName) => {
64
+ if (external === true) return true;
65
+ if (external instanceof Set) return external.has(tableName);
66
+ if (Array.isArray(external)) return external.includes(tableName);
67
+ if (external && typeof external === 'object') return !!external[tableName];
68
+ return false;
69
+ };
70
+
71
+ for (const t of tables) {
72
+ for (const c of (t.attributes ?? []).filter(a => a.isFK && a.references)) {
73
+ const ref = schema.resolveReference(c.references);
74
+ if (!ref) {
75
+ const raw = String(c.references).trim();
76
+ const dot = raw.lastIndexOf('.');
77
+ let targetTable = raw;
78
+ let targetCol = null;
79
+ if (dot > 0) {
80
+ targetTable = raw.slice(0, dot);
81
+ targetCol = raw.slice(dot + 1);
82
+ }
83
+ if (isExternalTrusted(targetTable)) {
84
+ const colRef = targetCol ? ` (${SqlDdl.name(targetCol)})` : '';
85
+ out.push(`ALTER TABLE ${table(t.name)} ADD CONSTRAINT ${SqlDdl.name(`fk_${t.name}_${c.name}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_'))}`
86
+ + ` FOREIGN KEY (${SqlDdl.name(c.name)}) REFERENCES ${table(targetTable)}${colRef};`);
87
+ } else {
88
+ out.push(`-- ${t.name}.${c.name} references ${c.references}, which is not a table here`);
89
+ }
90
+ continue;
91
+ }
92
+ const theirs = ref.column ? [ref.column] : (ref.entity.attributes ?? []).filter(a => a.isPK).map(a => a.name);
93
+ if (!theirs.length) {
94
+ out.push(`-- ${t.name}.${c.name} references ${ref.entity.name}, which has no primary key`);
95
+ continue;
96
+ }
97
+ out.push(`ALTER TABLE ${table(t.name)} ADD CONSTRAINT ${SqlDdl.name(`fk_${t.name}_${c.name}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_'))}`
98
+ + ` FOREIGN KEY (${SqlDdl.name(c.name)}) REFERENCES ${table(ref.entity.name)} (${theirs.map(SqlDdl.name).join(', ')});`);
99
+ }
100
+ }
101
+ if (this.comments) {
102
+ const notes = tables.flatMap(t => [
103
+ ...(t.description ? [`COMMENT ON TABLE ${table(t.name)} IS ${SqlDdl.literal(t.description)};`] : []),
104
+ ...(t.attributes ?? []).filter(c => c.description).map(c => `COMMENT ON COLUMN ${table(t.name)}.${SqlDdl.name(c.name)} IS ${SqlDdl.literal(c.description)};`),
105
+ ]);
106
+ if (notes.length) out.push('', ...notes);
107
+ }
108
+ return `${out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
109
+ }
110
+ }
@@ -19,3 +19,6 @@ export { DataModelFileFormat } from './DataModelFileFormat.js';
19
19
  export { SqlDdl } from './SqlDdl.js';
20
20
  export { SqlDdlFileFormat } from './SqlDdlFileFormat.js';
21
21
  export { LevelEntitiesCompared, LevelRelationshipsCompared, MappingsCompared, ContextsCompared, PerspectivesCompared, DataTypesCompared } from './DataModelCompared.js';
22
+ export { ModelMapping } from './ModelMapping.js';
23
+ export { DataLineage } from './DataLineage.js';
24
+ export { ArchimateDataLink } from './ArchimateDataLink.js';
@@ -0,0 +1,295 @@
1
+ // Copyright 2026 Jaroslav Peter Prib, Purveyor of Excellence
2
+ /**
3
+ * DecisionWorkbench — the Decision Workbench's views of a SADIS recommendation
4
+ * (decisionengine), as chart specs this library draws.
5
+ *
6
+ * It reads decisionengine's JSON, not decisionengine: a workbench document
7
+ *
8
+ * { kind: 'sadis-workbench', report, plan?, before? }
9
+ *
10
+ * holds a report's `toJSON()` (the ranked candidates, each with its score's
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
13
+ * shows (`{ title, chartType, data, options }`, `chartType` a CHART_CATALOG key)
14
+ * and notes for what a chart cannot show; a view needing what the document
15
+ * lacks (a plan, perspectives, an earlier report) is not offered.
16
+ *
17
+ * DOM-free: a workspace page puts each chart in a ChartPart.
18
+ */
19
+
20
+ const KIND = 'sadis-workbench';
21
+ const DAY = 24 * 60 * 60 * 1000;
22
+ const pct = (share) => Math.round(share * 1000) / 10;
23
+ const round = (n) => Math.round(n * 1000) / 1000;
24
+
25
+ /** One view of the workbench: what it is called, whether the document can fill it, its charts and notes. */
26
+ export class WorkbenchView {
27
+ static id = 'view';
28
+ static title = 'View';
29
+
30
+ constructor(workbench) { this.workbench = workbench; }
31
+
32
+ get id() { return this.constructor.id; }
33
+ get title() { return this.constructor.title; }
34
+
35
+ /** Whether the document holds what this view needs. */
36
+ available() { return true; }
37
+
38
+ /** @returns {Array<{title: string, chartType: string, data: *, options?: object}>} */
39
+ charts() { return []; }
40
+
41
+ /** Plain lines for what the charts cannot show. */
42
+ notes() { return []; }
43
+
44
+ toJSON() {
45
+ return { id: this.id, title: this.title, charts: this.charts(), notes: this.notes() };
46
+ }
47
+ }
48
+
49
+ /** What to build next, how the scores are made up, how fast value arrives. */
50
+ export class ExecutiveView extends WorkbenchView {
51
+ static id = 'executive';
52
+ static title = 'Executive';
53
+
54
+ charts() {
55
+ const { entries, plan } = this.workbench;
56
+ const top = entries[0];
57
+ const charts = [{ title: `Build next: ${top.name}`, chartType: 'kpi-tile', data: { value: round(top.score) } }];
58
+ if (plan) {
59
+ const half = plan.value.find((v) => v.share >= 0.5);
60
+ const last = plan.value[plan.value.length - 1];
61
+ charts.push(half
62
+ ? { title: `Half the recommended value delivered by period ${half.period} (%)`, chartType: 'kpi-tile', data: { value: pct(half.share) } }
63
+ : { title: `Value delivered by period ${last?.period ?? 0} (%)`, chartType: 'kpi-tile', data: { value: pct(last?.share ?? 0) } });
64
+ }
65
+ charts.push({
66
+ title: 'Score, part by part',
67
+ chartType: 'stackedBar',
68
+ data: ['basin', 'unlock', 'influence'].map((part) => ({
69
+ name: { basin: 'rating', unlock: 'unlock', influence: 'influence' }[part],
70
+ values: entries.map((e) => ({ label: e.name, value: round(e.flow[part]) })),
71
+ })),
72
+ });
73
+ if (plan) {
74
+ charts.push({
75
+ title: 'Value delivered by the end of each period (%)',
76
+ chartType: 'line',
77
+ data: plan.value.map((v) => ({ label: `P${v.period}`, value: pct(v.share) })),
78
+ });
79
+ }
80
+ return charts;
81
+ }
82
+
83
+ notes() {
84
+ const top = this.workbench.entries[0];
85
+ return [`#1 ${top.name} (${top.type}): score ${round(top.score)}`];
86
+ }
87
+ }
88
+
89
+ /** Where value hides: the rating each candidate shows against what it unlocks and reaches. */
90
+ export class ArchitectureView extends WorkbenchView {
91
+ static id = 'architecture';
92
+ static title = 'Architecture';
93
+
94
+ charts() {
95
+ const { entries } = this.workbench;
96
+ return [
97
+ {
98
+ title: 'Visible rating against basin position (unlock + influence): foundations sit high and left',
99
+ chartType: 'scatter',
100
+ data: entries.map((e) => ({ label: e.name, x: round(e.flow.basin), y: round(e.flow.unlock + e.flow.influence), type: e.type })),
101
+ options: { colorCol: 'type' },
102
+ },
103
+ {
104
+ title: 'Share of other candidates each one frees (%)',
105
+ chartType: 'lollipop',
106
+ data: [...entries].sort((a, b) => b.basin.unlock - a.basin.unlock).map((e) => ({ label: e.name, value: pct(e.basin.unlock) })),
107
+ },
108
+ ];
109
+ }
110
+
111
+ notes() {
112
+ return this.workbench.entries
113
+ .filter((e) => e.basin.unlocked.length)
114
+ .map((e) => `${e.name} frees ${e.basin.unlocked.join(', ')}`);
115
+ }
116
+ }
117
+
118
+ /** The delivery plan: when each candidate is worked on, and how full each period is. */
119
+ export class DeliveryView extends WorkbenchView {
120
+ static id = 'delivery';
121
+ static title = 'Delivery';
122
+
123
+ available() { return Boolean(this.workbench.plan); }
124
+
125
+ /** Period `p` (from 1) as the date it starts: the workbench's calendar. */
126
+ periodStart(p) {
127
+ const { start, periodDays } = this.workbench.calendar;
128
+ return new Date(start + (p - 1) * periodDays * DAY).toISOString().slice(0, 10);
129
+ }
130
+
131
+ /** Each planned candidate's first period worked and the period it is finished in. */
132
+ spans() {
133
+ const { plan } = this.workbench;
134
+ const start = new Map();
135
+ const end = new Map();
136
+ for (const p of plan.periods) {
137
+ for (const id of p.working ?? p.items) if (!start.has(id)) start.set(id, p.period);
138
+ for (const id of p.items) end.set(id, p.period);
139
+ }
140
+ return [...end].map(([id, finished]) => ({ id, start: start.get(id) ?? finished, end: finished }));
141
+ }
142
+
143
+ charts() {
144
+ const { plan } = this.workbench;
145
+ const name = (id) => this.workbench.entry(id)?.name ?? id;
146
+ const { periodDays } = this.workbench.calendar;
147
+ const charts = [{
148
+ title: `Delivery plan (${plan.order} order${plan.search?.swaps ? `, searched: ${plan.search.swaps} swaps` : ''}): ${periodDays}-day periods from ${this.periodStart(1)}`,
149
+ chartType: 'gantt',
150
+ data: this.spans().map((s) => ({ label: name(s.id), start: this.periodStart(s.start), end: this.periodStart(s.end + 1), category: this.workbench.entry(s.id)?.type })),
151
+ options: { dateFormat: 'iso', showToday: false },
152
+ }];
153
+ const teams = plan.periods[0]?.teams ? Object.keys(plan.periods[0].teams) : null;
154
+ charts.push({
155
+ title: 'Capacity used per period',
156
+ chartType: 'stackedBar',
157
+ data: teams
158
+ ? teams.map((team) => ({ name: team, values: plan.periods.map((p) => ({ label: `P${p.period}`, value: p.teams[team].used })) }))
159
+ : [
160
+ { name: 'used', values: plan.periods.map((p) => ({ label: `P${p.period}`, value: p.used })) },
161
+ { name: 'free', values: plan.periods.map((p) => ({ label: `P${p.period}`, value: p.capacity - p.used })) },
162
+ ],
163
+ });
164
+ return charts;
165
+ }
166
+
167
+ notes() {
168
+ const name = (id) => this.workbench.entry(id)?.name ?? id;
169
+ return this.workbench.plan.excluded.map((x) => `Not planned: ${name(x.id)} (${x.why})`);
170
+ }
171
+ }
172
+
173
+ /** Where the stakeholders' perspectives agree and where they pull apart. */
174
+ export class PortfolioView extends WorkbenchView {
175
+ static id = 'portfolio';
176
+ static title = 'Portfolio';
177
+
178
+ available() { return Boolean(this.workbench.report.perspectives); }
179
+
180
+ charts() {
181
+ const { entries } = this.workbench;
182
+ const n = entries.length;
183
+ const contested = entries.filter((e) => e.spread > 0).sort((a, b) => b.spread - a.spread || a.rank - b.rank);
184
+ return [
185
+ {
186
+ title: 'Rank by perspective (darker: ranked higher)',
187
+ chartType: 'heatmap',
188
+ data: entries.flatMap((e) => e.perspectives.map((p) => ({ x: p.id, y: e.name, value: n - p.rank + 1 }))),
189
+ },
190
+ {
191
+ title: 'Best and worst rank across perspectives, most contested first',
192
+ chartType: 'dumbbell',
193
+ data: contested.map((e) => {
194
+ const ranks = e.perspectives.filter((p) => p.weight > 0).map((p) => p.rank);
195
+ return { label: e.name, start: Math.min(...ranks), end: Math.max(...ranks) };
196
+ }),
197
+ },
198
+ ];
199
+ }
200
+
201
+ notes() {
202
+ return Object.entries(this.workbench.report.perspectives).map(([id, weight]) => `${id}: weight ${weight}`);
203
+ }
204
+ }
205
+
206
+ /** What changed since the earlier report: ranks then and now, what entered and what left. */
207
+ export class ReplayView extends WorkbenchView {
208
+ static id = 'replay';
209
+ static title = 'Replay';
210
+
211
+ available() { return Boolean(this.workbench.before); }
212
+
213
+ /** Each candidate's rank before and now; those only now, and those only before. */
214
+ change() {
215
+ const was = new Map(this.workbench.before.entries.map((e) => [e.id, e]));
216
+ const now = this.workbench.entries;
217
+ return {
218
+ moved: now.filter((e) => was.has(e.id)).map((e) => ({ id: e.id, name: e.name, from: was.get(e.id).rank, to: e.rank })),
219
+ entered: now.filter((e) => !was.has(e.id)),
220
+ left: this.workbench.before.entries.filter((e) => !this.workbench.entry(e.id)),
221
+ };
222
+ }
223
+
224
+ charts() {
225
+ const { moved } = this.change();
226
+ const when = (report) => report.asOf?.businessTime?.slice(0, 10);
227
+ const then = when(this.workbench.before) ?? 'then';
228
+ const now = when(this.workbench.report) ?? 'now';
229
+ return [{
230
+ title: `Rank ${then} → ${now} (1 = first)`,
231
+ chartType: 'slopegraph',
232
+ data: moved.map((m) => ({ label: m.name, start: m.from, end: m.to })),
233
+ }];
234
+ }
235
+
236
+ notes() {
237
+ const { moved, entered, left } = this.change();
238
+ const superseded = new Map((this.workbench.report.superseded ?? []).map((s) => [s.id, s.by]));
239
+ return [
240
+ ...entered.map((e) => `${e.name} entered (#${e.rank})`),
241
+ ...left.map((e) => `${e.name} left${superseded.has(e.id) ? ` (superseded by ${superseded.get(e.id).join(', ')})` : ''}`),
242
+ ...moved.filter((m) => m.from !== m.to).sort((a, b) => Math.abs(b.from - b.to) - Math.abs(a.from - a.to))
243
+ .map((m) => `${m.name} ${m.to < m.from ? 'up' : 'down'} ${Math.abs(m.from - m.to)} (#${m.from} → #${m.to})`),
244
+ ];
245
+ }
246
+ }
247
+
248
+ export const WORKBENCH_VIEWS = Object.freeze([ExecutiveView, ArchitectureView, DeliveryView, PortfolioView, ReplayView]);
249
+
250
+ export class DecisionWorkbench {
251
+ static KIND = KIND;
252
+
253
+ /** A workbench from a workbench document, checked; refused with why when it is not one. */
254
+ static from(doc, calendar) {
255
+ if (!doc || typeof doc !== 'object') throw new Error('Not a decision workbench document');
256
+ if (doc.kind !== KIND) throw new Error(`Not a decision workbench document: kind is ${JSON.stringify(doc.kind)}, not "${KIND}"`);
257
+ const isReport = (r) => r && Array.isArray(r.entries) && r.entries.every((e) => e && e.id && e.flow && e.basin && Number.isFinite(e.rank));
258
+ if (!isReport(doc.report)) throw new Error('A decision workbench document needs a report: decisionengine\'s report.toJSON(), with ranked entries');
259
+ if (!doc.report.entries.length) throw new Error('The report ranks no candidates');
260
+ if (doc.plan !== undefined && !(doc.plan && Array.isArray(doc.plan.periods) && Array.isArray(doc.plan.value))) {
261
+ throw new Error('A plan, when given, is decisionengine\'s plan.toJSON(): periods and value');
262
+ }
263
+ if (doc.before !== undefined && !isReport(doc.before)) throw new Error('An earlier report, when given, is a report.toJSON() too');
264
+ return new DecisionWorkbench(doc, calendar);
265
+ }
266
+
267
+ /**
268
+ * @param {{report: object, plan?: object, before?: object}} doc
269
+ * @param {{start?: string|number, periodDays?: number}} [calendar] - when period 1 starts (default: the report's
270
+ * as-of business time, else today) and how long a period is (default 14 days), for the delivery plan's dates
271
+ */
272
+ constructor({ report, plan = null, before = null }, { start, periodDays = 14 } = {}) {
273
+ const from = start ?? report.asOf?.businessTime ?? Date.now();
274
+ const ms = typeof from === 'number' ? from : Date.parse(from);
275
+ if (!Number.isFinite(ms)) throw new Error(`The calendar's start is ${JSON.stringify(start)}, not a date`);
276
+ if (!(Number.isFinite(periodDays) && periodDays > 0)) throw new Error('periodDays must be a number of days above 0');
277
+ this.calendar = Object.freeze({ start: ms, periodDays });
278
+ this.report = report;
279
+ this.plan = plan;
280
+ this.before = before;
281
+ this.entries = [...report.entries].sort((a, b) => a.rank - b.rank);
282
+ this.byId = new Map(this.entries.map((e) => [e.id, e]));
283
+ }
284
+
285
+ entry(id) { return this.byId.get(id); }
286
+
287
+ /** The views this document can fill, in order. */
288
+ views() {
289
+ return WORKBENCH_VIEWS.map((View) => new View(this)).filter((view) => view.available());
290
+ }
291
+
292
+ view(id) {
293
+ return this.views().find((v) => v.id === id) ?? null;
294
+ }
295
+ }
@@ -20,13 +20,13 @@ export class ArchimateDiagram extends Diagram {
20
20
  const { elements, relationships } = this.data;
21
21
  const options = this.options;
22
22
  const {
23
- layerColors, relationshipStyles, defaultIcons, iconChoiceIndex = 0, elementWidth = 120, elementHeight = 55,
23
+ layerColors, elementColors, relationshipStyles, defaultIcons, iconChoiceIndex = 0, elementWidth = 120, elementHeight = 55,
24
24
  archimateVersion = '4', useDistinctShapes = false, iconSet = 'glyph',
25
25
  validate = true, invalidRelationships = 'highlight', onValidationWarning, annotations = [], viewpoint = null,
26
26
  viewpointMode = 'fade', customViewpoints = [], ...rest
27
27
  } = options;
28
28
  const nodeOpts = {
29
- layerColors, defaultIcons, iconChoiceIndex, elementWidth, elementHeight,
29
+ layerColors, elementColors, defaultIcons, iconChoiceIndex, elementWidth, elementHeight,
30
30
  archimateVersion, useDistinctShapes, iconSet,
31
31
  };
32
32
  const nodes = elements.map(element => ArchimateDiagram.buildNode(element, nodeOpts));
@@ -79,6 +79,12 @@ export class ArchimateDiagram extends Diagram {
79
79
  annotation.shape = 'note';
80
80
  annotation.labelPosition = annotation.labelPosition || 'inside';
81
81
  annotation.resizable = annotation.resizable !== false;
82
+ if (annotation.reference || annotation.referencedModel || annotation.viewRef || annotation.isViewReference) {
83
+ annotation.isViewReference = true;
84
+ const refId = annotation.reference || annotation.referencedModel || annotation.viewRef;
85
+ annotation.referencedModel = refId;
86
+ annotation.tooltip = annotation.tooltip || `View: ${annotation.label || refId} (double-click to open)`;
87
+ }
82
88
  visibleNodes.push(annotation);
83
89
  }
84
90
 
@@ -117,7 +123,7 @@ export class ArchimateDiagram extends Diagram {
117
123
  // Diagram-wide ArchiMate settings survive a rerender so live icon-choice
118
124
  // changes can be applied later (see RenderEngine._archimateSetIconChoiceIndex).
119
125
  this.engine._archimateOptions = {
120
- layerColors, defaultIcons, iconChoiceIndex, relationshipStyles, elementWidth, elementHeight,
126
+ layerColors, elementColors, defaultIcons, iconChoiceIndex, relationshipStyles, elementWidth, elementHeight,
121
127
  archimateVersion, useDistinctShapes, iconSet, viewpoint,
122
128
  viewpointMode, customViewpoints,
123
129
  };
@@ -195,15 +201,18 @@ export class ArchimateDiagram extends Diagram {
195
201
  }
196
202
 
197
203
  static buildNode(element, {
198
- layerColors, defaultIcons, iconChoiceIndex = 0, elementWidth = 120, elementHeight = 55,
204
+ layerColors, elementColors, defaultIcons, iconChoiceIndex = 0, elementWidth = 120, elementHeight = 55,
199
205
  archimateVersion = '4', useDistinctShapes = false, iconSet = 'glyph',
200
206
  } = {}) {
201
207
  const resolved = archimateVersion === '4' ? ArchimateNotation.migrateElementToV4(element) : element;
202
208
  ArchimateSpecializationRegistry.applyToNode(resolved);
203
209
  const layer = ArchimateDiagram.elementLayer(resolved, archimateVersion);
204
210
  const type = ArchimateDiagram.resolveElementType(resolved.type, archimateVersion);
205
- const useDistinctShape = resolved.useDistinctShape ?? useDistinctShapes;
206
- const notationShape = useDistinctShape ? type?.notationShape : undefined;
211
+ const isOrJunction = resolved.type === 'or-junction'
212
+ || ((resolved.type === 'junction' || resolved.type === 'and-junction') && (resolved.junctionType === 'or' || resolved.attributes?.type === 'or' || resolved.archi?.attributes?.type === 'or'));
213
+ const isJunction = resolved.type === 'junction' || resolved.type === 'and-junction' || resolved.type === 'or-junction';
214
+ const useDistinctShape = isJunction ? true : (resolved.useDistinctShape ?? useDistinctShapes);
215
+ const notationShape = useDistinctShape ? (isJunction ? (isOrJunction ? 'junction-or' : 'junction') : type?.notationShape) : undefined;
207
216
  const glyphBadges = (resolved.iconSet ?? iconSet) !== 'emoji';
208
217
 
209
218
  // CRITICAL: Use diagramInstanceId (not elementId) as the node's unique ID
@@ -213,14 +222,21 @@ export class ArchimateDiagram extends Diagram {
213
222
  // icon, and a plain white fill unless it sets one (a note keeps its own default).
214
223
  const viewBox = resolved.viewObject && resolved.shape !== 'note';
215
224
 
225
+ const defaultW = isJunction ? 18 : elementWidth;
226
+ const defaultH = isJunction ? 18 : elementHeight;
227
+ const typeColor = elementColors?.[type?.name] ?? elementColors?.[resolved.type];
228
+ const defaultColor = typeColor || (isJunction
229
+ ? (isOrJunction ? '#ffffff' : '#000000')
230
+ : (viewBox ? '#ffffff' : ArchimateDiagram.layerColor(layer, layerColors, type?.ownColor)));
231
+
216
232
  return {
217
233
  ...resolved,
218
234
  id: nodeId, // Override to use diagramInstanceId for uniqueness
219
235
  layer,
220
236
  shape: resolved.shape || notationShape || 'rect',
221
- color: resolved.color || (viewBox ? '#ffffff' : ArchimateDiagram.layerColor(layer, layerColors, type?.ownColor)),
222
- width: resolved.width ?? elementWidth,
223
- height: resolved.height ?? elementHeight,
237
+ color: resolved.color || defaultColor,
238
+ width: resolved.width ?? defaultW,
239
+ height: resolved.height ?? defaultH,
224
240
  strokeColor: resolved.strokeColor || '#000000',
225
241
  strokeWidth: resolved.strokeWidth ?? 1.2,
226
242
  // The ArchiMate notation draws a Grouping with a dashed outline.