@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.
Files changed (78) hide show
  1. package/README.md +4 -0
  2. package/REFERENCE.md +267 -17
  3. package/dist/renderengine-workspace.umd.js +5289 -2895
  4. package/dist/renderengine.umd.js +4243 -2120
  5. package/examples/archimate.html +8 -28
  6. package/examples/data-model.html +45 -556
  7. package/package.json +70 -70
  8. package/src/archimateArchiFormat.js +11 -3
  9. package/src/archimateExchangeFormat.js +17 -9
  10. package/src/charting/ArchimateDiagramEditor.js +1 -1
  11. package/src/charting/Axis.js +20 -0
  12. package/src/charting/DiagramEditor.js +35 -3
  13. package/src/charting/Dialog.js +195 -0
  14. package/src/charting/DuplicatesDialog.js +108 -0
  15. package/src/charting/FormDialog.js +28 -97
  16. package/src/charting/InteractionManager.js +3 -0
  17. package/src/charting/Legend.js +45 -2
  18. package/src/charting/MergeCompareDialog.js +81 -0
  19. package/src/charting/PopupMenu.js +12 -3
  20. package/src/charting/SettingsDialog.js +6 -2
  21. package/src/charting/SettingsManager.js +14 -1
  22. package/src/charting/archimate/ArchimateCanvasEditor.js +190 -15
  23. package/src/charting/archimate/ArchimatePalette.js +1 -1
  24. package/src/charting/charts/AreaChart.js +5 -2
  25. package/src/charting/charts/BarChart.js +5 -3
  26. package/src/charting/charts/BarChart3DOrbit.js +5 -7
  27. package/src/charting/charts/BubbleChart.js +5 -7
  28. package/src/charting/charts/BubbleChart3D.js +5 -7
  29. package/src/charting/charts/CandlestickChart.js +3 -1
  30. package/src/charting/charts/DensityChart.js +4 -6
  31. package/src/charting/charts/GroupedBarChart.js +2 -2
  32. package/src/charting/charts/LineChart.js +5 -2
  33. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  34. package/src/charting/charts/PipeChart.js +4 -6
  35. package/src/charting/charts/RadarChart.js +5 -6
  36. package/src/charting/charts/ScatterChart.js +5 -7
  37. package/src/charting/charts/StackedBarChart.js +4 -2
  38. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -7
  39. package/src/charting/charts/StreamGraphChart.js +5 -2
  40. package/src/charting/charts/WaterfallChart.js +2 -1
  41. package/src/charting/content/ContentSource.js +129 -0
  42. package/src/charting/content/ContentSources.js +77 -0
  43. package/src/charting/datamodel/DataModel.js +116 -0
  44. package/src/charting/diagrams/ArchimateModel.js +38 -3
  45. package/src/charting/diagrams/ArchimateModelChecker.js +13 -4
  46. package/src/charting/diagrams/MergeElementsCommand.js +124 -101
  47. package/src/charting/diagrams/SvgZoom.js +20 -1
  48. package/src/charting/diagrams/duplicates.js +95 -13
  49. package/src/charting/duplicateSearchForm.js +58 -0
  50. package/src/charting/modelTree/ModelTree.js +239 -202
  51. package/src/charting/modelTree/ModelTreePanel.js +955 -919
  52. package/src/charting/modelTree/TreeBuilder.js +93 -45
  53. package/src/charting/modelTree/TreeViewMode.js +9 -6
  54. package/src/charting/modelTree/viewFolders.js +15 -6
  55. package/src/charting/requirements/AzureDevOpsApi.js +55 -0
  56. package/src/charting/requirements/GitLabApi.js +43 -0
  57. package/src/charting/requirements/JiraApi.js +69 -0
  58. package/src/charting/requirements/TrackerApi.js +142 -0
  59. package/src/charting/requirements/TrackerExport.js +3 -2
  60. package/src/charting/requirements/index.js +5 -0
  61. package/src/charting/requirements/trackerApis.js +21 -0
  62. package/src/icons/IconSet.js +86 -0
  63. package/src/icons/index.js +59 -0
  64. package/src/index.js +67 -10
  65. package/src/workspace/dom.js +56 -42
  66. package/src/workspace/index.js +1 -1
  67. package/src/workspace/nav/NavEntry.js +42 -0
  68. package/src/workspace/nav/NavGroup.js +43 -0
  69. package/src/workspace/nav/NavItem.js +53 -0
  70. package/src/workspace/nav/NavMenu.js +143 -0
  71. package/src/workspace/nav/NavSection.js +17 -0
  72. package/src/workspace/nav/index.js +5 -0
  73. package/src/workspace/parts/ArchimateCanvasPart.js +8 -3
  74. package/src/workspace/parts/DataModelPart.js +91 -0
  75. package/src/workspace/parts/EditorPart.js +8 -0
  76. package/src/workspace/parts/ModelHosted.js +1 -0
  77. package/src/workspace/parts/ModelsPart.js +765 -622
  78. 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
- * Undo puts everything back as it was.
11
- */
12
- export class MergeElementsCommand extends Command {
13
- /**
14
- * @param {ArchimateModel} model
15
- * @param {string} keepId - The element kept
16
- * @param {Array<string>} otherIds - The elements merged into it
17
- */
18
- constructor(model, keepId, otherIds, label = `Merge ${otherIds.length + 1} elements into "${model.getElement(keepId)?.name || keepId}"`) {
19
- super(label);
20
- this.model = model;
21
- this.keepId = keepId;
22
- this.otherIds = otherIds.filter(id => id !== keepId && model.getElement(id));
23
- this._before = null;
24
- }
25
-
26
- execute() {
27
- const { model, keepId } = this;
28
- const keep = model.getElement(keepId);
29
- if (!keep || !this.otherIds.length) return;
30
- this._before = this._snapshot();
31
- const merged = new Set(this.otherIds);
32
- const into = (id) => (merged.has(id) ? keepId : id);
33
- for (const other of this.otherIds.map(id => model.getElement(id))) {
34
- if (other.documentation && !(keep.documentation ?? '').includes(other.documentation)) {
35
- keep.documentation = keep.documentation ? `${keep.documentation}\n\n${other.documentation}` : other.documentation;
36
- }
37
- const own = new Set((keep.properties ?? []).map(p => p.name ?? p.key));
38
- const added = (other.properties ?? []).filter(p => !own.has(p.name ?? p.key));
39
- if (added.length) keep.properties = [...(keep.properties ?? []), ...added.map(p => ({ ...p }))];
40
- if (!keep.specialization && other.specialization) keep.specialization = other.specialization;
41
- }
42
- // Relationships end at the kept element; one left joining it to itself, or doubling another, goes.
43
- const seen = new Map();
44
- const replaced = new Map(); // removed relationship id -> the one its lines now draw
45
- for (const rel of model.relationships()) {
46
- const touched = merged.has(rel.source) || merged.has(rel.target);
47
- rel.source = into(rel.source);
48
- rel.target = into(rel.target);
49
- if (touched && rel.source === keepId && rel.target === keepId) { replaced.set(rel.id, null); continue; }
50
- const key = `${rel.kind} ${rel.source} ${rel.target} ${rel.name ?? ''}`;
51
- if (touched && seen.has(key)) replaced.set(rel.id, seen.get(key));
52
- else if (!seen.has(key)) seen.set(key, rel.id);
53
- }
54
- for (const id of replaced.keys()) model.removeRelationship(id);
55
- for (const view of model.views()) {
56
- if (Array.isArray(view.elementIds)) view.elementIds = [...new Set(view.elementIds.map(into))];
57
- for (const inst of view.elementInstances?.values() ?? []) if (merged.has(inst.elementId)) inst.elementId = keepId;
58
- for (const inst of [...(view.relationshipInstances?.values() ?? [])]) {
59
- if (!replaced.has(inst.relationshipId)) continue;
60
- const now = replaced.get(inst.relationshipId);
61
- if (now) inst.relationshipId = now;
62
- else view.relationshipInstances.delete(inst.id);
63
- }
64
- }
65
- for (const id of this.otherIds) model.removeElement(id);
66
- }
67
-
68
- undo() {
69
- if (!this._before) return;
70
- const { model } = this;
71
- const b = this._before;
72
- model._elements = new Map(b.elements.map(([id, el]) => [id, el]));
73
- const keep = model._elements.get(this.keepId);
74
- Object.keys(keep).forEach(k => delete keep[k]); // what the merge added too
75
- Object.assign(keep, structuredCopy(b.keep));
76
- model._elementFolder = new Map(b.folders);
77
- model._relationships = new Map(b.relationships.map(([id, rel, ends]) => [id, Object.assign(rel, ends)]));
78
- for (const v of b.views) {
79
- v.view.elementIds = v.elementIds;
80
- for (const [inst, elementId] of v.instances) inst.elementId = elementId;
81
- v.view.relationshipInstances = new Map(v.lines.map(([inst, relationshipId]) => { inst.relationshipId = relationshipId; return [inst.id, inst]; }));
82
- }
83
- }
84
-
85
- /** Everything the merge changes, to put back. @private */
86
- _snapshot() {
87
- const { model } = this;
88
- return {
89
- elements: [...model._elements],
90
- keep: structuredCopy(model.getElement(this.keepId)),
91
- folders: [...model._elementFolder],
92
- relationships: [...model._relationships].map(([id, rel]) => [id, rel, { source: rel.source, target: rel.target }]),
93
- views: model.views().map(view => ({
94
- view,
95
- elementIds: Array.isArray(view.elementIds) ? [...view.elementIds] : view.elementIds,
96
- instances: [...(view.elementInstances?.values() ?? [])].map(inst => [inst, inst.elementId]),
97
- lines: [...(view.relationshipInstances?.values() ?? [])].map(inst => [inst, inst.relationshipId]),
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
- constructor(container, svg, { clientToSvgPoint, hitTest, is3D } = {}) {
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) and whatever offers to merge them.
3
+ * model check (likely duplicates), Find likely duplicates… and whatever
4
+ * offers to merge them.
4
5
  *
5
- * Names are compared by their words, ignoring case, punctuation, word order
6
- * and filler words ("of", "and", "the", ...): "Order Management System",
7
- * "order-management system" and "System of Order Management" match. A name
8
- * that is only filler is compared as it is.
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
- /** The words a name is compared by, as one key ('' for no name). */
15
- export function nameKey(name) {
16
- const words = String(name ?? '').toLowerCase().replace(/[^\p{L}\p{N}&]+/gu, ' ').trim().split(/\s+/).filter(Boolean);
17
- const meaningful = words.filter(w => !FILLER_WORDS.includes(w));
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 whose names have the same key.
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
- const key = nameKey(e.name);
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
+ }