@peter.prib/renderengine 0.12.0 → 0.13.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/README.md +10 -4
- package/dist/renderengine-workspace.umd.js +1920 -1055
- package/dist/renderengine.umd.js +1901 -1052
- package/documentation/REFERENCE.md +66 -5
- package/package.json +1 -1
- package/src/charting/Axis.js +47 -5
- package/src/charting/DiagramEditor.js +239 -34
- package/src/charting/ModelFileFormat.js +47 -0
- package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
- package/src/charting/archimate/deleteFromModel.js +57 -0
- package/src/charting/archimate/elementMenuItems.js +3 -1
- package/src/charting/archimate/heatMaps.js +169 -0
- package/src/charting/archimate/index.js +1 -0
- package/src/charting/charts/BarChart.js +5 -5
- package/src/charting/charts/BarChartBase.js +21 -3
- package/src/charting/charts/GroupedBarChart.js +7 -9
- package/src/charting/charts/TableChart.js +18 -2
- package/src/charting/diagrams/ArchimateModel.js +33 -0
- package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
- package/src/charting/diagrams/EdgeGeometry.js +16 -0
- package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
- package/src/charting/pivot/PivotGridChart.js +1 -1
- package/src/charting/pivot/PivotView.js +11 -2
- package/src/charting/table/TableView.js +10 -1
- package/src/charting/table/TableViewChart.js +1 -1
- package/src/charting/templates/Template.js +10 -0
- package/src/exportImage.js +45 -18
- package/src/index.js +17 -4
- package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
- package/src/workspace/parts/DiagramEditorPart.js +2 -0
- package/src/workspace/parts/ModelHosted.js +15 -4
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { FormDialog } from '../FormDialog.js';
|
|
2
|
+
import { typeTitle } from './archimateNames.js';
|
|
3
|
+
|
|
4
|
+
/** At most this many views or relationships are named; the rest are counted. */
|
|
5
|
+
const LISTED = 10;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What deleting elements from the model takes with it, in words, for the
|
|
9
|
+
* confirmation: the views they are on (and how many boxes), and the
|
|
10
|
+
* relationships that go too (ArchimateModel#usage).
|
|
11
|
+
* @param {ArchimateModel} model
|
|
12
|
+
* @param {Array<string>} elementIds
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function deleteFromModelMessage(model, elementIds) {
|
|
16
|
+
const elements = elementIds.map(id => model.getElement(id)).filter(Boolean);
|
|
17
|
+
const name = (el) => `"${el.name || el.id}"`;
|
|
18
|
+
const { views, relationships } = model.usage(elementIds);
|
|
19
|
+
const list = (items) => [...items.slice(0, LISTED).map(s => ` • ${s}`), ...(items.length > LISTED ? [` • and ${items.length - LISTED} more`] : [])];
|
|
20
|
+
const end = (id) => { const c = model.getElement(id) ?? model.getRelationship(id); return c ? `${c.name || typeTitle(c.type ?? c.kind ?? '') || c.id}` : id; };
|
|
21
|
+
const lines = [
|
|
22
|
+
elements.length === 1 ? `${name(elements[0])} (${typeTitle(elements[0].type ?? '')}) goes from the model.` : `These ${elements.length} elements go from the model: ${elements.map(name).join(', ')}.`,
|
|
23
|
+
'',
|
|
24
|
+
views.length
|
|
25
|
+
? `Used in ${views.length} view${views.length === 1 ? '' : 's'}, where ${elements.length === 1 ? 'its' : 'their'} boxes go:`
|
|
26
|
+
: 'Used in no view.',
|
|
27
|
+
...list(views.map(({ view, boxes }) => `${view.name || view.id}${boxes > 1 ? ` (${boxes} boxes)` : ''}`)),
|
|
28
|
+
'',
|
|
29
|
+
relationships.length
|
|
30
|
+
? `${relationships.length} relationship${relationships.length === 1 ? '' : 's'} go${relationships.length === 1 ? 'es' : ''} too:`
|
|
31
|
+
: 'No relationships.',
|
|
32
|
+
...list(relationships.map(r => `${end(r.source)} → ${end(r.target)} (${typeTitle(r.kind ?? r.type ?? '') || 'relationship'}${r.name ? `: ${r.name}` : ''})`)),
|
|
33
|
+
'',
|
|
34
|
+
'Undo puts them all back.',
|
|
35
|
+
];
|
|
36
|
+
return lines.join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Ask before deleting elements from the model: a dialog saying where they are
|
|
41
|
+
* used and what goes with them; **Delete from model** runs `onConfirm()`.
|
|
42
|
+
* @returns {FormDialog|null} null when none of the ids is an element
|
|
43
|
+
*/
|
|
44
|
+
export function askDeleteFromModel(model, elementIds, { onConfirm, document: doc = globalThis.document } = {}) {
|
|
45
|
+
const ids = elementIds.filter(id => model.getElement(id));
|
|
46
|
+
if (!ids.length) return null;
|
|
47
|
+
const dialog = new FormDialog({
|
|
48
|
+
title: ids.length === 1 ? 'Delete from model' : `Delete ${ids.length} elements from model`,
|
|
49
|
+
description: deleteFromModelMessage(model, ids),
|
|
50
|
+
fields: [],
|
|
51
|
+
submitLabel: 'Delete from model',
|
|
52
|
+
document: doc,
|
|
53
|
+
onApply: () => onConfirm(ids),
|
|
54
|
+
});
|
|
55
|
+
dialog.open();
|
|
56
|
+
return dialog;
|
|
57
|
+
}
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* @param {(element) => void} host.editProperties
|
|
12
12
|
* @param {(view, instanceId) => void} host.showIn - Open `view` with that box selected
|
|
13
13
|
* @param {(element) => void} [host.addToView] - Show it on `view` (offered when it is not there)
|
|
14
|
+
* @param {(element) => void} [host.deleteFromModel] - Delete it from the model, after saying where it is used
|
|
14
15
|
* @returns {Array<Object>} menu items
|
|
15
16
|
*/
|
|
16
|
-
export function elementMenuItems(element, { model, view = null, editProperties, showIn, addToView = null }) {
|
|
17
|
+
export function elementMenuItems(element, { model, view = null, editProperties, showIn, addToView = null, deleteFromModel = null }) {
|
|
17
18
|
const placed = model.views()
|
|
18
19
|
.map(v => ({ view: v, instance: v.elementInstancesFor?.(element.id)?.[0] }))
|
|
19
20
|
.filter(({ instance }) => instance);
|
|
@@ -28,5 +29,6 @@ export function elementMenuItems(element, { model, view = null, editProperties,
|
|
|
28
29
|
: { label: 'Show in View (on none)', disabled: true, action: () => {} },
|
|
29
30
|
...(addToView && view && !onThisView ? [{ label: `Add to "${view.name || 'this view'}"`, action: () => addToView(element) }] : []),
|
|
30
31
|
{ label: 'Generate View For This', action: () => model.generateView([element.id]) },
|
|
32
|
+
...(deleteFromModel ? [{ separator: true }, { label: '🗑 Delete from model…', danger: true, action: () => deleteFromModel(element) }] : []),
|
|
31
33
|
];
|
|
32
34
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { compileFormula } from '../../kernelExpr.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Heat maps an ArchiMate view can be coloured by beyond its elements' own
|
|
5
|
+
* fields (ArchimateCanvasEditor#colourKeys): what change a deliverable or work
|
|
6
|
+
* package makes to each element, and a formula over the elements' numeric
|
|
7
|
+
* properties. Pure: no editor, no DOM.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The change heat map's colours. */
|
|
11
|
+
export const CHANGE_COLOURS = Object.freeze({ Create: '#FDD835', Update: '#1E88E5', Delete: '#D81B60' });
|
|
12
|
+
|
|
13
|
+
/** Element types that make changes: their relationships to an element say what they do to it. */
|
|
14
|
+
export const CHANGE_MAKERS = Object.freeze(['deliverable', 'work-package']);
|
|
15
|
+
|
|
16
|
+
/** The words for each change, synonyms included (verbs as base forms; their -s, -ed, -ing forms match too). */
|
|
17
|
+
export const CHANGE_WORDS = Object.freeze({
|
|
18
|
+
Delete: ['delete', 'remove', 'retire', 'decommission', 'drop', 'sunset', 'terminate', 'eliminate', 'discontinue', 'deprecate', 'withdraw', 'phase out', 'shut down', 'switch off'],
|
|
19
|
+
Create: ['create', 'add', 'new', 'introduce', 'build', 'built', 'establish', 'implement', 'insert', 'provision', 'set up', 'launch', 'develop', 'onboard'],
|
|
20
|
+
Update: ['update', 'change', 'modify', 'amend', 'enhance', 'upgrade', 'improve', 'revise', 'extend', 'migrate', 'refactor', 'replace', 'rework', 'adapt', 'alter', 'patch'],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** Which change wins when an element is given more than one: the one with most consequence. */
|
|
24
|
+
const PRECEDENCE = ['Delete', 'Create', 'Update'];
|
|
25
|
+
|
|
26
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A word and its inflections: retire → retires, retired, retiring; drop →
|
|
30
|
+
* dropped, dropping; modify → modifies, modified, modifying.
|
|
31
|
+
*/
|
|
32
|
+
function forms(word) {
|
|
33
|
+
const [verb, ...rest] = word.split(' ');
|
|
34
|
+
const tail = rest.length ? `\\s+${rest.map(escape).join('\\s+')}` : '';
|
|
35
|
+
const stem = verb.endsWith('e') ? verb.slice(0, -1) : verb;
|
|
36
|
+
const doubled = /[^aeiou][aeiou][bdgmnpt]$/.test(verb) ? `|${escape(verb)}${verb.at(-1)}(?:ed|ing)` : '';
|
|
37
|
+
const y = /[^aeiou]y$/.test(verb) ? `|${escape(verb.slice(0, -1))}i(?:es|ed)` : '';
|
|
38
|
+
return `(?:${escape(verb)}(?:s|es|d|ed)?|${escape(stem)}(?:ed|ing|es)${doubled}${y})${tail}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const PATTERNS = Object.fromEntries(Object.entries(CHANGE_WORDS)
|
|
42
|
+
.map(([change, words]) => [change, new RegExp(`\\b(?:${words.map(forms).join('|')})\\b`, 'i')]));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The change a piece of text names ('Create', 'Update' or 'Delete'), or null.
|
|
46
|
+
* Where it names more than one, the one with most consequence (delete, then
|
|
47
|
+
* create, then update).
|
|
48
|
+
*/
|
|
49
|
+
export function changeAction(text) {
|
|
50
|
+
if (!text) return null;
|
|
51
|
+
return PRECEDENCE.find(change => PATTERNS[change].test(String(text))) ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The change one relationship says a deliverable or work package makes: its
|
|
56
|
+
* name, or, when it has none, the name of the deliverable or work package at
|
|
57
|
+
* one of its ends (`toward`: the end that is not the element changed, when
|
|
58
|
+
* known). Null when neither end makes changes, or nothing is said.
|
|
59
|
+
* @returns {'Create'|'Update'|'Delete'|null}
|
|
60
|
+
*/
|
|
61
|
+
export function relationshipChange(model, rel, toward = null) {
|
|
62
|
+
const ends = toward ? [toward] : [rel.source, rel.target];
|
|
63
|
+
const maker = ends.map(id => model.getElement(id)).find(e => e && CHANGE_MAKERS.includes(e.type));
|
|
64
|
+
if (!maker) return null;
|
|
65
|
+
return changeAction((rel.name ?? '').trim() ? rel.name : maker.name);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What change the deliverables and work packages related to an element make
|
|
70
|
+
* to it: for each relationship between them (either way), relationshipChange.
|
|
71
|
+
* Where they differ, the one with most consequence. Null when none says.
|
|
72
|
+
* @param {ArchimateModel} model
|
|
73
|
+
* @param {string} elementId
|
|
74
|
+
* @returns {'Create'|'Update'|'Delete'|null}
|
|
75
|
+
*/
|
|
76
|
+
export function elementChange(model, elementId) {
|
|
77
|
+
const found = new Set();
|
|
78
|
+
for (const rel of model.relationships()) {
|
|
79
|
+
const other = rel.source === elementId ? rel.target : rel.target === elementId ? rel.source : null;
|
|
80
|
+
const change = other && relationshipChange(model, rel, other);
|
|
81
|
+
if (change) found.add(change);
|
|
82
|
+
}
|
|
83
|
+
return PRECEDENCE.find(change => found.has(change)) ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether a model has anything that makes changes (a deliverable or work package). */
|
|
87
|
+
export function hasChangeMakers(model) {
|
|
88
|
+
return model.elements().some(e => CHANGE_MAKERS.includes(e.type));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The names a formula can use for the properties: each property name as an
|
|
93
|
+
* identifier (anything not a letter, digit or _ becomes _; a leading digit gets
|
|
94
|
+
* a _ before it), mapped to the property it stands for.
|
|
95
|
+
* @param {Array<Object>} elements
|
|
96
|
+
* @returns {Map<string, string>} identifier -> property name
|
|
97
|
+
*/
|
|
98
|
+
export function propertyParams(elements) {
|
|
99
|
+
const params = new Map();
|
|
100
|
+
for (const e of elements) {
|
|
101
|
+
for (const p of e.properties ?? []) {
|
|
102
|
+
const name = p.name ?? p.key;
|
|
103
|
+
if (!name) continue;
|
|
104
|
+
let id = String(name).replace(/[^A-Za-z0-9_]/g, '_');
|
|
105
|
+
if (/^\d/.test(id)) id = `_${id}`;
|
|
106
|
+
if (!params.has(id)) params.set(id, name);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return params;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A formula's result for each element (by id): the formula over the
|
|
114
|
+
* element's numeric properties, by their propertyParams names. An element
|
|
115
|
+
* missing a property the formula uses, or whose value is not a number, or
|
|
116
|
+
* whose result is not a finite number, has none.
|
|
117
|
+
* Throws the formula's own error when it does not parse (an unknown name, say).
|
|
118
|
+
* @returns {Map<string, number>}
|
|
119
|
+
*/
|
|
120
|
+
export function expressionValues(elements, formula) {
|
|
121
|
+
const params = propertyParams(elements);
|
|
122
|
+
const names = [...params.keys()];
|
|
123
|
+
if (!names.length) throw new Error('No element here has a property to use');
|
|
124
|
+
const fn = compileFormula(formula, names);
|
|
125
|
+
const used = names.filter(n => new RegExp(`\\b${n}\\b`).test(formula));
|
|
126
|
+
const results = new Map();
|
|
127
|
+
for (const e of elements) {
|
|
128
|
+
const valueOf = (id) => {
|
|
129
|
+
const p = (e.properties ?? []).find(q => (q.name ?? q.key) === params.get(id));
|
|
130
|
+
const n = p === undefined || p.value === null || p.value === '' ? NaN : Number(p.value);
|
|
131
|
+
return n;
|
|
132
|
+
};
|
|
133
|
+
if (used.some(id => !Number.isFinite(valueOf(id)))) continue;
|
|
134
|
+
const result = fn(...names.map(id => { const v = valueOf(id); return Number.isFinite(v) ? v : 0; }));
|
|
135
|
+
if (Number.isFinite(result)) results.set(e.id, result);
|
|
136
|
+
}
|
|
137
|
+
return results;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A number as a label: integers as they are, others to at most 3 decimals. */
|
|
141
|
+
const show = (n) => (Number.isInteger(n) ? String(n) : String(Number(n.toFixed(3))));
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Labels for a formula's results: each distinct result its own, in order,
|
|
145
|
+
* when there are at most `distinct` of them; else `bands` equal-width bands
|
|
146
|
+
* from the smallest to the largest ("10 – 20"), the last taking in the largest;
|
|
147
|
+
* a band no result falls in is left out.
|
|
148
|
+
* @param {Iterable<number>} numbers
|
|
149
|
+
* @returns {{labels: string[], labelOf: (n: number) => string}}
|
|
150
|
+
*/
|
|
151
|
+
export function resultBands(numbers, { distinct = 8, bands = 5 } = {}) {
|
|
152
|
+
const values = [...new Set(numbers)].sort((a, b) => a - b);
|
|
153
|
+
if (values.length <= distinct) return { labels: values.map(show), labelOf: show };
|
|
154
|
+
const lo = values[0], hi = values.at(-1), width = (hi - lo) / bands;
|
|
155
|
+
const all = Array.from({ length: bands }, (_, i) => `${show(lo + i * width)} – ${show(i === bands - 1 ? hi : lo + (i + 1) * width)}`);
|
|
156
|
+
const labelOf = (n) => all[Math.min(bands - 1, Math.floor((n - lo) / width))];
|
|
157
|
+
const used = new Set(values.map(labelOf));
|
|
158
|
+
return { labels: all.filter(l => used.has(l)), labelOf };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** `count` colours from light to dark (a sequential scale, light yellow to dark red), for ordered values. */
|
|
162
|
+
export function sequentialColours(count) {
|
|
163
|
+
const from = [255, 247, 188], to = [189, 0, 38];
|
|
164
|
+
const hex = (c) => Math.round(c).toString(16).padStart(2, '0');
|
|
165
|
+
return Array.from({ length: count }, (_, i) => {
|
|
166
|
+
const t = count === 1 ? 1 : i / (count - 1);
|
|
167
|
+
return `#${from.map((f, k) => hex(f + (to[k] - f) * t)).join('')}`;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -15,3 +15,4 @@ export { ViewGroupTemplate, GROUP_FILL } from './ViewGroupTemplate.js';
|
|
|
15
15
|
export { CustomViewpoints, CUSTOM_VIEWPOINTS_STORE } from './CustomViewpoints.js';
|
|
16
16
|
export { specializationCatalogDialog } from './specializationCatalogDialog.js';
|
|
17
17
|
export { elementTypeChoices } from './elementTypeChoices.js';
|
|
18
|
+
export { askDeleteFromModel, deleteFromModelMessage } from './deleteFromModel.js';
|
|
@@ -17,7 +17,7 @@ export class BarChart extends BarChartBase {
|
|
|
17
17
|
_computeMaxValue() {
|
|
18
18
|
const data = this.data;
|
|
19
19
|
const rawMax = Math.max(...data.map(d => d.value)) || 1;
|
|
20
|
-
return this._drawYAxis(rawMax);
|
|
20
|
+
return this._drawYAxis(rawMax, Math.min(0, ...data.map(d => d.value)));
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
_beforeBars(maxValue) {
|
|
@@ -39,12 +39,12 @@ export class BarChart extends BarChartBase {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const color = colors ? this._palette.colorAt(i) : barColor;
|
|
42
|
-
const
|
|
42
|
+
const bar = this._barSpan(d.value);
|
|
43
43
|
const barX = x + i * (bandWidth + margin);
|
|
44
|
-
const barY = y + height - barHeight;
|
|
45
44
|
|
|
46
|
-
this._datum(this.renderer.rect(barX,
|
|
47
|
-
|
|
45
|
+
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 });
|
|
46
|
+
// The value just past the bar's end: above a bar going up, below one going down.
|
|
47
|
+
this.renderer.text(String(d.value), barX + bandWidth / 2, d.value < 0 ? bar.end + 12 : bar.end - 5, { textAnchor, fontSize: '10px', fill: '#333' });
|
|
48
48
|
if (i % labelStep === 0) this.renderer.text(d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
|
|
49
49
|
});
|
|
50
50
|
}
|
|
@@ -38,9 +38,27 @@ export class BarChartBase extends Chart {
|
|
|
38
38
|
this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Draw a Y axis and return the nice-rounded max value. With a negative `rawMin` the axis goes
|
|
43
|
+
* below zero, with a zero line; `_barSpan` then draws each bar from zero, up or down.
|
|
44
|
+
*/
|
|
45
|
+
_drawYAxis(rawMax, rawMin = 0) {
|
|
46
|
+
this._domain = new Axis().drawYRange(this.renderer, this._plotBox, rawMin, rawMax);
|
|
47
|
+
return this._domain.max;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Where a bar of `value` goes on the axis `_drawYAxis` drew: from the zero line up to it, or
|
|
52
|
+
* down to it when negative.
|
|
53
|
+
* @returns {{top: number, height: number, end: number}} `end` is the bar's far end from zero
|
|
54
|
+
*/
|
|
55
|
+
_barSpan(value) {
|
|
56
|
+
const { y, height } = this._plotBox;
|
|
57
|
+
const { min, max } = this._domain;
|
|
58
|
+
const yOf = (v) => y + height - ((v - min) / (max - min)) * height;
|
|
59
|
+
const zero = yOf(0);
|
|
60
|
+
const end = yOf(value);
|
|
61
|
+
return { top: Math.min(zero, end), height: Math.abs(end - zero), end };
|
|
44
62
|
}
|
|
45
63
|
|
|
46
64
|
_render() {
|
|
@@ -10,18 +10,17 @@ import { TableChart } from './TableChart.js';
|
|
|
10
10
|
* Array<{name: string, values: Array<{label: string, value: number}>}>
|
|
11
11
|
*
|
|
12
12
|
* Each category (`label`) gets a cluster of bars side-by-side, one per series. A value a series does
|
|
13
|
-
* not have (missing, or null) is a gap in its cluster — no bar — never a bar of zero.
|
|
13
|
+
* not have (missing, or null) is a gap in its cluster — no bar — never a bar of zero. A negative value
|
|
14
|
+
* is drawn down from a zero line, the axis taking in the smallest value too.
|
|
14
15
|
*/
|
|
15
16
|
export class GroupedBarChart extends BarChartBase {
|
|
16
17
|
static chartType = 'groupedBar';
|
|
17
18
|
|
|
18
19
|
_computeMaxValue() {
|
|
19
20
|
const series = this.data;
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
);
|
|
24
|
-
return this._drawYAxis(rawMax);
|
|
21
|
+
const values = series.flatMap(s => s.values.map(v => v.value).filter(v => v != null));
|
|
22
|
+
const rawMax = Math.max(...values, 1);
|
|
23
|
+
return this._drawYAxis(rawMax, Math.min(0, ...values));
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
_renderBars(maxValue) {
|
|
@@ -63,12 +62,11 @@ export class GroupedBarChart extends BarChartBase {
|
|
|
63
62
|
series.forEach((seriesData, seriesIndex) => {
|
|
64
63
|
const value = valueBySeriesLabel[seriesIndex].get(label);
|
|
65
64
|
if (value == null) return;
|
|
66
|
-
const
|
|
65
|
+
const bar = this._barSpan(value);
|
|
67
66
|
const barX = clusterX + seriesIndex * barWidth + barGap / 2;
|
|
68
|
-
const barY = y + height - barHeight;
|
|
69
67
|
const color = this._palette.colorAt(seriesIndex);
|
|
70
68
|
|
|
71
|
-
this._datum(this.renderer.rect(barX,
|
|
69
|
+
this._datum(this.renderer.rect(barX, bar.top, finalBarWidth, bar.height, color, null, {
|
|
72
70
|
tooltip: `${seriesData.name} — ${label}: ${value}`,
|
|
73
71
|
}), { label, series: seriesData.name });
|
|
74
72
|
});
|
|
@@ -46,14 +46,30 @@ export class TableChart extends Chart {
|
|
|
46
46
|
return top + (1 + shown + more) * TableChart.ROW_H + bottom;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
49
|
+
/**
|
|
50
|
+
* A cell's text: a number exactly (to at most 4 decimals), null/undefined as `nullText`. With a
|
|
51
|
+
* `format` (`formatFor`'s), a number is written by it instead.
|
|
52
|
+
*/
|
|
53
|
+
static cellText(value, nullText = '—', format = null) {
|
|
51
54
|
if (value == null) return nullText;
|
|
55
|
+
if (typeof value === 'number' && format) return String(typeof format === 'function' ? format(value) : format.format(value));
|
|
52
56
|
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)));
|
|
53
57
|
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
|
54
58
|
return String(value);
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The number format for one column or measure, from a view's `format` option: a function
|
|
63
|
+
* (number → text) or an `Intl.NumberFormat` for every number, or an object naming either per
|
|
64
|
+
* column (`{ Amount: new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }) }`),
|
|
65
|
+
* where a column it does not name keeps the plain text. Null when there is none.
|
|
66
|
+
*/
|
|
67
|
+
static formatFor(format, name) {
|
|
68
|
+
if (!format) return null;
|
|
69
|
+
if (typeof format === 'function' || format instanceof Intl.NumberFormat) return format;
|
|
70
|
+
return format[name] ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
_render() {
|
|
58
74
|
const data = this.data ?? [];
|
|
59
75
|
const box = ChartBox.fromRenderer(this.renderer, TableChart.margins(this.options.title));
|
|
@@ -8,6 +8,7 @@ import { ArchimateModelChecker } from './ArchimateModelChecker.js';
|
|
|
8
8
|
import { ArchimateModelReport } from './ArchimateModelReport.js';
|
|
9
9
|
import { ArchimateModelSearch } from './ArchimateModelSearch.js';
|
|
10
10
|
import { MergeElementsCommand } from './MergeElementsCommand.js';
|
|
11
|
+
import { DeleteFromModelCommand } from './DeleteFromModelCommand.js';
|
|
11
12
|
import { ElementsCompared, RelationshipsCompared } from './ConceptsCompared.js';
|
|
12
13
|
import { ArchimateViewsCompared } from './ArchimateViewsCompared.js';
|
|
13
14
|
import { roadmapPlan, parseDate } from './roadmap.js';
|
|
@@ -244,6 +245,38 @@ export class ArchimateModel extends ViewSetModel {
|
|
|
244
245
|
return new MergeElementsCommand(this, keepId, otherIds, choices);
|
|
245
246
|
}
|
|
246
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Where elements are used, so deleting them from the model can say what goes
|
|
250
|
+
* with them: the views drawing them (with how many boxes on each), and the
|
|
251
|
+
* relationships that go too — each one ending on one of them, and each one
|
|
252
|
+
* ending on such a relationship, and so on.
|
|
253
|
+
* @param {Array<string>} elementIds
|
|
254
|
+
* @returns {{views: Array<{view: ArchimateView, boxes: number}>, relationships: Array<Object>}}
|
|
255
|
+
*/
|
|
256
|
+
usage(elementIds) {
|
|
257
|
+
const gone = new Set(elementIds);
|
|
258
|
+
const relationships = [];
|
|
259
|
+
for (let grew = true; grew;) {
|
|
260
|
+
grew = false;
|
|
261
|
+
for (const rel of this.relationships()) {
|
|
262
|
+
if (!gone.has(rel.id) && (gone.has(rel.source) || gone.has(rel.target))) {
|
|
263
|
+
gone.add(rel.id);
|
|
264
|
+
relationships.push(rel);
|
|
265
|
+
grew = true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const views = this.views()
|
|
270
|
+
.map(view => ({ view, boxes: [...(view.elementInstances?.values() ?? [])].filter(i => elementIds.includes(i.elementId)).length }))
|
|
271
|
+
.filter(v => v.boxes > 0);
|
|
272
|
+
return { views, relationships };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Take elements out of the model, with their relationships and everything drawing them (not yet executed). */
|
|
276
|
+
deleteFromModelCommand(elementIds) {
|
|
277
|
+
return new DeleteFromModelCommand(this, elementIds);
|
|
278
|
+
}
|
|
279
|
+
|
|
247
280
|
/** Two models are compared on, and merged by, their elements, relationships and views (in the order brought). */
|
|
248
281
|
comparedKinds() {
|
|
249
282
|
return [new ElementsCompared(), new RelationshipsCompared(), new ArchimateViewsCompared()];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Command } from '../CommandHistory.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DeleteFromModelCommand — elements taken out of the model (Archi's "Delete
|
|
5
|
+
* from Model"), not just off one view: their relationships go too (and any
|
|
6
|
+
* relationship ending on one of those), and with them every box and line
|
|
7
|
+
* drawing them on every view, and every view's own line (a connection) to
|
|
8
|
+
* one of those boxes. Undo puts everything back as it was, ids and all.
|
|
9
|
+
*
|
|
10
|
+
* What goes is ArchimateModel#usage's: say it before asking.
|
|
11
|
+
*/
|
|
12
|
+
export class DeleteFromModelCommand extends Command {
|
|
13
|
+
/**
|
|
14
|
+
* @param {ArchimateModel} model
|
|
15
|
+
* @param {Array<string>} elementIds
|
|
16
|
+
*/
|
|
17
|
+
constructor(model, elementIds) {
|
|
18
|
+
const ids = elementIds.filter(id => model.getElement(id));
|
|
19
|
+
const first = model.getElement(ids[0]);
|
|
20
|
+
super(ids.length === 1 ? `Delete "${first?.name || ids[0]}" from model` : `Delete ${ids.length} elements from model`);
|
|
21
|
+
this.model = model;
|
|
22
|
+
this.elementIds = ids;
|
|
23
|
+
this._before = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
execute() {
|
|
27
|
+
const { model } = this;
|
|
28
|
+
if (!this.elementIds.length) return;
|
|
29
|
+
this._before = this._snapshot();
|
|
30
|
+
const { relationships } = model.usage(this.elementIds);
|
|
31
|
+
const goneElements = new Set(this.elementIds);
|
|
32
|
+
const goneRelationships = new Set(relationships.map(r => r.id));
|
|
33
|
+
for (const view of model.views()) {
|
|
34
|
+
if (Array.isArray(view.elementIds)) view.elementIds = view.elementIds.filter(id => !goneElements.has(id));
|
|
35
|
+
const goneBoxes = new Set();
|
|
36
|
+
for (const inst of [...(view.elementInstances?.values() ?? [])]) {
|
|
37
|
+
if (goneElements.has(inst.elementId)) { goneBoxes.add(inst.id); view.elementInstances.delete(inst.id); }
|
|
38
|
+
}
|
|
39
|
+
for (const inst of [...(view.relationshipInstances?.values() ?? [])]) {
|
|
40
|
+
if (goneRelationships.has(inst.relationshipId)) view.relationshipInstances.delete(inst.id);
|
|
41
|
+
}
|
|
42
|
+
for (const line of [...(view.viewConnections?.values() ?? [])]) {
|
|
43
|
+
if (goneBoxes.has(line.sourceInstanceId) || goneBoxes.has(line.targetInstanceId)) view.viewConnections.delete(line.id);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const id of goneRelationships) model.removeRelationship(id);
|
|
47
|
+
for (const id of goneElements) model.removeElement(id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
undo() {
|
|
51
|
+
if (!this._before) return;
|
|
52
|
+
const { model } = this;
|
|
53
|
+
const b = this._before;
|
|
54
|
+
model._elements = new Map(b.elements);
|
|
55
|
+
model._elementFolder = new Map(b.folders);
|
|
56
|
+
model._relationships = new Map(b.relationships);
|
|
57
|
+
for (const v of b.views) {
|
|
58
|
+
v.view.elementIds = v.elementIds;
|
|
59
|
+
if (v.instances) v.view.elementInstances = new Map(v.instances);
|
|
60
|
+
if (v.lines) v.view.relationshipInstances = new Map(v.lines);
|
|
61
|
+
if (v.connections) v.view.viewConnections = new Map(v.connections);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Everything the delete changes, to put back (the objects themselves, so ids and state are kept). @private */
|
|
66
|
+
_snapshot() {
|
|
67
|
+
const { model } = this;
|
|
68
|
+
return {
|
|
69
|
+
elements: [...model._elements],
|
|
70
|
+
folders: [...model._elementFolder],
|
|
71
|
+
relationships: [...model._relationships],
|
|
72
|
+
views: model.views().map(view => ({
|
|
73
|
+
view,
|
|
74
|
+
elementIds: Array.isArray(view.elementIds) ? [...view.elementIds] : view.elementIds,
|
|
75
|
+
instances: view.elementInstances ? [...view.elementInstances] : null,
|
|
76
|
+
lines: view.relationshipInstances ? [...view.relationshipInstances] : null,
|
|
77
|
+
connections: view.viewConnections ? [...view.viewConnections] : null,
|
|
78
|
+
})),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -91,6 +91,22 @@ export class EdgeGeometry {
|
|
|
91
91
|
return denom > 0 ? 1 / denom : Math.max(hw, hh);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** Pure. Where two boxes ({left, top, right, bottom}) face each other
|
|
95
|
+
* across a gap with their x ranges (or y ranges) overlapping, the
|
|
96
|
+
* shortest line between them runs straight across, at the middle of the
|
|
97
|
+
* overlap: `{axis: 'v', at: x, forward}` (a vertical line, `forward` when
|
|
98
|
+
* `a` is above `b`) or `{axis: 'h', at: y, forward}` (`a` left of `b`).
|
|
99
|
+
* Null when they overlap neither way, or each other. */
|
|
100
|
+
static acrossOverlap(a, b) {
|
|
101
|
+
const left = Math.max(a.left, b.left), right = Math.min(a.right, b.right);
|
|
102
|
+
const top = Math.max(a.top, b.top), bottom = Math.min(a.bottom, b.bottom);
|
|
103
|
+
const apartV = a.bottom <= b.top || b.bottom <= a.top;
|
|
104
|
+
const apartH = a.right <= b.left || b.right <= a.left;
|
|
105
|
+
if (left < right && apartV) return { axis: 'v', at: (left + right) / 2, forward: a.bottom <= b.top };
|
|
106
|
+
if (top < bottom && apartH) return { axis: 'h', at: (top + bottom) / 2, forward: a.right <= b.left };
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
/** Pure. Anchor for one end of an edge. A node that knows its own outline
|
|
95
111
|
* supplies `info.boundaryPoint(dx, dy) → {x, y}` (DiagramEditor templates
|
|
96
112
|
* do, rotation included), and the anchor is where the ray toward the other
|
|
@@ -20,8 +20,12 @@ export const EDGE_STYLE_PROPERTIES = Object.freeze([
|
|
|
20
20
|
choices: [['straight', 'Straight'], ['orthogonal', 'Orthogonal'], ['curved', 'Curved'], ['polyline', 'Around shapes']],
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
|
-
key: 'attach', label: 'Attach to', type: 'select', default: '
|
|
24
|
-
choices: [
|
|
23
|
+
key: 'attach', label: 'Attach to', type: 'select', default: 'shortest',
|
|
24
|
+
choices: [
|
|
25
|
+
['shortest', 'Shortest: straight across where the shapes overlap, else aimed at the other end'],
|
|
26
|
+
['floating', 'Outline, aimed at the other end'],
|
|
27
|
+
['sides', 'Middle of the facing side'],
|
|
28
|
+
],
|
|
25
29
|
},
|
|
26
30
|
{
|
|
27
31
|
key: 'lineStyle', label: 'Line', type: 'select', default: 'solid',
|
|
@@ -110,7 +110,7 @@ export class PivotGridChart extends TableViewChart {
|
|
|
110
110
|
const lit = view.cellFill(node, c, cuts);
|
|
111
111
|
if (lit) this.renderer.rect(x0 + i * colW + 1, top + 1, colW - 2, ROW_H - 2, lit, null);
|
|
112
112
|
const row = view.cell(node, c.node);
|
|
113
|
-
const text = row === null ? '' :
|
|
113
|
+
const text = row === null ? '' : view.valueText(c.measure, row[c.measure], nullText);
|
|
114
114
|
const shown = this._cell(text, end, x0 + i * colW, top, colW, { fill: row?.[c.measure] == null ? '#999' : '#222', ...weight });
|
|
115
115
|
// A cell with a value opens its menu (drill-through) where the host gives one.
|
|
116
116
|
if (row !== null && view.cellHasMenu()) {
|
|
@@ -53,6 +53,8 @@ export class PivotView {
|
|
|
53
53
|
* @param {Array<{measure?, below?, atLeast?, fill, label?}>} [options.trafficLights] the host's rules:
|
|
54
54
|
* the first whose measure (any, when absent) and range match colours a detail cell
|
|
55
55
|
* @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
|
|
56
|
+
* @param {function|Intl.NumberFormat|object} [options.format] how numbers show, for every measure or per
|
|
57
|
+
* measure (`TableChart.formatFor`): cells, totals and the traffic lights' cut-offs. A copy keeps the raw numbers.
|
|
56
58
|
*/
|
|
57
59
|
constructor(rows, options = {}) {
|
|
58
60
|
this.onChange = options.onChange ?? null;
|
|
@@ -63,6 +65,7 @@ export class PivotView {
|
|
|
63
65
|
this.members = options.members ?? null;
|
|
64
66
|
this.menus = options.menus ?? {};
|
|
65
67
|
this.copy = options.copy ?? PivotView.clipboard;
|
|
68
|
+
this.format = options.format ?? null;
|
|
66
69
|
this._listeners = new Set();
|
|
67
70
|
this.lightRules = options.trafficLights ?? null;
|
|
68
71
|
/** Traffic lights showing: null (off), 'rules' (the host's), 'thirds-high' or 'thirds-low' (by thirds, higher or lower is better). */
|
|
@@ -608,18 +611,24 @@ export class PivotView {
|
|
|
608
611
|
return PivotView.LIGHTS[good];
|
|
609
612
|
}
|
|
610
613
|
|
|
614
|
+
/** A measure's value as the grid shows it, in the measure's `format` when there is one. */
|
|
615
|
+
valueText(measure, value, nullText = '—') {
|
|
616
|
+
return TableChart.cellText(value, nullText, TableChart.formatFor(this.format, measure));
|
|
617
|
+
}
|
|
618
|
+
|
|
611
619
|
/** The traffic lights in words, for a line under the grid; null when off. */
|
|
612
620
|
lightsLegend() {
|
|
613
621
|
if (!this.lights) return null;
|
|
614
622
|
const tail = ' (detail cells; totals not coloured)';
|
|
615
623
|
if (this.lights === 'rules') {
|
|
616
624
|
const said = this.lightRules.map(r => r.label ?? [
|
|
617
|
-
r.measure, r.atLeast !== undefined ? '≥ ' + r.
|
|
625
|
+
r.measure, r.atLeast !== undefined ? '≥ ' + this.valueText(r.measure, r.atLeast) : null,
|
|
626
|
+
r.below !== undefined ? '< ' + this.valueText(r.measure, r.below) : null,
|
|
618
627
|
].filter(Boolean).join(' '));
|
|
619
628
|
return 'Traffic lights: ' + said.join('; ') + tail;
|
|
620
629
|
}
|
|
621
630
|
const better = this.lights === 'thirds-high' ? 'higher' : 'lower';
|
|
622
|
-
const cuts = [...this.thirds()].map(([m, [a, b]]) => m + ' ' +
|
|
631
|
+
const cuts = [...this.thirds()].map(([m, [a, b]]) => m + ' ' + this.valueText(m, a) + ' / ' + this.valueText(m, b)).join('; ');
|
|
623
632
|
return 'Traffic lights by thirds, ' + better + ' is better — cut at ' + (cuts || 'no values') + tail;
|
|
624
633
|
}
|
|
625
634
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ChartChooser } from '../ChartChooser.js';
|
|
2
|
+
import { TableChart } from '../charts/TableChart.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* TableView — the state of an interactive table over result rows, kept apart from drawing so all of
|
|
@@ -52,8 +53,10 @@ export class TableView {
|
|
|
52
53
|
* @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
|
|
53
54
|
* @param {ChartChooser} [options.chooser]
|
|
54
55
|
* @param {function} [options.highlight] row → true for a row drawn highlighted (a host's selection)
|
|
56
|
+
* @param {function|Intl.NumberFormat|object} [options.format] how numbers show, for every column or per
|
|
57
|
+
* column (`TableChart.formatFor`). A copy keeps the raw numbers.
|
|
55
58
|
*/
|
|
56
|
-
constructor(rows, { columns = null, complete = true, pageSize = 50, onChange = null, menus = {}, copy = null, chooser = null, highlight = null } = {}) {
|
|
59
|
+
constructor(rows, { columns = null, complete = true, pageSize = 50, onChange = null, menus = {}, copy = null, chooser = null, highlight = null, format = null } = {}) {
|
|
57
60
|
this.rows = rows;
|
|
58
61
|
this.columns = ChartChooser.describe(rows, columns);
|
|
59
62
|
this.complete = complete;
|
|
@@ -63,6 +66,7 @@ export class TableView {
|
|
|
63
66
|
this.copy = copy ?? TableView.clipboard;
|
|
64
67
|
this.chooser = chooser ?? new ChartChooser();
|
|
65
68
|
this.highlight = highlight;
|
|
69
|
+
this.format = format;
|
|
66
70
|
/** The sort keys, most significant first: `{ column, direction: 'asc' | 'desc' }`. */
|
|
67
71
|
this.sorts = [];
|
|
68
72
|
this.filters = []; // { column, op, value }
|
|
@@ -70,6 +74,11 @@ export class TableView {
|
|
|
70
74
|
this.page = 0;
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
/** A column's value as the table shows it, in the column's `format` when there is one. */
|
|
78
|
+
valueText(column, value, nullText = '—') {
|
|
79
|
+
return TableChart.cellText(value, nullText, TableChart.formatFor(this.format, column));
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
static clipboard(text) {
|
|
74
83
|
return globalThis.navigator?.clipboard?.writeText(text);
|
|
75
84
|
}
|