@peter.prib/renderengine 0.10.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 +5779 -3731
- package/dist/renderengine.umd.js +5757 -3725
- package/documentation/REFERENCE.md +3291 -3077
- package/package.json +2 -2
- package/src/charting/Axis.js +47 -5
- package/src/charting/Chart.js +25 -0
- package/src/charting/ChartChooser.js +5 -4
- package/src/charting/DiagramEditor.js +239 -34
- package/src/charting/Legend.js +16 -0
- 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 +42 -22
- package/src/charting/charts/LineChart.js +9 -8
- package/src/charting/charts/PieChart.js +2 -2
- package/src/charting/charts/TableChart.js +34 -5
- 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/PivotChart.js +50 -0
- package/src/charting/pivot/PivotGridChart.js +191 -0
- package/src/charting/pivot/PivotView.js +740 -0
- package/src/charting/table/TableView.js +84 -15
- package/src/charting/table/TableViewChart.js +5 -3
- package/src/charting/templates/Template.js +10 -0
- package/src/exportImage.js +45 -18
- package/src/index.js +2899 -2807
- 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.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() {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { Axis } from '../Axis.js';
|
|
1
2
|
import { Legend } from '../Legend.js';
|
|
2
3
|
import { BarChartBase } from './BarChartBase.js';
|
|
4
|
+
import { TableChart } from './TableChart.js';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* GroupedBarChart — vertical grouped (multi-set) bar chart.
|
|
@@ -7,18 +9,18 @@ import { BarChartBase } from './BarChartBase.js';
|
|
|
7
9
|
* Data shape:
|
|
8
10
|
* Array<{name: string, values: Array<{label: string, value: number}>}>
|
|
9
11
|
*
|
|
10
|
-
* Each category (`label`) gets a cluster of bars side-by-side, one per series.
|
|
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. A negative value
|
|
14
|
+
* is drawn down from a zero line, the axis taking in the smallest value too.
|
|
11
15
|
*/
|
|
12
16
|
export class GroupedBarChart extends BarChartBase {
|
|
13
17
|
static chartType = 'groupedBar';
|
|
14
18
|
|
|
15
19
|
_computeMaxValue() {
|
|
16
20
|
const series = this.data;
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
);
|
|
21
|
-
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));
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
_renderBars(maxValue) {
|
|
@@ -32,7 +34,7 @@ export class GroupedBarChart extends BarChartBase {
|
|
|
32
34
|
const labels = [...new Set(series.flatMap(s => s.values.map(v => v.label)))];
|
|
33
35
|
const categoryCount = labels.length;
|
|
34
36
|
|
|
35
|
-
//
|
|
37
|
+
// A value lookup per series; a label a series lacks is a gap.
|
|
36
38
|
const valueBySeriesLabel = series.map(s => {
|
|
37
39
|
const map = new Map();
|
|
38
40
|
for (const v of s.values) map.set(v.label, v.value);
|
|
@@ -47,26 +49,49 @@ export class GroupedBarChart extends BarChartBase {
|
|
|
47
49
|
const finalBarWidth = barWidth - barGap;
|
|
48
50
|
|
|
49
51
|
const textAnchor = this.renderer.centerTextAnchor;
|
|
52
|
+
// Labels that would overlap: each cut to its cluster ("…") while a cluster has room for a few
|
|
53
|
+
// characters, so every cluster stays named; past that, every nth label, as BarChart does.
|
|
54
|
+
const measure = (s) => this.renderer.measureText?.(s, { fontSize: '12px' }) ?? s.length * 12 * 0.55;
|
|
55
|
+
const room = clusterWidth - 4;
|
|
56
|
+
const cut = room >= measure('x'.repeat(GroupedBarChart.MIN_LABEL_CHARS));
|
|
57
|
+
const labelStep = cut ? 1 : Axis.categoryLabelStep(labels, width);
|
|
50
58
|
|
|
51
59
|
labels.forEach((label, categoryIndex) => {
|
|
52
60
|
const clusterX = x + categoryIndex * clusterWidth + groupGap / 2;
|
|
53
61
|
|
|
54
62
|
series.forEach((seriesData, seriesIndex) => {
|
|
55
|
-
const value = valueBySeriesLabel[seriesIndex].get(label)
|
|
56
|
-
|
|
63
|
+
const value = valueBySeriesLabel[seriesIndex].get(label);
|
|
64
|
+
if (value == null) return;
|
|
65
|
+
const bar = this._barSpan(value);
|
|
57
66
|
const barX = clusterX + seriesIndex * barWidth + barGap / 2;
|
|
58
|
-
const barY = y + height - barHeight;
|
|
59
67
|
const color = this._palette.colorAt(seriesIndex);
|
|
60
68
|
|
|
61
|
-
this.renderer.rect(barX,
|
|
69
|
+
this._datum(this.renderer.rect(barX, bar.top, finalBarWidth, bar.height, color, null, {
|
|
62
70
|
tooltip: `${seriesData.name} — ${label}: ${value}`,
|
|
63
|
-
});
|
|
71
|
+
}), { label, series: seriesData.name });
|
|
64
72
|
});
|
|
65
73
|
|
|
66
|
-
|
|
74
|
+
if (categoryIndex % labelStep === 0) {
|
|
75
|
+
this.renderer.text(cut ? TableChart.fit(String(label), room, measure) : label, clusterX + availableWidth / 2, y + height + 20, { textAnchor });
|
|
76
|
+
}
|
|
67
77
|
});
|
|
68
78
|
}
|
|
69
79
|
|
|
80
|
+
/** With a legend, the plot leaves room for it beside, so it does not cover the last bars. */
|
|
81
|
+
_setup() {
|
|
82
|
+
super._setup();
|
|
83
|
+
if (this.options.legend && this.options.width === undefined) {
|
|
84
|
+
this._plotBox.width = Legend.plotWidthBeside(this._plotBox, this.renderer.width, this._legendOptions());
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_legendOptions() {
|
|
89
|
+
return {
|
|
90
|
+
items: this.data.map((s, i) => ({ label: s.name, color: this._palette.colorAt(i), dataIndex: i })),
|
|
91
|
+
itemHeight: 20, markerSize: 10,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
70
95
|
_afterBars() {
|
|
71
96
|
const series = this.data;
|
|
72
97
|
const options = this.options;
|
|
@@ -74,15 +99,10 @@ export class GroupedBarChart extends BarChartBase {
|
|
|
74
99
|
const { legend: showLegend = false } = options;
|
|
75
100
|
|
|
76
101
|
if (showLegend) {
|
|
77
|
-
|
|
78
|
-
label: s.name,
|
|
79
|
-
color: this._palette.colorAt(i),
|
|
80
|
-
dataIndex: i,
|
|
81
|
-
}));
|
|
82
|
-
Legend.beside({ x, y, width }, this.renderer.width, {
|
|
83
|
-
items: legendItems,
|
|
84
|
-
itemHeight: 20, markerSize: 10,
|
|
85
|
-
}).draw(this.renderer);
|
|
102
|
+
Legend.beside({ x, y, width }, this.renderer.width, this._legendOptions()).draw(this.renderer);
|
|
86
103
|
}
|
|
87
104
|
}
|
|
88
105
|
}
|
|
106
|
+
|
|
107
|
+
/** The fewest characters a cut label keeps; clusters narrower than that thin their labels instead. */
|
|
108
|
+
GroupedBarChart.MIN_LABEL_CHARS = 6;
|
|
@@ -17,15 +17,19 @@ export class LineChart extends Chart {
|
|
|
17
17
|
const options = this.options;
|
|
18
18
|
const box = ChartBox.fromRenderer(this.renderer);
|
|
19
19
|
const {
|
|
20
|
-
x = box.x, y = box.y,
|
|
20
|
+
x = box.x, y = box.y, height = box.height,
|
|
21
21
|
lineColor = 'steelblue', pointColor = '#fff', title, xAxisLabel, yAxisLabel,
|
|
22
22
|
colors, zAxis = false, legend: showLegend = false, depth: zDepth = 20,
|
|
23
23
|
} = options;
|
|
24
|
-
const plotBox = { x, y, width, height };
|
|
25
24
|
|
|
26
25
|
// Check if data is multiline (array of series) or single line
|
|
27
26
|
const isMultiline = Array.isArray(data[0]?.values);
|
|
28
27
|
const series = isMultiline ? data : [{ name: 'default', values: data }];
|
|
28
|
+
const legendOptions = { items: series.map(s => ({ name: s.name })), itemHeight: 20, markerSize: 12 };
|
|
29
|
+
// With a legend, the plot leaves room for it beside, so it does not cover the last points.
|
|
30
|
+
const width = options.width ?? (isMultiline && showLegend
|
|
31
|
+
? Legend.plotWidthBeside({ x, width: box.width }, this.renderer.width, legendOptions) : box.width);
|
|
32
|
+
const plotBox = { x, y, width, height };
|
|
29
33
|
|
|
30
34
|
// Determine max value across all series
|
|
31
35
|
const maxValue = Math.max(...series.flatMap(s =>
|
|
@@ -82,13 +86,13 @@ export class LineChart extends Chart {
|
|
|
82
86
|
const pointY = y + height - (d.value / maxValue) * height - seriesDepth;
|
|
83
87
|
const textAnchor = this.renderer.centerTextAnchor;
|
|
84
88
|
|
|
85
|
-
this.renderer.circle(
|
|
89
|
+
this._datum(this.renderer.circle(
|
|
86
90
|
pointX, pointY,
|
|
87
91
|
3,
|
|
88
92
|
seriesColor,
|
|
89
93
|
{ color: pointColor, width: 1.5 },
|
|
90
94
|
{ tooltip: `${isMultiline ? seriesData.name + ': ' : ''}${d.label}: ${d.value}` }
|
|
91
|
-
);
|
|
95
|
+
), { label: d.label, series: isMultiline ? seriesData.name : null });
|
|
92
96
|
|
|
93
97
|
const errMinus = d.errorMinus != null ? d.errorMinus : d.error != null ? d.error : null;
|
|
94
98
|
const errPlus = d.errorPlus != null ? d.errorPlus : d.error != null ? d.error : null;
|
|
@@ -118,10 +122,7 @@ export class LineChart extends Chart {
|
|
|
118
122
|
// Draw legend for multiline
|
|
119
123
|
if (isMultiline && showLegend) {
|
|
120
124
|
const legendItems = series.map((s, i) => ({ name: s.name, color: palette.colorAt(i) }));
|
|
121
|
-
Legend.beside({ x, y, width }, this.renderer.width, {
|
|
122
|
-
items: legendItems,
|
|
123
|
-
itemHeight: 20, markerSize: 12,
|
|
124
|
-
}).draw(this.renderer);
|
|
125
|
+
Legend.beside({ x, y, width }, this.renderer.width, { ...legendOptions, items: legendItems }).draw(this.renderer);
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
}
|
|
@@ -33,9 +33,9 @@ export class PieChart extends Chart {
|
|
|
33
33
|
const slice = (d.value / total) * Math.PI * 2;
|
|
34
34
|
const color = palette.colorAt(i);
|
|
35
35
|
const path = ShapePaths.donutSlicePath(cx, cy, radius, rInner, angle, angle + slice);
|
|
36
|
-
this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
36
|
+
this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
37
37
|
tooltip: `${d.label}: ${d.value} (${(d.value / total * 100).toFixed(1)}%)`,
|
|
38
|
-
});
|
|
38
|
+
}), { label: d.label });
|
|
39
39
|
angle += slice;
|
|
40
40
|
});
|
|
41
41
|
|
|
@@ -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));
|
|
@@ -85,11 +101,24 @@ export class TableChart extends Chart {
|
|
|
85
101
|
}
|
|
86
102
|
}
|
|
87
103
|
|
|
104
|
+
/**
|
|
105
|
+
* `text` cut to fit `room` px, ending in "…" when cut, so a long value never draws over the next
|
|
106
|
+
* column. `measure(s)` gives a string's width; the whole text comes back when it fits.
|
|
107
|
+
*/
|
|
108
|
+
static fit(text, room, measure) {
|
|
109
|
+
if (measure(text) <= room) return text;
|
|
110
|
+
let keep = text.length - 1;
|
|
111
|
+
while (keep > 0 && measure(text.slice(0, keep) + '…') > room) keep--;
|
|
112
|
+
return keep > 0 ? text.slice(0, keep) + '…' : '';
|
|
113
|
+
}
|
|
114
|
+
|
|
88
115
|
_cell(text, column, left, top, colW, attrs) {
|
|
89
116
|
const end = column.align === 'end';
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
117
|
+
const style = { textAnchor: end ? 'end' : 'start', verticalAlign: 'middle', fontSize: '12px', ...attrs };
|
|
118
|
+
const font = { fontSize: style.fontSize, fontWeight: style.fontWeight };
|
|
119
|
+
const measure = (s) => this.renderer.measureText?.(s, font) ?? s.length * (parseFloat(font.fontSize) || 12) * 0.55;
|
|
120
|
+
const shown = TableChart.fit(text, colW - 2 * TableChart.PAD_X, measure);
|
|
121
|
+
return this.renderer.text(shown, end ? left + colW - TableChart.PAD_X : left + TableChart.PAD_X, top + TableChart.ROW_H / 2, style);
|
|
93
122
|
}
|
|
94
123
|
|
|
95
124
|
_rule(x, y, width) {
|
|
@@ -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()];
|