@peter.prib/renderengine 0.4.0 → 0.7.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 +4 -0
- package/REFERENCE.md +267 -17
- package/dist/renderengine-workspace.umd.js +5289 -2895
- package/dist/renderengine.umd.js +4243 -2120
- package/examples/archimate.html +8 -28
- package/examples/data-model.html +45 -556
- package/package.json +70 -70
- package/src/archimateArchiFormat.js +11 -3
- package/src/archimateExchangeFormat.js +17 -9
- package/src/charting/ArchimateDiagramEditor.js +1 -1
- package/src/charting/Axis.js +20 -0
- package/src/charting/DiagramEditor.js +35 -3
- package/src/charting/Dialog.js +195 -0
- package/src/charting/DuplicatesDialog.js +108 -0
- package/src/charting/FormDialog.js +28 -97
- package/src/charting/InteractionManager.js +3 -0
- package/src/charting/Legend.js +45 -2
- package/src/charting/MergeCompareDialog.js +81 -0
- package/src/charting/PopupMenu.js +12 -3
- package/src/charting/SettingsDialog.js +6 -2
- package/src/charting/SettingsManager.js +14 -1
- package/src/charting/archimate/ArchimateCanvasEditor.js +190 -15
- package/src/charting/archimate/ArchimatePalette.js +1 -1
- package/src/charting/charts/AreaChart.js +5 -2
- package/src/charting/charts/BarChart.js +5 -3
- package/src/charting/charts/BarChart3DOrbit.js +5 -7
- package/src/charting/charts/BubbleChart.js +5 -7
- package/src/charting/charts/BubbleChart3D.js +5 -7
- package/src/charting/charts/CandlestickChart.js +3 -1
- package/src/charting/charts/DensityChart.js +4 -6
- package/src/charting/charts/GroupedBarChart.js +2 -2
- package/src/charting/charts/LineChart.js +5 -2
- package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
- package/src/charting/charts/PipeChart.js +4 -6
- package/src/charting/charts/RadarChart.js +5 -6
- package/src/charting/charts/ScatterChart.js +5 -7
- package/src/charting/charts/StackedBarChart.js +4 -2
- package/src/charting/charts/StackedBarChart3DOrbit.js +5 -7
- package/src/charting/charts/StreamGraphChart.js +5 -2
- package/src/charting/charts/WaterfallChart.js +2 -1
- package/src/charting/content/ContentSource.js +129 -0
- package/src/charting/content/ContentSources.js +77 -0
- package/src/charting/datamodel/DataModel.js +116 -0
- package/src/charting/diagrams/ArchimateModel.js +38 -3
- package/src/charting/diagrams/ArchimateModelChecker.js +13 -4
- package/src/charting/diagrams/MergeElementsCommand.js +124 -101
- package/src/charting/diagrams/SvgZoom.js +20 -1
- package/src/charting/diagrams/duplicates.js +95 -13
- package/src/charting/duplicateSearchForm.js +58 -0
- package/src/charting/modelTree/ModelTree.js +239 -202
- package/src/charting/modelTree/ModelTreePanel.js +955 -919
- package/src/charting/modelTree/TreeBuilder.js +93 -45
- package/src/charting/modelTree/TreeViewMode.js +9 -6
- package/src/charting/modelTree/viewFolders.js +15 -6
- package/src/charting/requirements/AzureDevOpsApi.js +55 -0
- package/src/charting/requirements/GitLabApi.js +43 -0
- package/src/charting/requirements/JiraApi.js +69 -0
- package/src/charting/requirements/TrackerApi.js +142 -0
- package/src/charting/requirements/TrackerExport.js +3 -2
- package/src/charting/requirements/index.js +5 -0
- package/src/charting/requirements/trackerApis.js +21 -0
- package/src/icons/IconSet.js +86 -0
- package/src/icons/index.js +59 -0
- package/src/index.js +67 -10
- package/src/workspace/dom.js +56 -42
- package/src/workspace/index.js +1 -1
- package/src/workspace/nav/NavEntry.js +42 -0
- package/src/workspace/nav/NavGroup.js +43 -0
- package/src/workspace/nav/NavItem.js +53 -0
- package/src/workspace/nav/NavMenu.js +143 -0
- package/src/workspace/nav/NavSection.js +17 -0
- package/src/workspace/nav/index.js +5 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +8 -3
- package/src/workspace/parts/DataModelPart.js +91 -0
- package/src/workspace/parts/EditorPart.js +8 -0
- package/src/workspace/parts/ModelHosted.js +1 -0
- package/src/workspace/parts/ModelsPart.js +765 -622
- package/src/workspace/styles.js +28 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContentSource — models kept apart from the tools (modelengine, or any
|
|
3
|
+
* collection laid out the same way): a manifest listing items by domain, each
|
|
4
|
+
* a file in a format a modeller already reads (.archimate, .datamodel.json,
|
|
5
|
+
* .requirements.json …). A modeller depends on no collection: given a source,
|
|
6
|
+
* it offers the items its own file formats read (worked out from the files,
|
|
7
|
+
* not a list kept here) and opens one through that format, so a collection
|
|
8
|
+
* can grow, and be updated, without a change to the tools.
|
|
9
|
+
*
|
|
10
|
+
* A source is fetched (`url`: where its manifest.json is) or given whole
|
|
11
|
+
* (`bundle`: `{ manifest, files }`, what modelengine's dist/modelengine.js puts
|
|
12
|
+
* on `globalThis.ModelEngineContent`, for a page opened straight off disk).
|
|
13
|
+
*
|
|
14
|
+
* Manifest: `{ name, version?, domains?: [{id, name, description?}], items: [{ id,
|
|
15
|
+
* name, file, domain?, kind?, version?, description?, traces? }] }`.
|
|
16
|
+
*/
|
|
17
|
+
export class ContentSource {
|
|
18
|
+
/**
|
|
19
|
+
* @param {Object} options
|
|
20
|
+
* @param {string} [options.label] - What menus call it (else the manifest's name)
|
|
21
|
+
* @param {string} [options.url] - Where its manifest.json is (a folder, or the manifest itself)
|
|
22
|
+
* @param {{manifest: Object, files: Object<string, (string|{base64: string})>}} [options.bundle]
|
|
23
|
+
* @param {Function} [options.fetch] - For a `url` source (the page's fetch unless given)
|
|
24
|
+
*/
|
|
25
|
+
constructor({ label = null, url = null, bundle = null, fetch = null } = {}) {
|
|
26
|
+
if (!url && !bundle) throw new TypeError('A content source needs a url or a bundle');
|
|
27
|
+
this._label = label;
|
|
28
|
+
this.url = url;
|
|
29
|
+
this.bundle = bundle;
|
|
30
|
+
this._fetch = fetch;
|
|
31
|
+
this._manifest = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The bundle a page loaded (dist/modelengine.js: globalThis.ModelEngineContent) as a source, or null when it has none. */
|
|
35
|
+
static fromPage(label = null, root = globalThis) {
|
|
36
|
+
const bundle = root?.ModelEngineContent;
|
|
37
|
+
return bundle?.manifest ? new ContentSource({ label, bundle }) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What menus call it: its own label, else its manifest's name (a bundle's at once, a url's once read), else where it is. */
|
|
41
|
+
get label() {
|
|
42
|
+
return this._label ?? this._manifest?.name ?? this.bundle?.manifest?.name ?? this.url ?? 'Content';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where a file of it is, for a `url` source. @private */
|
|
46
|
+
_resolve(file) {
|
|
47
|
+
const base = /\.json$/i.test(this.url) ? this.url : `${this.url.replace(/\/?$/, '/')}manifest.json`;
|
|
48
|
+
return new URL(file, new URL(base, globalThis.location?.href ?? 'http://localhost/')).href;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @private */
|
|
52
|
+
async _get(file, asBytes) {
|
|
53
|
+
const fetch = this._fetch ?? globalThis.fetch?.bind(globalThis);
|
|
54
|
+
if (!fetch) throw new Error(`Cannot read ${file}: no fetch here`);
|
|
55
|
+
const response = await fetch(this._resolve(file));
|
|
56
|
+
if (!response.ok) throw new Error(`Cannot read ${file} from ${this.label}: ${response.status ?? ''} ${response.statusText ?? ''}`.trim());
|
|
57
|
+
return asBytes ? new Uint8Array(await response.arrayBuffer()) : response.text();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Its manifest, read once (refresh: read again, for what was added or updated since). */
|
|
61
|
+
async manifest({ refresh = false } = {}) {
|
|
62
|
+
if (this._manifest && !refresh) return this._manifest;
|
|
63
|
+
const manifest = this.bundle ? this.bundle.manifest : JSON.parse(await this._get('manifest.json', false));
|
|
64
|
+
ContentSource.check(manifest);
|
|
65
|
+
this._manifest = manifest;
|
|
66
|
+
return manifest;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Throws when a manifest will not do: no items, or an item without an id or a file, or two with one id. */
|
|
70
|
+
static check(manifest) {
|
|
71
|
+
if (!manifest || !Array.isArray(manifest.items)) throw new Error('Not a content manifest: it lists no items');
|
|
72
|
+
const ids = new Set();
|
|
73
|
+
for (const item of manifest.items) {
|
|
74
|
+
if (!item?.id || !item.file) throw new Error(`A content item needs an id and a file: ${JSON.stringify(item)}`);
|
|
75
|
+
if (ids.has(item.id)) throw new Error(`Two content items are "${item.id}"`);
|
|
76
|
+
ids.add(item.id);
|
|
77
|
+
}
|
|
78
|
+
return manifest;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The format of `formats` that reads an item's file: the only one
|
|
83
|
+
* accepting its whole ending (all after the name's first dot) — so a
|
|
84
|
+
* name ending .requirements.json is that format's, not just any .json;
|
|
85
|
+
* null when none, or more than one, would.
|
|
86
|
+
*/
|
|
87
|
+
static formatFor(item, formats) {
|
|
88
|
+
const file = String(item?.file ?? '').toLowerCase();
|
|
89
|
+
const readers = formats.filter(f => f.imports !== false);
|
|
90
|
+
const name = file.split('/').pop();
|
|
91
|
+
const ending = name.includes('.') ? name.slice(name.indexOf('.')) : '';
|
|
92
|
+
const accepting = readers.filter(f => String(f.accept ?? '').split(',').map(a => a.trim().toLowerCase()).includes(ending));
|
|
93
|
+
return accepting.length === 1 ? accepting[0] : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Its items, each with its domain's name; with `formats`, only those one of them reads (`format` set on each). */
|
|
97
|
+
async items({ formats = null } = {}) {
|
|
98
|
+
const manifest = await this.manifest();
|
|
99
|
+
const domains = new Map((manifest.domains ?? []).map(d => [d.id, d]));
|
|
100
|
+
return manifest.items
|
|
101
|
+
.map(item => ({ ...item, domainName: domains.get(item.domain)?.name ?? item.domain ?? '', ...(formats && { format: ContentSource.formatFor(item, formats) }) }))
|
|
102
|
+
.filter(item => !formats || item.format);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** An item by its id, or null. */
|
|
106
|
+
async item(id) {
|
|
107
|
+
return (await this.manifest()).items.find(i => i.id === id) ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** An item's file: text, or bytes for a format that reads bytes. */
|
|
111
|
+
async read(item, format = null) {
|
|
112
|
+
const asBytes = !!format?.readsBytes;
|
|
113
|
+
if (!this.bundle) return this._get(item.file, asBytes);
|
|
114
|
+
const kept = this.bundle.files?.[item.file];
|
|
115
|
+
if (kept == null) throw new Error(`${this.label} has no file ${item.file}`);
|
|
116
|
+
if (typeof kept === 'string') return asBytes ? new TextEncoder().encode(kept) : kept;
|
|
117
|
+
const bytes = Uint8Array.from(atob(kept.base64), c => c.charCodeAt(0));
|
|
118
|
+
return asBytes ? bytes : new TextDecoder().decode(bytes);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** An item opened as a model, through the format of `formats` that reads it: `{ model, format, item }`. */
|
|
122
|
+
async open(item, formats) {
|
|
123
|
+
const format = ContentSource.formatFor(item, formats);
|
|
124
|
+
if (!format) throw new Error(`Nothing here reads ${item.file}`);
|
|
125
|
+
const model = format.import(await this.read(item, format));
|
|
126
|
+
if (!model.name && item.name) model.name = item.name;
|
|
127
|
+
return { model, format, item };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ContentSource } from './ContentSource.js';
|
|
2
|
+
import { LocalJsonStore } from '../storage/LocalJsonStore.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ContentSources — the content sources a modeller offers (ContentSource):
|
|
6
|
+
* the bundle the page loaded (modelengine's dist/modelengine.js), those its
|
|
7
|
+
* host or spec names, and those the user added (kept in this browser). Each
|
|
8
|
+
* manifest is read once (`load`), so menus are made at once from `catalog`;
|
|
9
|
+
* a source that could not be read is listed with why, not dropped.
|
|
10
|
+
*/
|
|
11
|
+
export class ContentSources {
|
|
12
|
+
/** Where the sources the user added are kept. */
|
|
13
|
+
static STORE_KEY = 'model-content-sources';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {Object} [options]
|
|
17
|
+
* @param {Array<ContentSource|{label?, url?, bundle?}>} [options.sources] - The host's or spec's
|
|
18
|
+
* @param {boolean} [options.fromPage] - Offer the bundle the page loaded (globalThis.ModelEngineContent)
|
|
19
|
+
* @param {Object} [options.store] - Where added sources are kept (a LocalJsonStore; a test gives its own)
|
|
20
|
+
* @param {Function} [options.fetch]
|
|
21
|
+
*/
|
|
22
|
+
constructor({ sources = [], fromPage = true, store = new LocalJsonStore(ContentSources.STORE_KEY), fetch = null } = {}) {
|
|
23
|
+
this.store = store;
|
|
24
|
+
this._fetch = fetch;
|
|
25
|
+
const made = (s) => (s instanceof ContentSource ? s : new ContentSource({ ...s, fetch: s.fetch ?? fetch }));
|
|
26
|
+
this.given = [
|
|
27
|
+
...(fromPage ? [ContentSource.fromPage()].filter(Boolean) : []),
|
|
28
|
+
...sources.map(made),
|
|
29
|
+
];
|
|
30
|
+
/** Each source with its items, or the error reading it: `[{ source, items, error }]` (after load). */
|
|
31
|
+
this.catalog = [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The sources the user added: `[{ label, url }]`. */
|
|
35
|
+
added() {
|
|
36
|
+
return this.store?.load([]) ?? [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every source: given, then added. */
|
|
40
|
+
sources() {
|
|
41
|
+
const fetch = this._fetch;
|
|
42
|
+
return [...this.given, ...this.added().map(s => new ContentSource({ ...s, fetch }))];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read every source's manifest afresh (what was added or updated since). Resolves to the catalog. */
|
|
46
|
+
async load() {
|
|
47
|
+
this.catalog = await Promise.all(this.sources().map(async (source) => {
|
|
48
|
+
try {
|
|
49
|
+
await source.manifest({ refresh: true });
|
|
50
|
+
return { source, items: await source.items(), error: null };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { source, items: [], error: err.message };
|
|
53
|
+
}
|
|
54
|
+
}));
|
|
55
|
+
return this.catalog;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Add a source by its url (its label, else the manifest's name, once read); kept in this browser. */
|
|
59
|
+
async add({ url, label = '' }) {
|
|
60
|
+
const text = String(url ?? '').trim();
|
|
61
|
+
if (!text) throw new Error('A content source needs a url');
|
|
62
|
+
const list = this.added().filter(s => s.url !== text);
|
|
63
|
+
this.store?.save([...list, { url: text, ...(label && { label }) }]);
|
|
64
|
+
return this.load();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Stop offering an added source. */
|
|
68
|
+
async remove(url) {
|
|
69
|
+
this.store?.save(this.added().filter(s => s.url !== url));
|
|
70
|
+
return this.load();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A source by what menus call it, or by url. */
|
|
74
|
+
find(key) {
|
|
75
|
+
return this.catalog.map(c => c.source).find(s => s.label === key || s.url === key) ?? null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
} from './DataModelCompared.js';
|
|
21
21
|
|
|
22
22
|
/** The levels, outermost first: each realised by the next. */
|
|
23
|
+
import { duplicateGroups } from '../diagrams/duplicates.js';
|
|
24
|
+
|
|
23
25
|
const LEVELS = Object.freeze(['conceptual', 'logical', 'physical']);
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -475,6 +477,120 @@ export class DataModel extends EditableModel {
|
|
|
475
477
|
});
|
|
476
478
|
}
|
|
477
479
|
|
|
480
|
+
/**
|
|
481
|
+
* The entities at a level that look like one thing twice (duplicates.js:
|
|
482
|
+
* by their names, their definitions — `by: 'documentation'` — or their
|
|
483
|
+
* attributes' names, `by: 'attributes'`; with DUPLICATE_OPTIONS). Each
|
|
484
|
+
* group is a model-check finding (`possible-duplicate`, kind 'entity',
|
|
485
|
+
* with its `level`), so the check's dialog lists it.
|
|
486
|
+
*/
|
|
487
|
+
duplicateEntities(level, options = {}) {
|
|
488
|
+
const noun = DataModelNotation.level(level)?.entityNoun?.toLowerCase() ?? 'entity';
|
|
489
|
+
const nouns = noun.endsWith('y') ? `${noun.slice(0, -1)}ies` : `${noun}s`;
|
|
490
|
+
const items = this.level(level).entities.map(e => ({
|
|
491
|
+
id: e.id, name: e.name, type: level, documentation: e.description, attributes: e.attributes ?? [], properties: e.properties,
|
|
492
|
+
}));
|
|
493
|
+
const why = { documentation: ' (by their definitions)', attributes: ' (by their attributes)' }[options.by] ?? '';
|
|
494
|
+
return duplicateGroups(items, options).map(g => {
|
|
495
|
+
const names = [...new Set(g.map(e => String(e.name ?? '').trim()))];
|
|
496
|
+
return {
|
|
497
|
+
code: 'possible-duplicate', severity: 'warning', kind: 'entity', level, ids: g.map(e => e.id),
|
|
498
|
+
message: names.length === 1
|
|
499
|
+
? `${g.length} ${nouns} are named "${names[0]}"${why}`
|
|
500
|
+
: `${g.length} ${nouns} look the same${why}: ${names.map(n => `"${n}"`).join(', ')}`,
|
|
501
|
+
};
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Some entities at a level side by side, for a merge (MergeCompareDialog's
|
|
507
|
+
* rows): their names, definitions, each attribute (by name, any case: its
|
|
508
|
+
* type, or ✓, where an entity has it), their relationships and what they
|
|
509
|
+
* are mapped to at the levels either side.
|
|
510
|
+
* @returns {Array<{label: string, values: string[]}>}
|
|
511
|
+
*/
|
|
512
|
+
entityComparison(level, ids) {
|
|
513
|
+
const schema = this.level(level);
|
|
514
|
+
const entities = ids.map(id => schema.entity(id)).filter(Boolean);
|
|
515
|
+
const nameOf = (id) => schema.entity(id)?.name || id;
|
|
516
|
+
const attributeNames = [];
|
|
517
|
+
for (const e of entities) {
|
|
518
|
+
for (const a of e.attributes ?? []) {
|
|
519
|
+
if (!attributeNames.some(n => n.toLowerCase() === String(a.name ?? '').toLowerCase())) attributeNames.push(String(a.name ?? ''));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const attribute = (e, name) => {
|
|
523
|
+
const a = (e.attributes ?? []).find(x => String(x.name ?? '').toLowerCase() === name.toLowerCase());
|
|
524
|
+
return a ? [a.type || '✓', a.isPK && 'PK', a.isFK && 'FK'].filter(Boolean).join(' ') : '';
|
|
525
|
+
};
|
|
526
|
+
const lines = (e) => schema.relationships
|
|
527
|
+
.filter(r => r.source === e.id || r.target === e.id)
|
|
528
|
+
.map(r => (r.source === e.id ? `${r.name ?? ''} → ${nameOf(r.target)}` : `${r.name ?? ''} ← ${nameOf(r.source)}`).trim())
|
|
529
|
+
.sort().join('\n');
|
|
530
|
+
const mapped = (e) => this.mappings.flatMap(m => [
|
|
531
|
+
...(m.from === level ? m.pairs.filter(([a]) => a === e.id).map(([, b]) => `→ ${this.level(m.to).entity(b)?.name ?? b} (${m.to})`) : []),
|
|
532
|
+
...(m.to === level ? m.pairs.filter(([, b]) => b === e.id).map(([a]) => `← ${this.level(m.from).entity(a)?.name ?? a} (${m.from})`) : []),
|
|
533
|
+
]).sort().join('\n');
|
|
534
|
+
return [
|
|
535
|
+
{ label: 'Name', values: entities.map(e => e.name ?? '') },
|
|
536
|
+
{ label: 'Definition', values: entities.map(e => String(e.description ?? '').trim()) },
|
|
537
|
+
...attributeNames.map(name => ({ label: `${name} (attribute)`, values: entities.map(e => attribute(e, name)) })),
|
|
538
|
+
{ label: 'Relationships', values: entities.map(lines) },
|
|
539
|
+
{ label: 'Mapped', values: entities.map(mapped) },
|
|
540
|
+
];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Make entities at a level that are one thing twice into one (one undo
|
|
545
|
+
* step): `keepId` takes the attributes it lacks (by name, any case), their
|
|
546
|
+
* relationships (one left joining it to itself, or the same as another,
|
|
547
|
+
* goes) and their mapping pairs; the others go, perspectives and contexts
|
|
548
|
+
* forgetting them. `description`: its definition (else its own, with the
|
|
549
|
+
* others' it lacks joined on).
|
|
550
|
+
*/
|
|
551
|
+
mergeEntitiesCommand(level, keepId, otherIds, { description } = {}) {
|
|
552
|
+
const keepName = this.level(level).entity(keepId)?.name ?? keepId;
|
|
553
|
+
return this.editCommand(`Merge ${otherIds.length + 1} into "${keepName}"`, (model) => {
|
|
554
|
+
const schema = model.level(level);
|
|
555
|
+
const keep = schema.entity(keepId);
|
|
556
|
+
const others = otherIds.filter(id => id !== keepId).map(id => schema.entity(id)).filter(Boolean);
|
|
557
|
+
if (!keep || !others.length) return;
|
|
558
|
+
const merged = new Set(others.map(e => e.id));
|
|
559
|
+
const into = (id) => (merged.has(id) ? keepId : id);
|
|
560
|
+
for (const other of others) {
|
|
561
|
+
const has = new Set((keep.attributes ?? []).map(a => String(a.name ?? '').toLowerCase()));
|
|
562
|
+
const added = (other.attributes ?? []).filter(a => !has.has(String(a.name ?? '').toLowerCase()));
|
|
563
|
+
if (added.length) keep.attributes = [...(keep.attributes ?? []), ...added.map(a => ({ ...a }))];
|
|
564
|
+
const theirs = String(other.description ?? '').trim();
|
|
565
|
+
if (description === undefined && theirs && !String(keep.description ?? '').includes(theirs)) {
|
|
566
|
+
keep.description = keep.description ? `${keep.description}
|
|
567
|
+
|
|
568
|
+
${theirs}` : theirs;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (description !== undefined) keep.description = description;
|
|
572
|
+
// Re-pointed, a relationship doubling one there (untouched, or re-pointed before it) goes; doubles there before stay.
|
|
573
|
+
const key = (r) => `${r.source} ${r.target} ${r.name ?? ''} ${r.kind ?? ''}`;
|
|
574
|
+
const touched = new Set(schema.relationships.filter(r => merged.has(r.source) || merged.has(r.target)));
|
|
575
|
+
const seen = new Set(schema.relationships.filter(r => !touched.has(r)).map(key));
|
|
576
|
+
schema.relationships = schema.relationships.filter(r => {
|
|
577
|
+
if (!touched.has(r)) return true;
|
|
578
|
+
r.source = into(r.source);
|
|
579
|
+
r.target = into(r.target);
|
|
580
|
+
if ((r.source === keepId && r.target === keepId) || seen.has(key(r))) return false;
|
|
581
|
+
seen.add(key(r));
|
|
582
|
+
return true;
|
|
583
|
+
});
|
|
584
|
+
for (const m of model.mappings) {
|
|
585
|
+
if (m.from !== level && m.to !== level) continue;
|
|
586
|
+
const pairs = m.pairs.map(([a, b]) => [m.from === level ? into(a) : a, m.to === level ? into(b) : b]);
|
|
587
|
+
m.pairs = pairs.filter(([a, b], i) => pairs.findIndex(([c, d]) => c === a && d === b) === i);
|
|
588
|
+
}
|
|
589
|
+
schema.entities = schema.entities.filter(e => !merged.has(e.id));
|
|
590
|
+
for (const id of merged) for (const holder of [...model.perspectives, ...model.contexts]) holder.forgetEntity(level, id);
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
478
594
|
/**
|
|
479
595
|
* Which of a level's relationships something drawn is (a line, or a
|
|
480
596
|
* Chen diamond), by its id, the id a diamond is given, else its ends and
|
|
@@ -26,7 +26,7 @@ import { AddToViewCommand, DeleteElementCommand, AddRelationshipToViewCommand, R
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
export class ArchimateModel extends ViewSetModel {
|
|
29
|
-
constructor({ id = undefined, name = '', elements = [], relationships = [], folders = [], elementFolders = [], views = [], documentation = undefined, properties = [], archi = null } = {}) {
|
|
29
|
+
constructor({ id = undefined, name = '', elements = [], relationships = [], folders = [], elementFolders = [], views = [], documentation = undefined, properties = [], archi = null, emptyViewFolders = [] } = {}) {
|
|
30
30
|
// Views (create, duplicate, rename, delete, switch, change listeners) come from ViewSetModel.
|
|
31
31
|
super({ idPrefix: 'view', viewNoun: 'View' });
|
|
32
32
|
this.name = name;
|
|
@@ -40,6 +40,8 @@ export class ArchimateModel extends ViewSetModel {
|
|
|
40
40
|
this.properties = properties.map(p => ({ ...p }));
|
|
41
41
|
/** What an Archi file said about the model that it does not use (profiles, ...), written back on export. */
|
|
42
42
|
this.archi = archi;
|
|
43
|
+
/** The Views sub-folders that hold no view, each a path of folder steps ({id, name}) as a view's `archiFolder` is. */
|
|
44
|
+
this.emptyViewFolders = emptyViewFolders.map(path => path.map(step => ({ ...step })));
|
|
43
45
|
this._elementFolder = new Map(); // elementId -> folderId (which folder contains this element)
|
|
44
46
|
|
|
45
47
|
// Ingest elements
|
|
@@ -238,8 +240,8 @@ export class ArchimateModel extends ViewSetModel {
|
|
|
238
240
|
* others' relationships, boxes, missing documentation and properties
|
|
239
241
|
* (MergeElementsCommand, not yet executed; put it on `history`).
|
|
240
242
|
*/
|
|
241
|
-
mergeCommand(keepId, otherIds) {
|
|
242
|
-
return new MergeElementsCommand(this, keepId, otherIds);
|
|
243
|
+
mergeCommand(keepId, otherIds, choices = {}) {
|
|
244
|
+
return new MergeElementsCommand(this, keepId, otherIds, choices);
|
|
243
245
|
}
|
|
244
246
|
|
|
245
247
|
/** Two models are compared on, and merged by, their elements, relationships and views (in the order brought). */
|
|
@@ -758,6 +760,39 @@ export class ArchimateView extends DiagramEditorState {
|
|
|
758
760
|
.filter(inst => inst.relationshipId === relationshipId);
|
|
759
761
|
}
|
|
760
762
|
|
|
763
|
+
/**
|
|
764
|
+
* The boxes that only repeat another box of the same element here: the same
|
|
765
|
+
* label shown, in the same box (or none), with the same lines drawn (each
|
|
766
|
+
* relationship, or connection, to the same thing at the other end). The
|
|
767
|
+
* first such box of each stays; the ones after it are returned, to remove.
|
|
768
|
+
* A box holding other boxes is never one.
|
|
769
|
+
* @returns {string[]} box (instance) ids
|
|
770
|
+
*/
|
|
771
|
+
repeatedBoxes() {
|
|
772
|
+
const boxes = [...this.elementInstances.values()].filter(i => i.elementId && this.model.getElement?.(i.elementId));
|
|
773
|
+
const all = [...this.elementInstances.values()];
|
|
774
|
+
const byVisual = new Map(all.filter(i => i.visualId).map(i => [i.visualId, i]));
|
|
775
|
+
const holds = (box) => box.visualId && all.some(i => i.nestedIn === box.visualId);
|
|
776
|
+
const thing = (instanceId) => { const i = this.elementInstances.get(instanceId); return i?.elementId ?? instanceId; };
|
|
777
|
+
const lines = (box) => [
|
|
778
|
+
...[...this.relationshipInstances.values()].map(l => [`r:${l.relationshipId}`, l]),
|
|
779
|
+
...[...this.viewConnections.values()].map(l => [`c:${l.label ?? ''}`, l]),
|
|
780
|
+
].flatMap(([what, l]) => [
|
|
781
|
+
...(l.sourceInstanceId === box.id ? [`${what}>${thing(l.targetInstanceId)}`] : []),
|
|
782
|
+
...(l.targetInstanceId === box.id ? [`${what}<${thing(l.sourceInstanceId)}`] : []),
|
|
783
|
+
]).sort();
|
|
784
|
+
const seen = new Set();
|
|
785
|
+
const repeats = [];
|
|
786
|
+
for (const box of boxes) {
|
|
787
|
+
if (holds(box)) continue;
|
|
788
|
+
const inside = byVisual.get(box.nestedIn)?.elementId ?? '';
|
|
789
|
+
const key = JSON.stringify([box.elementId, box.getDisplayName?.(this.model) ?? '', inside, lines(box)]);
|
|
790
|
+
if (seen.has(key)) repeats.push(box.id);
|
|
791
|
+
else seen.add(key);
|
|
792
|
+
}
|
|
793
|
+
return repeats;
|
|
794
|
+
}
|
|
795
|
+
|
|
761
796
|
/**
|
|
762
797
|
* Clear all element instances.
|
|
763
798
|
*/
|
|
@@ -68,9 +68,16 @@ export class ArchimateModelChecker extends ViewSetChecker {
|
|
|
68
68
|
* Elements of the same type whose names have the same words (ignoring
|
|
69
69
|
* case, punctuation, word order and filler words: duplicates.js): likely
|
|
70
70
|
* one thing twice. The finding names them all, so they can be merged.
|
|
71
|
+
* `options` (DUPLICATE_OPTIONS) compare otherwise: by documentation, by a
|
|
72
|
+
* property's value, with more noise left out, across types. `within`: only
|
|
73
|
+
* among these elements (by id: a view's, a selection's).
|
|
71
74
|
*/
|
|
72
|
-
possibleDuplicates() {
|
|
73
|
-
|
|
75
|
+
possibleDuplicates(options = {}, { within = null } = {}) {
|
|
76
|
+
const { by = 'name', property } = options;
|
|
77
|
+
const why = { documentation: ' (by their documentation)', property: ` (by their ${property})` }[by] ?? '';
|
|
78
|
+
const among = within ? new Set(within) : null;
|
|
79
|
+
const elements = this.model.elements().filter(e => !among || among.has(e.id));
|
|
80
|
+
return duplicateGroups(elements, options).map(g => {
|
|
74
81
|
// Names that differ only in case and spacing are one name to the reader.
|
|
75
82
|
const byLook = new Map();
|
|
76
83
|
for (const e of g) {
|
|
@@ -78,11 +85,13 @@ export class ArchimateModelChecker extends ViewSetChecker {
|
|
|
78
85
|
if (!byLook.has(look)) byLook.set(look, this._name(e).trim());
|
|
79
86
|
}
|
|
80
87
|
const names = [...byLook.values()];
|
|
88
|
+
const types = [...new Set(g.map(e => e.type))];
|
|
89
|
+
const what = types.length === 1 ? `${g.length} ${types[0]} elements` : `${g.length} elements (${types.join(', ')})`;
|
|
81
90
|
return {
|
|
82
91
|
code: 'possible-duplicate', severity: 'warning', kind: 'element', ids: g.map(e => e.id),
|
|
83
92
|
message: names.length === 1
|
|
84
|
-
? `${
|
|
85
|
-
: `${
|
|
93
|
+
? `${what} are named "${names[0]}"${why}`
|
|
94
|
+
: `${what} look the same${why}: ${names.map(n => `"${n}"`).join(', ')}`,
|
|
86
95
|
};
|
|
87
96
|
});
|
|
88
97
|
}
|