@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.
Files changed (31) hide show
  1. package/README.md +10 -4
  2. package/dist/renderengine-workspace.umd.js +1920 -1055
  3. package/dist/renderengine.umd.js +1901 -1052
  4. package/documentation/REFERENCE.md +66 -5
  5. package/package.json +1 -1
  6. package/src/charting/Axis.js +47 -5
  7. package/src/charting/DiagramEditor.js +239 -34
  8. package/src/charting/ModelFileFormat.js +47 -0
  9. package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
  10. package/src/charting/archimate/deleteFromModel.js +57 -0
  11. package/src/charting/archimate/elementMenuItems.js +3 -1
  12. package/src/charting/archimate/heatMaps.js +169 -0
  13. package/src/charting/archimate/index.js +1 -0
  14. package/src/charting/charts/BarChart.js +5 -5
  15. package/src/charting/charts/BarChartBase.js +21 -3
  16. package/src/charting/charts/GroupedBarChart.js +7 -9
  17. package/src/charting/charts/TableChart.js +18 -2
  18. package/src/charting/diagrams/ArchimateModel.js +33 -0
  19. package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
  20. package/src/charting/diagrams/EdgeGeometry.js +16 -0
  21. package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
  22. package/src/charting/pivot/PivotGridChart.js +1 -1
  23. package/src/charting/pivot/PivotView.js +11 -2
  24. package/src/charting/table/TableView.js +10 -1
  25. package/src/charting/table/TableViewChart.js +1 -1
  26. package/src/charting/templates/Template.js +10 -0
  27. package/src/exportImage.js +45 -18
  28. package/src/index.js +17 -4
  29. package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
  30. package/src/workspace/parts/DiagramEditorPart.js +2 -0
  31. 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._datum(this.renderer.rect(barX, barY, bandWidth, barHeight, color, null, { bar: true, dataIndex: i, tooltip: `${d.label}: ${d.value}` }), { label: d.label });
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() {
@@ -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 rawMax = Math.max(
21
- ...series.flatMap(s => s.values.map(v => v.value).filter(v => v != null)),
22
- 1,
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 barHeight = (value / maxValue) * height;
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, barY, finalBarWidth, barHeight, color, null, {
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
- /** 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));
@@ -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: 'floating',
24
- choices: [['floating', 'Outline, aimed at the other end'], ['sides', 'Middle of the facing side']],
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 ? '' : TableChart.cellText(row[c.measure], nullText);
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.atLeast : null, r.below !== undefined ? '< ' + r.below : null,
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 + ' ' + TableChart.cellText(a) + ' / ' + TableChart.cellText(b)).join('; ');
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
  }