@peter.prib/renderengine 0.5.0 → 0.8.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 +297 -17
- package/dist/renderengine-workspace.umd.js +5369 -2952
- package/dist/renderengine.umd.js +4466 -2320
- 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/chartCatalog.js +10 -0
- package/src/charting/ArchimateDiagramEditor.js +1 -1
- package/src/charting/Axis.js +20 -0
- package/src/charting/ChartChooser.js +229 -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/TableChart.js +98 -0
- 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/icons/IconSet.js +86 -0
- package/src/icons/index.js +59 -0
- package/src/index.js +86 -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
|
@@ -1,101 +1,124 @@
|
|
|
1
|
-
import { Command } from '../CommandHistory.js';
|
|
2
|
-
import { structuredCopy } from './ElementInstance.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* MergeElementsCommand — elements that are one thing twice made into one
|
|
6
|
-
* (Archi's merge): the one kept takes the others' relationships, their boxes
|
|
7
|
-
* on every view, the documentation it lacks and the properties it lacks; the
|
|
8
|
-
* others go. A relationship left joining the kept element to itself, or the
|
|
9
|
-
* same as another (kind and ends), goes too, its lines drawn as the other.
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* @param {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
this.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
for (const id of
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
1
|
+
import { Command } from '../CommandHistory.js';
|
|
2
|
+
import { structuredCopy } from './ElementInstance.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* MergeElementsCommand — elements that are one thing twice made into one
|
|
6
|
+
* (Archi's merge): the one kept takes the others' relationships, their boxes
|
|
7
|
+
* on every view, the documentation it lacks and the properties it lacks; the
|
|
8
|
+
* others go. A relationship left joining the kept element to itself, or the
|
|
9
|
+
* same as another (kind and ends), goes too, its lines drawn as the other.
|
|
10
|
+
* What differs can be chosen instead: the documentation (one of them, or all
|
|
11
|
+
* joined), a property's value, the specialization.
|
|
12
|
+
* Undo puts everything back as it was.
|
|
13
|
+
*/
|
|
14
|
+
export class MergeElementsCommand extends Command {
|
|
15
|
+
/**
|
|
16
|
+
* @param {ArchimateModel} model
|
|
17
|
+
* @param {string} keepId - The element kept
|
|
18
|
+
* @param {Array<string>} otherIds - The elements merged into it
|
|
19
|
+
* @param {Object} [choices] - What the kept element ends with, where they differ
|
|
20
|
+
* @param {string} [choices.documentation] - Its documentation (else its own, with the others' it lacks joined on)
|
|
21
|
+
* @param {Object<string, string>} [choices.properties] - A value for each property named
|
|
22
|
+
* @param {string} [choices.specialization] - Its specialization (else its own, or the first other's)
|
|
23
|
+
* @param {string} [choices.label] - The undo step's name
|
|
24
|
+
*/
|
|
25
|
+
constructor(model, keepId, otherIds, { documentation, properties = {}, specialization, label } = {}) {
|
|
26
|
+
super(label ?? `Merge ${otherIds.length + 1} elements into "${model.getElement(keepId)?.name || keepId}"`);
|
|
27
|
+
this.model = model;
|
|
28
|
+
this.keepId = keepId;
|
|
29
|
+
this.otherIds = otherIds.filter(id => id !== keepId && model.getElement(id));
|
|
30
|
+
this.choices = { documentation, properties, specialization };
|
|
31
|
+
this._before = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
execute() {
|
|
35
|
+
const { model, keepId } = this;
|
|
36
|
+
const keep = model.getElement(keepId);
|
|
37
|
+
if (!keep || !this.otherIds.length) return;
|
|
38
|
+
this._before = this._snapshot();
|
|
39
|
+
const merged = new Set(this.otherIds);
|
|
40
|
+
const into = (id) => (merged.has(id) ? keepId : id);
|
|
41
|
+
for (const other of this.otherIds.map(id => model.getElement(id))) {
|
|
42
|
+
if (other.documentation && !(keep.documentation ?? '').includes(other.documentation)) {
|
|
43
|
+
keep.documentation = keep.documentation ? `${keep.documentation}\n\n${other.documentation}` : other.documentation;
|
|
44
|
+
}
|
|
45
|
+
const own = new Set((keep.properties ?? []).map(p => p.name ?? p.key));
|
|
46
|
+
const added = (other.properties ?? []).filter(p => !own.has(p.name ?? p.key));
|
|
47
|
+
if (added.length) keep.properties = [...(keep.properties ?? []), ...added.map(p => ({ ...p }))];
|
|
48
|
+
if (!keep.specialization && other.specialization) keep.specialization = other.specialization;
|
|
49
|
+
}
|
|
50
|
+
this._applyChoices(keep);
|
|
51
|
+
// Relationships end at the kept element; one left joining it to itself, or doubling another, goes.
|
|
52
|
+
// A re-pointed one doubling one there (untouched, or re-pointed before it) goes; doubles there before stay.
|
|
53
|
+
const key = (rel) => `${rel.kind} ${rel.source} ${rel.target} ${rel.name ?? ''}`;
|
|
54
|
+
const touched = model.relationships().filter(rel => merged.has(rel.source) || merged.has(rel.target));
|
|
55
|
+
const seen = new Map();
|
|
56
|
+
for (const rel of model.relationships()) if (!touched.includes(rel) && !seen.has(key(rel))) seen.set(key(rel), rel.id);
|
|
57
|
+
const replaced = new Map(); // removed relationship id -> the one its lines now draw
|
|
58
|
+
for (const rel of touched) {
|
|
59
|
+
rel.source = into(rel.source);
|
|
60
|
+
rel.target = into(rel.target);
|
|
61
|
+
if (rel.source === keepId && rel.target === keepId) { replaced.set(rel.id, null); continue; }
|
|
62
|
+
if (seen.has(key(rel))) replaced.set(rel.id, seen.get(key(rel)));
|
|
63
|
+
else seen.set(key(rel), rel.id);
|
|
64
|
+
}
|
|
65
|
+
for (const id of replaced.keys()) model.removeRelationship(id);
|
|
66
|
+
for (const view of model.views()) {
|
|
67
|
+
if (Array.isArray(view.elementIds)) view.elementIds = [...new Set(view.elementIds.map(into))];
|
|
68
|
+
for (const inst of view.elementInstances?.values() ?? []) if (merged.has(inst.elementId)) inst.elementId = keepId;
|
|
69
|
+
for (const inst of [...(view.relationshipInstances?.values() ?? [])]) {
|
|
70
|
+
if (!replaced.has(inst.relationshipId)) continue;
|
|
71
|
+
const now = replaced.get(inst.relationshipId);
|
|
72
|
+
if (now) inst.relationshipId = now;
|
|
73
|
+
else view.relationshipInstances.delete(inst.id);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const id of this.otherIds) model.removeElement(id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What was chosen where the elements differ, on the kept one. @private */
|
|
80
|
+
_applyChoices(keep) {
|
|
81
|
+
const { documentation, properties, specialization } = this.choices;
|
|
82
|
+
if (documentation !== undefined) keep.documentation = documentation;
|
|
83
|
+
if (specialization !== undefined) keep.specialization = specialization;
|
|
84
|
+
for (const [name, value] of Object.entries(properties)) {
|
|
85
|
+
const own = (keep.properties ?? []).find(p => (p.name ?? p.key) === name);
|
|
86
|
+
if (own) own.value = value;
|
|
87
|
+
else keep.properties = [...(keep.properties ?? []), { key: name, value }];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
undo() {
|
|
92
|
+
if (!this._before) return;
|
|
93
|
+
const { model } = this;
|
|
94
|
+
const b = this._before;
|
|
95
|
+
model._elements = new Map(b.elements.map(([id, el]) => [id, el]));
|
|
96
|
+
const keep = model._elements.get(this.keepId);
|
|
97
|
+
Object.keys(keep).forEach(k => delete keep[k]); // what the merge added too
|
|
98
|
+
Object.assign(keep, structuredCopy(b.keep));
|
|
99
|
+
model._elementFolder = new Map(b.folders);
|
|
100
|
+
model._relationships = new Map(b.relationships.map(([id, rel, ends]) => [id, Object.assign(rel, ends)]));
|
|
101
|
+
for (const v of b.views) {
|
|
102
|
+
v.view.elementIds = v.elementIds;
|
|
103
|
+
for (const [inst, elementId] of v.instances) inst.elementId = elementId;
|
|
104
|
+
v.view.relationshipInstances = new Map(v.lines.map(([inst, relationshipId]) => { inst.relationshipId = relationshipId; return [inst.id, inst]; }));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Everything the merge changes, to put back. @private */
|
|
109
|
+
_snapshot() {
|
|
110
|
+
const { model } = this;
|
|
111
|
+
return {
|
|
112
|
+
elements: [...model._elements],
|
|
113
|
+
keep: structuredCopy(model.getElement(this.keepId)),
|
|
114
|
+
folders: [...model._elementFolder],
|
|
115
|
+
relationships: [...model._relationships].map(([id, rel]) => [id, rel, { source: rel.source, target: rel.target }]),
|
|
116
|
+
views: model.views().map(view => ({
|
|
117
|
+
view,
|
|
118
|
+
elementIds: Array.isArray(view.elementIds) ? [...view.elementIds] : view.elementIds,
|
|
119
|
+
instances: [...(view.elementInstances?.values() ?? [])].map(inst => [inst, inst.elementId]),
|
|
120
|
+
lines: [...(view.relationshipInstances?.values() ?? [])].map(inst => [inst, inst.relationshipId]),
|
|
121
|
+
})),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -8,8 +8,16 @@ import { ViewFit } from '../ViewFit.js';
|
|
|
8
8
|
* without going through the engine façade.
|
|
9
9
|
*/
|
|
10
10
|
export class SvgZoom {
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* @param {HTMLElement} container
|
|
13
|
+
* @param {SVGSVGElement} svg
|
|
14
|
+
* @param {Object} [options]
|
|
15
|
+
* @param {'ctrl'|'alt'|'shift'|null} [options.wheelModifier] - Zoom only with this key held (ctrl
|
|
16
|
+
* also takes ⌘), so a plain wheel scrolls the page; null: any wheel zooms
|
|
17
|
+
*/
|
|
18
|
+
constructor(container, svg, { clientToSvgPoint, hitTest, is3D, wheelModifier = null } = {}) {
|
|
12
19
|
this.container = container;
|
|
20
|
+
this.wheelModifier = wheelModifier;
|
|
13
21
|
this.svg = svg;
|
|
14
22
|
this.clientToSvgPoint = clientToSvgPoint;
|
|
15
23
|
this.hitTest = hitTest;
|
|
@@ -72,8 +80,19 @@ export class SvgZoom {
|
|
|
72
80
|
viewBox.height = this._initialViewBox.height;
|
|
73
81
|
}
|
|
74
82
|
|
|
83
|
+
/** Whether `event` has the wheel modifier held (always, when there is none). */
|
|
84
|
+
wheelZooms(event) {
|
|
85
|
+
switch (this.wheelModifier) {
|
|
86
|
+
case 'ctrl': return !!(event.ctrlKey || event.metaKey);
|
|
87
|
+
case 'alt': return !!event.altKey;
|
|
88
|
+
case 'shift': return !!event.shiftKey;
|
|
89
|
+
default: return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
75
93
|
_onWheel(event) {
|
|
76
94
|
if (this.is3D?.()) return;
|
|
95
|
+
if (!this.wheelZooms(event)) return;
|
|
77
96
|
event.preventDefault();
|
|
78
97
|
|
|
79
98
|
const scaleFactor = event.deltaY > 0 ? 1.1 : 1 / 1.1;
|
|
@@ -1,35 +1,117 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* When two elements are probably one thing twice: one definition for the
|
|
3
|
-
* model check (likely duplicates)
|
|
3
|
+
* model check (likely duplicates), Find likely duplicates… and whatever
|
|
4
|
+
* offers to merge them.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* "order-management system" and "System of Order Management" match
|
|
8
|
-
*
|
|
6
|
+
* Text is compared by its words, ignoring case, punctuation, word order and
|
|
7
|
+
* noise words ("of", "and", "the", ...): "Order Management System",
|
|
8
|
+
* "order-management system" and "System of Order Management" match, and so
|
|
9
|
+
* do a word and its plural ("Customer", "Customers"; "Process", "Processes").
|
|
10
|
+
* A name that is only noise is compared as it is. How much is noise, and what
|
|
11
|
+
* is compared (the names, the documentation, a property's value), are options
|
|
12
|
+
* (DUPLICATE_OPTIONS), as the jArchi dedupe scripts have them.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
15
|
/** Words that do not tell two names apart. */
|
|
12
16
|
export const FILLER_WORDS = Object.freeze(['a', 'an', 'and', 'the', 'of', 'on', 'for', 'to', 'in', 'at', 'by', 'with', '&']);
|
|
13
17
|
|
|
14
|
-
/**
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
/** More words that say little in a name or a description: the jArchi scripts' noise words. */
|
|
19
|
+
export const NOISE_WORDS = Object.freeze([...FILLER_WORDS, 'as', 'was', 'from', 'is', 'then', 'into', 'be', 'tbc', 'tba']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What Find likely duplicates… compares, and what it ignores. The model check
|
|
23
|
+
* uses these as they are.
|
|
24
|
+
* - `by`: 'name', 'documentation', 'property' (the value of `property`), or any
|
|
25
|
+
* other field of the items holding text or a list of names (a data model's 'attributes')
|
|
26
|
+
* - `match`: 'words' (by their words, with the options below) or 'exact' (the same
|
|
27
|
+
* text, case and spacing aside)
|
|
28
|
+
* - `noiseWords`: words left out
|
|
29
|
+
* - `ignorePattern`: a regular expression; words matching it are left out (an id code: ^[Aa][0-9A-Fa-f]{5}$)
|
|
30
|
+
* - `ignoreNumbers`: numbers and version numbers ("2", "1.2.3") are left out
|
|
31
|
+
* - `ignoreShortWords`: one-letter words are left out
|
|
32
|
+
* - `plurals`: a word and its plural are one word
|
|
33
|
+
* - `acrossTypes`: elements of different types may be one thing
|
|
34
|
+
* - `skipSpecializations`: elements of these specializations are left out (Decommissioned)
|
|
35
|
+
*/
|
|
36
|
+
export const DUPLICATE_OPTIONS = Object.freeze({
|
|
37
|
+
by: 'name',
|
|
38
|
+
match: 'words',
|
|
39
|
+
property: '',
|
|
40
|
+
noiseWords: FILLER_WORDS,
|
|
41
|
+
ignorePattern: '',
|
|
42
|
+
ignoreNumbers: false,
|
|
43
|
+
ignoreShortWords: false,
|
|
44
|
+
plurals: true,
|
|
45
|
+
acrossTypes: false,
|
|
46
|
+
skipSpecializations: Object.freeze([]),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** A word as its singular, roughly: "processes" → "process", "customers" → "customer" ("process", "status" stay). */
|
|
50
|
+
export function singular(word) {
|
|
51
|
+
if (word.length > 4 && /(ss|x|ch|sh)es$/.test(word)) return word.slice(0, -2);
|
|
52
|
+
if (word.length > 3 && /[^su]s$/.test(word)) return word.slice(0, -1);
|
|
53
|
+
return word;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A number or a version number: "2", "1.2.3", "v2". */
|
|
57
|
+
const NUMBER = /^v?\d+(\.\d+)*$/;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The words some text is compared by, as one key ('' for none). Text that is
|
|
61
|
+
* all noise is compared by all its words, unless `allNoiseIsEmpty` (a
|
|
62
|
+
* description of only noise says nothing).
|
|
63
|
+
*/
|
|
64
|
+
export function wordKey(text, options = {}, { allNoiseIsEmpty = false } = {}) {
|
|
65
|
+
const { noiseWords, ignorePattern, ignoreNumbers, ignoreShortWords, plurals } = { ...DUPLICATE_OPTIONS, ...options };
|
|
66
|
+
const ignore = ignorePattern ? new RegExp(ignorePattern, 'i') : null;
|
|
67
|
+
const words = String(text ?? '').toLowerCase().replace(/[^\p{L}\p{N}&.]+/gu, ' ').replace(/\.(?!\d)/g, ' ').trim().split(/\s+/).filter(Boolean);
|
|
68
|
+
const meaningful = words
|
|
69
|
+
.filter(w => !noiseWords.includes(w))
|
|
70
|
+
.filter(w => !(ignoreNumbers && NUMBER.test(w)))
|
|
71
|
+
.filter(w => !(ignoreShortWords && w.length < 2))
|
|
72
|
+
.filter(w => !ignore?.test(w))
|
|
73
|
+
.map(w => (plurals ? singular(w) : w));
|
|
74
|
+
if (!meaningful.length && allNoiseIsEmpty) return '';
|
|
18
75
|
return [...new Set(meaningful.length ? meaningful : words)].sort().join(' ');
|
|
19
76
|
}
|
|
20
77
|
|
|
78
|
+
/** The words a name is compared by, as one key ('' for no name): wordKey with the model check's options. */
|
|
79
|
+
export function nameKey(name) {
|
|
80
|
+
return wordKey(name);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Text as it is, case and spacing aside. */
|
|
84
|
+
const exactKey = (text) => String(text ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
85
|
+
|
|
86
|
+
/** What of an element is compared, as a key ('' when it has nothing to compare). */
|
|
87
|
+
export function duplicateKey(element, options = {}) {
|
|
88
|
+
const o = { ...DUPLICATE_OPTIONS, ...options };
|
|
89
|
+
if (o.by === 'property') {
|
|
90
|
+
const prop = (element.properties ?? []).find(p => (p.name ?? p.key) === o.property);
|
|
91
|
+
return exactKey(prop?.value);
|
|
92
|
+
}
|
|
93
|
+
if (o.by === 'name') return o.match === 'exact' ? exactKey(element.name) : wordKey(element.name, o);
|
|
94
|
+
// Documentation, or another field: a list is its items' names (or the items) together.
|
|
95
|
+
const field = element[o.by];
|
|
96
|
+
const text = Array.isArray(field) ? field.map(f => f?.name ?? f).join(' ') : field;
|
|
97
|
+
return o.match === 'exact' ? exactKey(text) : wordKey(text, o, { allNoiseIsEmpty: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
21
100
|
/**
|
|
22
101
|
* The elements that are probably the same thing twice: groups of two or more
|
|
23
|
-
* of one type
|
|
102
|
+
* (of one type, unless `acrossTypes`) with the same key (duplicateKey).
|
|
24
103
|
* @param {Array<Object>} elements
|
|
104
|
+
* @param {Object} [options] - DUPLICATE_OPTIONS
|
|
25
105
|
* @returns {Array<Array<Object>>}
|
|
26
106
|
*/
|
|
27
|
-
export function duplicateGroups(elements) {
|
|
107
|
+
export function duplicateGroups(elements, options = {}) {
|
|
108
|
+
const o = { ...DUPLICATE_OPTIONS, ...options };
|
|
28
109
|
const groups = new Map();
|
|
29
110
|
for (const e of elements) {
|
|
30
|
-
|
|
111
|
+
if (o.skipSpecializations.includes(e.specialization)) continue;
|
|
112
|
+
const key = duplicateKey(e, o);
|
|
31
113
|
if (!key) continue;
|
|
32
|
-
const k = `${e.type} ${key}`;
|
|
114
|
+
const k = o.acrossTypes ? key : `${e.type} ${key}`;
|
|
33
115
|
if (!groups.has(k)) groups.set(k, []);
|
|
34
116
|
groups.get(k).push(e);
|
|
35
117
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { DUPLICATE_OPTIONS, NOISE_WORDS } from './diagrams/duplicates.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Find likely duplicates… form, for any model: one definition of its
|
|
5
|
+
* fields (FormDialog field specs) and of reading them back into the options
|
|
6
|
+
* duplicateGroups takes (DUPLICATE_OPTIONS). A modeller gives what is
|
|
7
|
+
* particular to it: where to look (`scope`: the whole model, a view, a level),
|
|
8
|
+
* what can be compared (`compare`), the property names and specializations its
|
|
9
|
+
* items have.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The form's fields.
|
|
14
|
+
* @param {Object} [options]
|
|
15
|
+
* @param {{label: string, choices: Array<[string, string]>, value?: string}} [options.scope] - Where to look
|
|
16
|
+
* @param {Array<[string, string]>} [options.compare] - What can be compared: [by, label] (names and documentation unless given)
|
|
17
|
+
* @param {string[]} [options.properties] - Property names: comparing a property's value is offered with any
|
|
18
|
+
* @param {string[]} [options.specializations] - Specializations: leaving one out is offered with any
|
|
19
|
+
* @returns {Array<Object>}
|
|
20
|
+
*/
|
|
21
|
+
export function duplicateSearchFields({ scope = null, compare = [['name', 'Names'], ['documentation', 'Documentation']], properties = [], specializations = [] } = {}) {
|
|
22
|
+
const o = DUPLICATE_OPTIONS;
|
|
23
|
+
return [
|
|
24
|
+
...(scope ? [{ key: 'scope', label: scope.label, type: 'select', value: scope.value ?? scope.choices[0][0], choices: scope.choices }] : []),
|
|
25
|
+
{ key: 'by', label: 'Compare', type: 'select', value: compare[0][0], choices: [...compare, ...(properties.length ? [['property', 'A property’s value']] : [])] },
|
|
26
|
+
...(properties.length ? [{ key: 'property', label: 'Property', type: 'select', value: properties[0], choices: properties.map(n => [n, n]) }] : []),
|
|
27
|
+
{ key: 'match', label: 'Match', type: 'select', value: o.match, choices: [['words', 'The same words (as below)'], ['exact', 'Exactly (case and spacing aside)']] },
|
|
28
|
+
{ key: 'noiseWords', label: 'Noise words', type: 'text', value: NOISE_WORDS.join(', ') },
|
|
29
|
+
{ key: 'ignorePattern', label: 'Ignore words matching', type: 'text', value: o.ignorePattern, placeholder: '^[Aa][0-9A-Fa-f]{5}$' },
|
|
30
|
+
{ key: 'ignoreNumbers', label: 'Ignore numbers and versions', type: 'checkbox', value: true },
|
|
31
|
+
{ key: 'ignoreShortWords', label: 'Ignore one-letter words', type: 'checkbox', value: true },
|
|
32
|
+
{ key: 'plurals', label: 'A word and its plural are one', type: 'checkbox', value: o.plurals },
|
|
33
|
+
{ key: 'acrossTypes', label: 'Across types', type: 'checkbox', value: o.acrossTypes },
|
|
34
|
+
...(specializations.length ? [{ key: 'skip', label: 'Leave out specialization', type: 'select', value: '', choices: [['', '(none)'], ...specializations.map(n => [n, n])] }] : []),
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A message when the form's values will not do (a pattern that is not a regular expression), else null. */
|
|
39
|
+
export function validateDuplicateSearch({ ignorePattern } = {}) {
|
|
40
|
+
try {
|
|
41
|
+
new RegExp(ignorePattern || '');
|
|
42
|
+
return null;
|
|
43
|
+
} catch {
|
|
44
|
+
return 'Ignore words matching: not a regular expression';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The form's values as duplicateGroups' options, and where to look (`scope`, when the form asked). */
|
|
49
|
+
export function duplicateSearchOptions({ noiseWords, skip, scope, ...rest } = {}) {
|
|
50
|
+
return {
|
|
51
|
+
options: {
|
|
52
|
+
...rest,
|
|
53
|
+
noiseWords: String(noiseWords ?? '').split(/[\s,]+/).map(w => w.trim().toLowerCase()).filter(Boolean),
|
|
54
|
+
skipSpecializations: skip ? [skip] : [],
|
|
55
|
+
},
|
|
56
|
+
scope,
|
|
57
|
+
};
|
|
58
|
+
}
|