@peter.prib/renderengine 0.2.2 → 0.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peter.prib/renderengine",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "ArchiMate modeller, diagram editor and chart library for the browser, with no runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -109,7 +109,7 @@ export class FormDialog {
109
109
  style: { overflowY: 'auto', flex: '1 1 auto', minHeight: '0', paddingRight: '4px' },
110
110
  ref: (n) => { this.body = n; },
111
111
  children: [
112
- this.description && { text: this.description, style: { fontSize: '11px', color: '#666', margin: '0 0 10px' } },
112
+ this.description && { text: this.description, style: { fontSize: '11px', color: '#666', margin: '0 0 10px', whiteSpace: 'pre-line' } },
113
113
  { style: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 12px', alignItems: 'center' }, children: rows.flat() },
114
114
  { attrs: { role: 'alert' }, style: { color: '#c0392b', fontSize: '12px', marginTop: '8px', display: 'none' }, ref: (n) => { this._error = n; } },
115
115
  ].filter(Boolean),
@@ -0,0 +1,122 @@
1
+ import { FormDialog } from './FormDialog.js';
2
+
3
+ /** What a difference is listed under, for a model called `mine` compared with `theirs`. */
4
+ const GROUP_TEXT = {
5
+ added: (kind, mine, theirs) => `${kind.label} only in "${theirs}"`,
6
+ changed: (kind) => `${kind.label} changed`,
7
+ removed: (kind, mine) => `${kind.label} only in "${mine}"`,
8
+ };
9
+
10
+ /** The order of a kind's groups. */
11
+ const CHANGE_ORDER = ['added', 'changed', 'removed'];
12
+
13
+ /** 'elementInstances' reads 'element instances'. */
14
+ const words = (field) => field.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
15
+
16
+ /**
17
+ * ModelCompareDialog — how one model differs from another (a
18
+ * ModelComparison), shown: how many things of each kind only one has or
19
+ * both have unalike, then each of them, grouped. With anything to merge, its
20
+ * button opens the merge (`onMerge`).
21
+ */
22
+ export class ModelCompareDialog {
23
+ /**
24
+ * @param {Object} options
25
+ * @param {ModelComparison} options.comparison
26
+ * @param {string} options.mineName - What the model compared is called
27
+ * @param {string} options.theirsName - What the other is called
28
+ * @param {() => void} [options.onMerge] - Merge from the other (offered when there is something to merge)
29
+ * @param {Document} [options.document]
30
+ */
31
+ constructor({ comparison, mineName, theirsName, onMerge = null, document: doc = globalThis.document }) {
32
+ this.comparison = comparison;
33
+ this.mineName = mineName;
34
+ this.theirsName = theirsName;
35
+ this.onMerge = onMerge;
36
+ this.document = doc;
37
+ }
38
+
39
+ get title() {
40
+ return `Compare "${this.mineName}" with "${this.theirsName}"`;
41
+ }
42
+
43
+ /** One line per kind that differs: how many only one has, and how many changed. */
44
+ summaryText() {
45
+ if (this.comparison.same) return 'The models are alike.';
46
+ return this.comparison.summary()
47
+ .filter(s => s.added || s.changed || s.removed)
48
+ .map(({ kind, added, changed, removed }) => `${kind.label}: ${[
49
+ added && `${added} only in "${this.theirsName}"`,
50
+ changed && `${changed} changed`,
51
+ removed && `${removed} only in "${this.mineName}"`,
52
+ ].filter(Boolean).join(', ')}`)
53
+ .join('\n');
54
+ }
55
+
56
+ /** The differences listed: [value, text, group] for a checklist, kind by kind, each kind's groups in CHANGE_ORDER. */
57
+ choices(differences) {
58
+ const kinds = this.comparison.kinds;
59
+ const rank = (d) => kinds.indexOf(d.kind) * CHANGE_ORDER.length + CHANGE_ORDER.indexOf(d.change);
60
+ return [...differences].sort((a, b) => rank(a) - rank(b)).map(d => [
61
+ d.key,
62
+ d.change === 'changed' ? `${d.name} (${d.fields.map(words).join(', ')})` : d.name,
63
+ GROUP_TEXT[d.change](d.kind, this.mineName, this.theirsName),
64
+ ]);
65
+ }
66
+
67
+ /** The dialog's fields: every difference, to read. */
68
+ fields() {
69
+ const all = this.comparison.differences();
70
+ return all.length ? [{ key: 'items', label: '', type: 'checklist', readOnly: true, value: [], choices: this.choices(all) }] : [];
71
+ }
72
+
73
+ open() {
74
+ const canMerge = !!this.onMerge && this.comparison.mergeable().length > 0;
75
+ return new FormDialog({
76
+ title: this.title,
77
+ description: this.summaryText(),
78
+ fields: this.fields(),
79
+ submitLabel: canMerge ? 'Merge…' : 'Close',
80
+ cancelLabel: 'Close',
81
+ idPrefix: 'model-compare-',
82
+ document: this.document,
83
+ onApply: () => { if (canMerge) this.onMerge(); },
84
+ }).open();
85
+ }
86
+ }
87
+
88
+ /**
89
+ * ModelMergeDialog — choose what to bring from the other model: what only it
90
+ * has and its version of what changed, each ticked to start with; what this
91
+ * model alone has stays. Merging hands the command (ModelComparison#mergeCommand,
92
+ * with what the chosen need) to `onMerge`.
93
+ */
94
+ export class ModelMergeDialog extends ModelCompareDialog {
95
+ get title() {
96
+ return `Merge from "${this.theirsName}" into "${this.mineName}"`;
97
+ }
98
+
99
+ fields() {
100
+ const mergeable = this.comparison.mergeable();
101
+ return mergeable.length
102
+ ? [{ key: 'items', label: '', type: 'checklist', value: this.choices(mergeable).map(([key]) => key), choices: this.choices(mergeable) }]
103
+ : [];
104
+ }
105
+
106
+ open() {
107
+ const nothing = !this.comparison.mergeable().length;
108
+ return new FormDialog({
109
+ title: this.title,
110
+ description: nothing ? `"${this.mineName}" has everything "${this.theirsName}" has.` : `${this.summaryText()}\n\nWhat the chosen need (a view's elements, a relationship's ends) comes too.`,
111
+ fields: this.fields(),
112
+ submitLabel: nothing ? 'Close' : 'Merge',
113
+ idPrefix: 'model-merge-',
114
+ document: this.document,
115
+ validate: ({ items }) => (nothing || items?.length ? null : 'Choose something to merge'),
116
+ onApply: ({ items }) => {
117
+ const command = nothing ? null : this.comparison.mergeCommand(items);
118
+ if (command) this.onMerge(command);
119
+ },
120
+ }).open();
121
+ }
122
+ }
@@ -48,6 +48,21 @@ export class PopupMenu {
48
48
  };
49
49
  }
50
50
 
51
+ /**
52
+ * Where a submenu opens beside its item (the item's box `{left, right, top}`),
53
+ * to be all inside the window: to the right, or to the left when the right
54
+ * would pass the edge (and there is more room on the left); level with the
55
+ * item, or lifted by as much as it would pass the bottom. `top` is relative
56
+ * to the item, as the submenu is positioned.
57
+ */
58
+ static submenuPlacement(item, width, height, windowWidth, windowHeight, margin = 4) {
59
+ const fitsRight = item.right + width + margin <= windowWidth;
60
+ const side = fitsRight || item.left < windowWidth - item.right ? 'right' : 'left';
61
+ const top = -4;
62
+ const overflow = item.top + top + height + margin - windowHeight;
63
+ return { side, top: overflow > 0 ? Math.max(top - overflow, margin - item.top) : top };
64
+ }
65
+
51
66
  static close() {
52
67
  const open = PopupMenu._open;
53
68
  if (!open) return;
@@ -85,7 +100,16 @@ export class PopupMenu {
85
100
  ...base,
86
101
  attrs: { ...base.attrs, 'aria-haspopup': 'menu' },
87
102
  on: {
88
- mouseenter: (e) => { e.currentTarget.style.background = hover; sub.style.display = 'block'; },
103
+ mouseenter: (e) => {
104
+ e.currentTarget.style.background = hover;
105
+ sub.style.display = 'block';
106
+ // Opened near the window's right or bottom edge, it opens left, or lifts.
107
+ const win = e.currentTarget.ownerDocument.defaultView;
108
+ const box = sub.getBoundingClientRect?.();
109
+ if (!win || !box?.width) return;
110
+ const at = PopupMenu.submenuPlacement(e.currentTarget.getBoundingClientRect(), box.width, box.height, win.innerWidth, win.innerHeight);
111
+ Object.assign(sub.style, at.side === 'left' ? { left: 'auto', right: '100%' } : { left: '100%', right: 'auto' }, { top: `${at.top}px` });
112
+ },
89
113
  mouseleave: (e) => { e.currentTarget.style.background = ''; sub.style.display = 'none'; },
90
114
  click: (e) => e.stopPropagation(),
91
115
  },
@@ -0,0 +1,67 @@
1
+ /**
2
+ * ComparedKind — one kind of thing two models are compared on and merged by
3
+ * (their views; a notation's elements and relationships). A subclass per kind
4
+ * says how to list them, what of one counts when two are compared
5
+ * (`content`), what one needs to be brought into another model (the elements
6
+ * a view draws: `requires`), and how to bring it (`bring`, undoable).
7
+ * Things are matched by id, as two versions of one model (an Archi file and
8
+ * its later copy) keep them.
9
+ */
10
+ export class ComparedKind {
11
+ constructor() {
12
+ if (new.target === ComparedKind) throw new TypeError('ComparedKind is abstract — subclass it');
13
+ }
14
+
15
+ /** What a difference of this kind is keyed by ('view', 'element'). @abstract */
16
+ get key() { throw new Error(`${this.constructor.name} must define key`); }
17
+
18
+ /** What they are called in lists ('Views', 'Elements'). @abstract */
19
+ get label() { throw new Error(`${this.constructor.name} must define label`); }
20
+
21
+ /** Every one of them in a model, in order. @abstract */
22
+ items(model) {
23
+ throw new Error(`${this.constructor.name} must implement items()`);
24
+ }
25
+
26
+ /** One of them by id, or null. */
27
+ get(model, id) {
28
+ return this.items(model).find(item => this.idOf(item) === id) ?? null;
29
+ }
30
+
31
+ idOf(item) {
32
+ return item.id;
33
+ }
34
+
35
+ /** What it is called: its name, else its id. */
36
+ nameOf(item) {
37
+ return item.name || item.title || this.idOf(item);
38
+ }
39
+
40
+ /** What of it counts when two are compared: plain JSON, nothing generated anew each time a model is read. @abstract */
41
+ content(item) {
42
+ throw new Error(`${this.constructor.name} must implement content()`);
43
+ }
44
+
45
+ /** The fields of `content` that differ between two of them (none: they are the same). */
46
+ changedFields(mine, theirs) {
47
+ const a = this.content(mine);
48
+ const b = this.content(theirs);
49
+ return [...new Set([...Object.keys(a), ...Object.keys(b)])]
50
+ .filter(k => JSON.stringify(a[k]) !== JSON.stringify(b[k]));
51
+ }
52
+
53
+ /** What else it needs in a model to be brought there from `from`: [[kind key, id]] (none here). */
54
+ requires(item, from) {
55
+ return [];
56
+ }
57
+
58
+ /**
59
+ * Put a copy of `item` (from `from`) in `model`, in place of the one with
60
+ * its id if there is one. Returns what undoes it.
61
+ * @abstract
62
+ * @returns {() => void}
63
+ */
64
+ bring(model, item, from) {
65
+ throw new Error(`${this.constructor.name} must implement bring()`);
66
+ }
67
+ }
@@ -34,6 +34,17 @@ export class DiagramView extends DiagramEditorState {
34
34
  return [...this.templates.values()];
35
35
  }
36
36
 
37
+ /** The diagram as plain JSON: its shapes (by id), lines, line style and documentation. */
38
+ toJSON() {
39
+ return {
40
+ title: this.title,
41
+ documentation: this.documentation,
42
+ templates: [...this.templates].map(([id, t]) => ({ id, type: t.constructor.name, name: t.name, data: t.serialize() })),
43
+ edges: [...this.edges.values()].map(e => JSON.parse(JSON.stringify(e))),
44
+ edgeStyle: JSON.parse(JSON.stringify(this.edgeStyle ?? {})),
45
+ };
46
+ }
47
+
37
48
  /** An independent copy: shapes, lines, style and documentation are copied; history starts empty (same options). */
38
49
  copy(id, title, historyOptions = this.historyOptions) {
39
50
  return new DiagramView({
@@ -0,0 +1,37 @@
1
+ import { Command } from '../CommandHistory.js';
2
+
3
+ /**
4
+ * MergeModelCommand — things from another model brought into this one
5
+ * (ModelComparison#mergeCommand): each step a ComparedKind's `bring`, in the
6
+ * order given (what is needed first). One undo step; undo takes each back,
7
+ * last first.
8
+ */
9
+ export class MergeModelCommand extends Command {
10
+ /** A merge is no edit on one view: undoing it shows no other. */
11
+ viewId = null;
12
+
13
+ /**
14
+ * @param {ViewSetModel} model - Merged into
15
+ * @param {ViewSetModel} from - Merged from
16
+ * @param {Array<{kind: ComparedKind, item: Object}>} steps
17
+ * @param {string} [label]
18
+ */
19
+ constructor(model, from, steps, label = `Merge ${steps.length} item${steps.length === 1 ? '' : 's'} from "${from.name || 'another model'}"`) {
20
+ super(label);
21
+ this.model = model;
22
+ this.from = from;
23
+ this.steps = steps;
24
+ this._undos = [];
25
+ }
26
+
27
+ execute() {
28
+ this._undos = this.steps.map(({ kind, item }) => kind.bring(this.model, item, this.from));
29
+ this.model._notify();
30
+ }
31
+
32
+ undo() {
33
+ for (const undo of [...this._undos].reverse()) undo();
34
+ this._undos = [];
35
+ this.model._notify();
36
+ }
37
+ }
@@ -0,0 +1,100 @@
1
+ import { MergeModelCommand } from './MergeModelCommand.js';
2
+
3
+ /**
4
+ * ModelComparison — how one model differs from another, kind by kind (the
5
+ * model's `comparedKinds()`: views, and a notation's elements and
6
+ * relationships), matched by id: what only the other has ('added'), what both
7
+ * have but not alike ('changed', with the fields that differ), and what only
8
+ * this one has ('removed'). It can merge (`mergeCommand`): bring what only the
9
+ * other has, and the other's version of what changed, into this model, with
10
+ * whatever those need that this model lacks.
11
+ */
12
+ export class ModelComparison {
13
+ /**
14
+ * @param {ViewSetModel} mine - The model compared, and merged into
15
+ * @param {ViewSetModel} theirs - The model it is compared with
16
+ */
17
+ constructor(mine, theirs) {
18
+ this.mine = mine;
19
+ this.theirs = theirs;
20
+ /** The kinds compared, in the order things are brought (what is needed first). */
21
+ this.kinds = mine.comparedKinds();
22
+ this._differences = null;
23
+ }
24
+
25
+ /**
26
+ * Every difference: `{key, kind, id, name, change, fields?}`; `change` is
27
+ * 'added' (only theirs), 'changed' (both; `fields` say which differ) or
28
+ * 'removed' (only mine). Kind by kind, in each model's order.
29
+ */
30
+ differences() {
31
+ if (this._differences) return this._differences;
32
+ const out = [];
33
+ for (const kind of this.kinds) {
34
+ const mine = new Map(kind.items(this.mine).map(item => [kind.idOf(item), item]));
35
+ const theirs = new Map(kind.items(this.theirs).map(item => [kind.idOf(item), item]));
36
+ const entry = (id, item, change, extra = {}) => ({ key: `${kind.key}:${id}`, kind, id, name: kind.nameOf(item), change, ...extra });
37
+ for (const [id, item] of theirs) {
38
+ if (!mine.has(id)) { out.push(entry(id, item, 'added')); continue; }
39
+ const fields = kind.changedFields(mine.get(id), item);
40
+ if (fields.length) out.push(entry(id, item, 'changed', { fields }));
41
+ }
42
+ for (const [id, item] of mine) if (!theirs.has(id)) out.push(entry(id, item, 'removed'));
43
+ }
44
+ return (this._differences = out);
45
+ }
46
+
47
+ /** Whether the two models are alike in everything compared. */
48
+ get same() {
49
+ return !this.differences().length;
50
+ }
51
+
52
+ /** How many differences of each change there are, kind by kind: [{kind, added, changed, removed}]. */
53
+ summary() {
54
+ return this.kinds.map(kind => {
55
+ const of = this.differences().filter(d => d.kind === kind);
56
+ const count = (change) => of.filter(d => d.change === change).length;
57
+ return { kind, added: count('added'), changed: count('changed'), removed: count('removed') };
58
+ });
59
+ }
60
+
61
+ /** The differences a merge can bring: what only theirs has, and what changed. */
62
+ mergeable() {
63
+ return this.differences().filter(d => d.change !== 'removed');
64
+ }
65
+
66
+ /**
67
+ * Bring some differences (by key; default every mergeable one) into this
68
+ * model, with what they need that it lacks (a view's elements, a
69
+ * relationship's ends): a MergeModelCommand, not yet run, or null when
70
+ * there is nothing to bring.
71
+ * @param {Iterable<string>} [keys]
72
+ * @returns {MergeModelCommand|null}
73
+ */
74
+ mergeCommand(keys = this.mergeable().map(d => d.key)) {
75
+ const byKey = new Map(this.mergeable().map(d => [d.key, d]));
76
+ const kindOf = new Map(this.kinds.map(kind => [kind.key, kind]));
77
+ const chosen = new Set();
78
+ const take = (kind, id) => {
79
+ const key = `${kind.key}:${id}`;
80
+ if (chosen.has(key)) return;
81
+ // Something needed is brought when only theirs has it; what both have stays as chosen.
82
+ if (!byKey.has(key)) return;
83
+ chosen.add(key);
84
+ const item = kind.get(this.theirs, id);
85
+ for (const [needKind, needId] of kind.requires(item, this.theirs)) {
86
+ const need = kindOf.get(needKind);
87
+ if (need && !need.get(this.mine, needId)) take(need, needId);
88
+ }
89
+ };
90
+ for (const key of keys) {
91
+ const d = byKey.get(key);
92
+ if (d) take(d.kind, d.id);
93
+ }
94
+ if (!chosen.size) return null;
95
+ const steps = this.kinds.flatMap(kind => this.mergeable()
96
+ .filter(d => d.kind === kind && chosen.has(d.key))
97
+ .map(d => ({ kind, item: kind.get(this.theirs, d.id) })));
98
+ return new MergeModelCommand(this.mine, this.theirs, steps);
99
+ }
100
+ }