@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.
Files changed (37) hide show
  1. package/README.md +10 -4
  2. package/dist/renderengine-workspace.umd.js +5779 -3731
  3. package/dist/renderengine.umd.js +5757 -3725
  4. package/documentation/REFERENCE.md +3291 -3077
  5. package/package.json +2 -2
  6. package/src/charting/Axis.js +47 -5
  7. package/src/charting/Chart.js +25 -0
  8. package/src/charting/ChartChooser.js +5 -4
  9. package/src/charting/DiagramEditor.js +239 -34
  10. package/src/charting/Legend.js +16 -0
  11. package/src/charting/ModelFileFormat.js +47 -0
  12. package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
  13. package/src/charting/archimate/deleteFromModel.js +57 -0
  14. package/src/charting/archimate/elementMenuItems.js +3 -1
  15. package/src/charting/archimate/heatMaps.js +169 -0
  16. package/src/charting/archimate/index.js +1 -0
  17. package/src/charting/charts/BarChart.js +5 -5
  18. package/src/charting/charts/BarChartBase.js +21 -3
  19. package/src/charting/charts/GroupedBarChart.js +42 -22
  20. package/src/charting/charts/LineChart.js +9 -8
  21. package/src/charting/charts/PieChart.js +2 -2
  22. package/src/charting/charts/TableChart.js +34 -5
  23. package/src/charting/diagrams/ArchimateModel.js +33 -0
  24. package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
  25. package/src/charting/diagrams/EdgeGeometry.js +16 -0
  26. package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
  27. package/src/charting/pivot/PivotChart.js +50 -0
  28. package/src/charting/pivot/PivotGridChart.js +191 -0
  29. package/src/charting/pivot/PivotView.js +740 -0
  30. package/src/charting/table/TableView.js +84 -15
  31. package/src/charting/table/TableViewChart.js +5 -3
  32. package/src/charting/templates/Template.js +10 -0
  33. package/src/exportImage.js +45 -18
  34. package/src/index.js +2899 -2807
  35. package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
  36. package/src/workspace/parts/DiagramEditorPart.js +2 -0
  37. 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 barHeight = (d.value / maxValue) * height;
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, barY, bandWidth, barHeight, color, null, { bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}` });
47
- this.renderer.text(String(d.value), barX + bandWidth / 2, barY - 5, { textAnchor, fontSize: '10px', fill: '#333' });
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
- /** Draw a Y axis and return the nice-rounded max value. */
42
- _drawYAxis(rawMax) {
43
- return new Axis().drawYAxis(this.renderer, this._plotBox, rawMax);
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 rawMax = Math.max(
18
- ...series.flatMap(s => s.values.map(v => v.value)),
19
- 1,
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
- // Build a value lookup so missing labels per series render as 0.
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) ?? 0;
56
- const barHeight = (value / maxValue) * height;
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, barY, finalBarWidth, barHeight, color, null, {
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
- this.renderer.text(label, clusterX + availableWidth / 2, y + height + 20, { textAnchor });
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
- const legendItems = series.map((s, i) => ({
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, width = box.width, height = box.height,
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
- /** A cell's text: a number exactly (to at most 4 decimals), null/undefined as `nullText`. */
50
- static cellText(value, nullText = '—') {
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
- return this.renderer.text(text, end ? left + colW - TableChart.PAD_X : left + TableChart.PAD_X, top + TableChart.ROW_H / 2, {
91
- textAnchor: end ? 'end' : 'start', verticalAlign: 'middle', fontSize: '12px', ...attrs,
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()];