@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
package/package.json
CHANGED
|
@@ -1,70 +1,70 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@peter.prib/renderengine",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "ArchiMate modeller, diagram editor and chart library for the browser, with no runtime dependencies.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "src/index.js",
|
|
7
|
-
"module": "src/index.js",
|
|
8
|
-
"browser": "dist/renderengine.umd.js",
|
|
9
|
-
"exports": {
|
|
10
|
-
".": "./src/index.js",
|
|
11
|
-
"./workspace": "./src/workspace/index.js",
|
|
12
|
-
"./src/*": "./src/*",
|
|
13
|
-
"./dist/*": "./dist/*",
|
|
14
|
-
"./package.json": "./package.json"
|
|
15
|
-
},
|
|
16
|
-
"files": [
|
|
17
|
-
"src/",
|
|
18
|
-
"dist/*.umd.js",
|
|
19
|
-
"examples/archimate.html",
|
|
20
|
-
"examples/diagram-editor.html",
|
|
21
|
-
"examples/data-model.html",
|
|
22
|
-
"README.md",
|
|
23
|
-
"REFERENCE.md",
|
|
24
|
-
"LICENSE"
|
|
25
|
-
],
|
|
26
|
-
"scripts": {
|
|
27
|
-
"start": "node scripts/open.mjs examples/archimate.html",
|
|
28
|
-
"archimate": "node scripts/open.mjs examples/archimate.html",
|
|
29
|
-
"diagram": "node scripts/open.mjs examples/diagram-editor.html",
|
|
30
|
-
"datamodel": "node scripts/open.mjs examples/data-model.html",
|
|
31
|
-
"test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
|
|
32
|
-
"build": "rollup -c",
|
|
33
|
-
"prepublishOnly": "npm test && npm run build",
|
|
34
|
-
"screenshots": "node scripts/screenshots/screenshots.mjs"
|
|
35
|
-
},
|
|
36
|
-
"keywords": [
|
|
37
|
-
"archimate",
|
|
38
|
-
"archi",
|
|
39
|
-
"diagram",
|
|
40
|
-
"diagram-editor",
|
|
41
|
-
"modelling",
|
|
42
|
-
"charts",
|
|
43
|
-
"svg",
|
|
44
|
-
"canvas",
|
|
45
|
-
"render",
|
|
46
|
-
"engine",
|
|
47
|
-
"graphics"
|
|
48
|
-
],
|
|
49
|
-
"author": "Peter Prib <peter.prib@gmail.com>",
|
|
50
|
-
"license": "MIT",
|
|
51
|
-
"repository": {
|
|
52
|
-
"type": "git",
|
|
53
|
-
"url": "git+https://gitlab.com/peter.prib/renderengine.git"
|
|
54
|
-
},
|
|
55
|
-
"homepage": "https://gitlab.com/peter.prib/renderengine#readme",
|
|
56
|
-
"bugs": {
|
|
57
|
-
"url": "https://gitlab.com/peter.prib/renderengine/-/issues"
|
|
58
|
-
},
|
|
59
|
-
"engines": {
|
|
60
|
-
"node": ">=18"
|
|
61
|
-
},
|
|
62
|
-
"publishConfig": {
|
|
63
|
-
"access": "public"
|
|
64
|
-
},
|
|
65
|
-
"devDependencies": {
|
|
66
|
-
"@rollup/plugin-commonjs": "^25.0.8",
|
|
67
|
-
"@rollup/plugin-node-resolve": "^15.3.1",
|
|
68
|
-
"rollup": "^3.30.0"
|
|
69
|
-
}
|
|
70
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@peter.prib/renderengine",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "ArchiMate modeller, diagram editor and chart library for the browser, with no runtime dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"module": "src/index.js",
|
|
8
|
+
"browser": "dist/renderengine.umd.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./workspace": "./src/workspace/index.js",
|
|
12
|
+
"./src/*": "./src/*",
|
|
13
|
+
"./dist/*": "./dist/*",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src/",
|
|
18
|
+
"dist/*.umd.js",
|
|
19
|
+
"examples/archimate.html",
|
|
20
|
+
"examples/diagram-editor.html",
|
|
21
|
+
"examples/data-model.html",
|
|
22
|
+
"README.md",
|
|
23
|
+
"REFERENCE.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"start": "node scripts/open.mjs examples/archimate.html",
|
|
28
|
+
"archimate": "node scripts/open.mjs examples/archimate.html",
|
|
29
|
+
"diagram": "node scripts/open.mjs examples/diagram-editor.html",
|
|
30
|
+
"datamodel": "node scripts/open.mjs examples/data-model.html",
|
|
31
|
+
"test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
|
|
32
|
+
"build": "rollup -c",
|
|
33
|
+
"prepublishOnly": "npm test && npm run build",
|
|
34
|
+
"screenshots": "node scripts/screenshots/screenshots.mjs"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"archimate",
|
|
38
|
+
"archi",
|
|
39
|
+
"diagram",
|
|
40
|
+
"diagram-editor",
|
|
41
|
+
"modelling",
|
|
42
|
+
"charts",
|
|
43
|
+
"svg",
|
|
44
|
+
"canvas",
|
|
45
|
+
"render",
|
|
46
|
+
"engine",
|
|
47
|
+
"graphics"
|
|
48
|
+
],
|
|
49
|
+
"author": "Peter Prib <peter.prib@gmail.com>",
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://gitlab.com/peter.prib/renderengine.git"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://gitlab.com/peter.prib/renderengine#readme",
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://gitlab.com/peter.prib/renderengine/-/issues"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=18"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@rollup/plugin-commonjs": "^25.0.8",
|
|
67
|
+
"@rollup/plugin-node-resolve": "^15.3.1",
|
|
68
|
+
"rollup": "^3.30.0"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -207,6 +207,8 @@ export function parseArchiModel(xml) {
|
|
|
207
207
|
elements: [],
|
|
208
208
|
relationships: [],
|
|
209
209
|
views: [],
|
|
210
|
+
// The Views sub-folders that hold no view (each a path of folder steps); a view carries its own.
|
|
211
|
+
emptyViewFolders: [],
|
|
210
212
|
// The model's own documentation (Archi's "purpose") and properties, in order (keys may repeat).
|
|
211
213
|
documentation: _childText(root, 'purpose'),
|
|
212
214
|
properties: _propertyPairs(root),
|
|
@@ -240,8 +242,10 @@ function _folderStep(folder) {
|
|
|
240
242
|
}
|
|
241
243
|
|
|
242
244
|
/** Every view in the Views folder, sub-folders included, in file order; each
|
|
243
|
-
* remembers its sub-folder path (`archiFolder`).
|
|
245
|
+
* remembers its sub-folder path (`archiFolder`). A sub-folder with nothing in
|
|
246
|
+
* it is kept as its path (`emptyViewFolders`), as Archi shows it. @private */
|
|
244
247
|
function _walkViewFolder(folder, model, path = []) {
|
|
248
|
+
if (path.length && !folder.children.some(c => c.tagName === 'element' || c.tagName === 'folder')) model.emptyViewFolders.push(path);
|
|
245
249
|
for (const child of folder.children) {
|
|
246
250
|
if (child.tagName === 'element') {
|
|
247
251
|
const view = _parseView(child);
|
|
@@ -754,6 +758,7 @@ export function toArchimateModel(archiModel) {
|
|
|
754
758
|
id: archiModel.id ?? undefined,
|
|
755
759
|
name: archiModel.name ?? '', elements, relationships, folders, elementFolders, views,
|
|
756
760
|
documentation: archiModel.documentation || undefined, properties: archiModel.properties ?? [],
|
|
761
|
+
emptyViewFolders: archiModel.emptyViewFolders ?? [],
|
|
757
762
|
// The specializations written for element types Archi lacks are not the model's own.
|
|
758
763
|
archi: archiModel.archi ? {
|
|
759
764
|
...archiModel.archi,
|
|
@@ -1372,6 +1377,7 @@ export function toArchiExport(model) {
|
|
|
1372
1377
|
documentation: model.documentation,
|
|
1373
1378
|
properties: model.properties,
|
|
1374
1379
|
archi: model.archi,
|
|
1380
|
+
emptyViewFolders: model.emptyViewFolders ?? [],
|
|
1375
1381
|
elements: model.elements(),
|
|
1376
1382
|
relationships: model.relationships(),
|
|
1377
1383
|
}, views];
|
|
@@ -1406,7 +1412,8 @@ function _propertyLines(pairs, indent) {
|
|
|
1406
1412
|
*/
|
|
1407
1413
|
function _inSubFolders(items, write, indent = ' ') {
|
|
1408
1414
|
const lines = [];
|
|
1409
|
-
|
|
1415
|
+
// An empty folder comes as {archiFolder, folderOnly}: it makes its folders and writes nothing itself.
|
|
1416
|
+
const direct = items.filter(item => !item.archiFolder?.length && !item.folderOnly);
|
|
1410
1417
|
// Sub-folders in the order they are first named, each holding the items one level deeper.
|
|
1411
1418
|
const groups = new Map();
|
|
1412
1419
|
for (const item of items.filter(item => item.archiFolder?.length)) {
|
|
@@ -1492,7 +1499,8 @@ export function toArchiXML(model, views) {
|
|
|
1492
1499
|
{ id: rel.id, ...(name && { name }), source: rel.source, target: rel.target, ...(directed && { directed: 'true' }) }, rel, indent);
|
|
1493
1500
|
}));
|
|
1494
1501
|
} else if (folder.type === 'diagrams') {
|
|
1495
|
-
|
|
1502
|
+
const emptyFolders = (model.emptyViewFolders ?? []).map(archiFolder => ({ archiFolder, folderOnly: true }));
|
|
1503
|
+
body.push(..._inSubFolders([...viewArray, ...emptyFolders], (view, indent) => _serializeView(view).map(line => indent.slice(4) + line)));
|
|
1496
1504
|
} else {
|
|
1497
1505
|
body.push(..._inSubFolders(items.map(item => ({ ...item, archiFolder: item.el.archiFolder })),
|
|
1498
1506
|
({ el, type, original, profile }, indent) => _serializeConcept(`archimate:${_toPascalType(type)}`,
|
|
@@ -80,7 +80,7 @@ export function parseExchangeModel(xml) {
|
|
|
80
80
|
key: definitions.get(p.getAttribute('propertyDefinitionRef') ?? p.getAttribute('identifierRef')) ?? '',
|
|
81
81
|
value: _text(p, 'value') ?? '',
|
|
82
82
|
}));
|
|
83
|
-
const folders = _organizationPaths(root);
|
|
83
|
+
const { paths: folders, emptyViewFolders } = _organizationPaths(root);
|
|
84
84
|
|
|
85
85
|
const model = {
|
|
86
86
|
id: root.getAttribute('identifier'),
|
|
@@ -91,6 +91,7 @@ export function parseExchangeModel(xml) {
|
|
|
91
91
|
elements: [],
|
|
92
92
|
relationships: [],
|
|
93
93
|
views: [],
|
|
94
|
+
emptyViewFolders,
|
|
94
95
|
};
|
|
95
96
|
for (const el of _kids(_kid(root, 'elements'), 'element')) {
|
|
96
97
|
model.elements.push(_inFolder(_parseElement(el, pairs(el)), folders));
|
|
@@ -116,26 +117,31 @@ function _inFolder(item, folders) {
|
|
|
116
117
|
* The folder path of every item the organizations hold, by its id: each
|
|
117
118
|
* labelled item it is in, outermost first. An outermost item named as one of
|
|
118
119
|
* Archi's top-level folders is that folder, not a sub-folder (Archi exports
|
|
119
|
-
* its folders so); any other is a sub-folder.
|
|
120
|
+
* its folders so); any other is a sub-folder. An empty sub-folder of Views
|
|
121
|
+
* is kept as its path, as parseArchiModel keeps one.
|
|
120
122
|
* @private
|
|
123
|
+
* @returns {{paths: Map<string, Array>, emptyViewFolders: Array<Array>}}
|
|
121
124
|
*/
|
|
122
125
|
function _organizationPaths(root) {
|
|
123
126
|
const topNames = new Set(ARCHI_FOLDERS.map(f => f.name));
|
|
127
|
+
const viewsName = ARCHI_FOLDERS.find(f => f.type === 'diagrams').name;
|
|
124
128
|
const paths = new Map();
|
|
129
|
+
const emptyViewFolders = [];
|
|
125
130
|
let made = 0;
|
|
126
|
-
const walk = (item, path, outermost) => {
|
|
131
|
+
const walk = (item, path, outermost, top) => {
|
|
127
132
|
for (const child of _kids(item, 'item')) {
|
|
128
133
|
const ref = child.getAttribute('identifierRef');
|
|
129
134
|
if (ref) { paths.set(ref, path); continue; }
|
|
130
135
|
const name = _text(child, 'label') ?? '';
|
|
131
|
-
if (outermost && topNames.has(name)) { walk(child, path, false); continue; }
|
|
136
|
+
if (outermost && topNames.has(name)) { walk(child, path, false, name); continue; }
|
|
132
137
|
const documentation = _text(child, 'documentation');
|
|
133
138
|
const step = { id: child.getAttribute('identifier') ?? `folder-${++made}`, name, ...(documentation && { documentation }) };
|
|
134
|
-
|
|
139
|
+
if (top === viewsName && !_kids(child, 'item').length) emptyViewFolders.push([...path, step]);
|
|
140
|
+
walk(child, [...path, step], false, top);
|
|
135
141
|
}
|
|
136
142
|
};
|
|
137
|
-
for (const organization of _kids(root, 'organizations')) walk(organization, [], true);
|
|
138
|
-
return paths;
|
|
143
|
+
for (const organization of _kids(root, 'organizations')) walk(organization, [], true, null);
|
|
144
|
+
return { paths, emptyViewFolders };
|
|
139
145
|
}
|
|
140
146
|
|
|
141
147
|
/** An element as parseArchiModel gives it (type: Archi's class name). @private */
|
|
@@ -390,7 +396,8 @@ export function toExchangeXML(model, views = []) {
|
|
|
390
396
|
});
|
|
391
397
|
|
|
392
398
|
const viewLines = diagrams.flatMap(view => _viewLines(view, { ids, known, propertyLines, textLines }));
|
|
393
|
-
const
|
|
399
|
+
const emptyFolders = (model.emptyViewFolders ?? []).map(archiFolder => ({ archiFolder, folderOnly: true }));
|
|
400
|
+
const organizationLines = _organizationLines({ elements, relationships, views: [...diagrams, ...emptyFolders], ids });
|
|
394
401
|
|
|
395
402
|
// Read once every property is defined: the model's, the concepts' and the views'.
|
|
396
403
|
const modelLines = [
|
|
@@ -534,7 +541,8 @@ function _organizationLines({ elements, relationships, views, ids }) {
|
|
|
534
541
|
if (step.documentation) lines.push(`${indent} <documentation>${escapeXml(step.documentation)}</documentation>`);
|
|
535
542
|
lines.push(...write(inner, `${indent} `), `${indent}</item>`);
|
|
536
543
|
}
|
|
537
|
-
|
|
544
|
+
// An empty folder ({archiFolder, folderOnly}) makes its folders and lists nothing itself.
|
|
545
|
+
for (const item of items.filter(i => !i.archiFolder?.length && !i.folderOnly)) lines.push(`${indent}<item identifierRef="${ids.ref(item.id)}"/>`);
|
|
538
546
|
return lines;
|
|
539
547
|
};
|
|
540
548
|
return ARCHI_FOLDERS.filter(f => inFolder.get(f.type).length).flatMap(folder => [
|
package/src/chartCatalog.js
CHANGED
|
@@ -457,6 +457,16 @@ export const CHART_CATALOG = [
|
|
|
457
457
|
dataShape: 'Array<{label, value?, children?}> — same hierarchical shape as treemap/sunburst (shares _hierarchyValue); drawn as an indented, collapsible list instead of nested rectangles/rings — a branch\'s value is auto-summed from its descendants and shown as a trailing count, a leaf\'s own extra fields (anything beyond label/value/children) shown as trailing descriptive text instead',
|
|
458
458
|
streamShape: 'flat',
|
|
459
459
|
},
|
|
460
|
+
{
|
|
461
|
+
key: 'table', label: 'Table', method: 'tableChart', category: '2d',
|
|
462
|
+
coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.NONE, camera: CAMERA_MODE.NONE,
|
|
463
|
+
axes: [
|
|
464
|
+
{ role: 'row', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_ORDER, required: false },
|
|
465
|
+
{ role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.TEXT_VALUE, required: false },
|
|
466
|
+
],
|
|
467
|
+
dataShape: 'Array<object> — one row per object, a column per key (options.columns: names or {key, label?, align?}; every key of the first row when absent); values shown exactly as text, numeric columns right-aligned; options.maxRows, options.nullText',
|
|
468
|
+
streamShape: 'flat',
|
|
469
|
+
},
|
|
460
470
|
{
|
|
461
471
|
key: 'gauge-dial', label: 'Gauge (Dial)', method: 'dialGauge', category: 'gauge',
|
|
462
472
|
coordSystem: COORD_SYSTEM.RADIAL, title: true, legend: false, colorMode: COLOR_MODE.SINGLE, camera: CAMERA_MODE.NONE,
|
|
@@ -303,7 +303,7 @@ export class ArchimateDiagramEditor extends DiagramEditor {
|
|
|
303
303
|
library: this.templateLibrary,
|
|
304
304
|
document: () => this.document,
|
|
305
305
|
});
|
|
306
|
-
this.tree = new this.workspace.ModelTree(model, { mode: 'byLayer' });
|
|
306
|
+
this.tree = new this.workspace.ModelTree(model, { mode: 'byLayer', ...(this.settingsManager?.treeOptions?.() ?? {}) });
|
|
307
307
|
this.currentView = model.currentView ?? this.tree.getSelectedView();
|
|
308
308
|
this._wireTreeSelection();
|
|
309
309
|
this._unsubscribeModel = model.onChange(() => this._onModelChange());
|
package/src/charting/Axis.js
CHANGED
|
@@ -27,6 +27,26 @@ export class Axis {
|
|
|
27
27
|
return ticks;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How often a category label fits along an axis `width` wide: 1 (every
|
|
32
|
+
* label), 2 (every other), … — so a long series (a minute of samples, a
|
|
33
|
+
* day of timestamps) shows readable labels rather than an overprinted
|
|
34
|
+
* smear. Estimated from the longest label at `fontSize` (a character is
|
|
35
|
+
* about 0.6 em wide), with a small gap between labels.
|
|
36
|
+
* @param {Array<*>} labels
|
|
37
|
+
* @param {number} width
|
|
38
|
+
* @param {Object} [options]
|
|
39
|
+
* @param {number} [options.fontSize=12] - In px
|
|
40
|
+
* @returns {number} the step: draw label i when i % step === 0
|
|
41
|
+
*/
|
|
42
|
+
static categoryLabelStep(labels, width, { fontSize = 12 } = {}) {
|
|
43
|
+
const count = labels?.length ?? 0;
|
|
44
|
+
if (count < 2 || !(width > 0)) return 1;
|
|
45
|
+
const longest = Math.max(...labels.map(l => String(l ?? '').length));
|
|
46
|
+
const need = longest * fontSize * 0.6 + 6;
|
|
47
|
+
return Math.max(1, Math.ceil(need / (width / count)));
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
/**
|
|
31
51
|
* Format a numeric value for axis labels (k/M suffixes, fixed decimals).
|
|
32
52
|
* @param {number} v
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { AXIS_KIND, chartRegistry } from '../chartCatalog.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ChartChooser — which catalog chart types fit a set of result rows, best first, each with the rows
|
|
5
|
+
* already shaped as that chart's `dataShape`. A table always fits, so it is always offered, last.
|
|
6
|
+
*
|
|
7
|
+
* The caller may describe the columns (hints). `kind` is `'categorical'`, `'numeric'` or `'time'`; a
|
|
8
|
+
* column with no hint is numeric when every value it holds is a number, else categorical. `measure`
|
|
9
|
+
* is renderengine's measure kind (`'additive'`, `'semi-additive'`, `'non-additive'`), as a data model
|
|
10
|
+
* gives it. The hints change the choice: a time column is drawn along a line, and only an additive
|
|
11
|
+
* measure is offered as shares of a whole (a pie), since a part-of-a-whole slice of an average or a
|
|
12
|
+
* balance means nothing.
|
|
13
|
+
*
|
|
14
|
+
* Every suggestion is checked against CHART_CATALOG: a chart type is offered only when the catalog
|
|
15
|
+
* has it and every axis it requires is one the result's shape provides — so the catalog stays the
|
|
16
|
+
* single source of truth for what each chart needs.
|
|
17
|
+
*
|
|
18
|
+
* const [best, ...others] = new ChartChooser().choose(rows, [{ name: 'Day', kind: 'time' }]);
|
|
19
|
+
* engine.renderChartType(best.key, best.data, { title: best.label });
|
|
20
|
+
*/
|
|
21
|
+
export class ChartChooser {
|
|
22
|
+
/** @param {ChartRegistry} [registry] — the built-in chart types, or another registry to choose from */
|
|
23
|
+
constructor(registry = chartRegistry) {
|
|
24
|
+
this.registry = registry;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {Array<object>} rows
|
|
29
|
+
* @param {Array<{name:string, kind?:string, measure?:string}>} [columns] — hints; every key of the
|
|
30
|
+
* first row when absent, in that order
|
|
31
|
+
* @param {{complete?:boolean}} [options] — `complete: false` when the rows are only part of the
|
|
32
|
+
* answer (a top N, a first page): then nothing is offered as shares of a whole, since the
|
|
33
|
+
* slices would claim to be all of it
|
|
34
|
+
* @returns {Array<{key:string, label:string, data:*, reason:string}>} best first; the table last
|
|
35
|
+
*/
|
|
36
|
+
choose(rows, columns = null, { complete = true } = {}) {
|
|
37
|
+
const described = ChartChooser.describe(rows, columns);
|
|
38
|
+
const shape = RESULT_SHAPES.map(S => S.of(described, rows)).find(s => s !== null) ?? null;
|
|
39
|
+
if (shape !== null) shape.complete = complete;
|
|
40
|
+
const charts = shape === null ? [] : shape.candidates()
|
|
41
|
+
.filter(({ key }) => this.fits(key, shape))
|
|
42
|
+
.map(({ key, reason, data }) => ({ key, label: this.registry.get(key).label, data, reason }));
|
|
43
|
+
return [...charts, this.table(rows, described, charts.length === 0)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A catalog entry fits a shape when every axis it requires is one the shape provides. */
|
|
47
|
+
fits(key, shape) {
|
|
48
|
+
const entry = this.registry.get(key);
|
|
49
|
+
if (!entry) return false;
|
|
50
|
+
if (shape.needsSeries && !entry.seriesCapable && !entry.axes.some(a => a.role === 'series')) return false;
|
|
51
|
+
return entry.axes.filter(a => a.required).every(a => shape.provides[a.role] === a.kind);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
table(rows, described, only) {
|
|
55
|
+
return {
|
|
56
|
+
key: 'table', label: this.registry.get('table')?.label ?? 'Table',
|
|
57
|
+
data: rows, columns: described.map(c => c.name),
|
|
58
|
+
reason: only ? 'no chart fits these columns' : 'every value, exactly',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The columns with their kinds settled: the hint's, else derived from the rows' values. */
|
|
63
|
+
static describe(rows, columns) {
|
|
64
|
+
const given = columns ?? Object.keys(rows[0] ?? {}).map(name => ({ name }));
|
|
65
|
+
return given.map(c => ({
|
|
66
|
+
name: c.name,
|
|
67
|
+
kind: c.kind ?? (rows.length > 0 && rows.every(r => r[c.name] == null || typeof r[c.name] === 'number') ? 'numeric' : 'categorical'),
|
|
68
|
+
measure: c.measure ?? null,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The label a category value is drawn with. */
|
|
74
|
+
function label(value) {
|
|
75
|
+
return value == null ? '—' : value instanceof Date ? value.toISOString().slice(0, 10) : String(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Rows in time order by `column` (numbers, dates and ISO strings all sort correctly this way). */
|
|
79
|
+
function inOrder(rows, column) {
|
|
80
|
+
return [...rows].sort((a, b) => (a[column] < b[column] ? -1 : a[column] > b[column] ? 1 : 0));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* ResultShape — what a result's columns look like: how many category, time and numeric columns.
|
|
85
|
+
* Each subclass recognises one shape (`static of`), says which catalog axes it provides, and lists
|
|
86
|
+
* the chart types that suit it, best first, with the rows shaped for each.
|
|
87
|
+
*/
|
|
88
|
+
class ResultShape {
|
|
89
|
+
constructor() {
|
|
90
|
+
/** Whether the rows are all of the answer (ChartChooser.choose's `complete`). */
|
|
91
|
+
this.complete = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The axes this shape can fill, role → AXIS_KIND. */
|
|
95
|
+
get provides() { return {}; }
|
|
96
|
+
/** Whether a chart must draw several series (one per numeric column). */
|
|
97
|
+
get needsSeries() { return false; }
|
|
98
|
+
/** @returns {Array<{key, reason, data}>} */
|
|
99
|
+
candidates() { return []; }
|
|
100
|
+
|
|
101
|
+
static split(columns) {
|
|
102
|
+
return {
|
|
103
|
+
categories: columns.filter(c => c.kind !== 'numeric'),
|
|
104
|
+
numbers: columns.filter(c => c.kind === 'numeric'),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One number and nothing to group it by: a single figure. */
|
|
110
|
+
class SingleFigure extends ResultShape {
|
|
111
|
+
static of(columns, rows) {
|
|
112
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
113
|
+
return categories.length === 0 && numbers.length === 1 && rows.length === 1 ? new SingleFigure(numbers[0], rows) : null;
|
|
114
|
+
}
|
|
115
|
+
constructor(number, rows) { super(); this.number = number; this.rows = rows; }
|
|
116
|
+
get provides() { return { value: AXIS_KIND.NUMERIC }; }
|
|
117
|
+
candidates() {
|
|
118
|
+
return [{ key: 'kpi-tile', reason: 'a single figure', data: { value: this.rows[0][this.number.name] } }];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One category (or time) column and one number: a value per category. */
|
|
123
|
+
class CategoryValue extends ResultShape {
|
|
124
|
+
static of(columns, rows) {
|
|
125
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
126
|
+
return categories.length === 1 && numbers.length === 1 ? new CategoryValue(categories[0], numbers[0], rows) : null;
|
|
127
|
+
}
|
|
128
|
+
constructor(category, number, rows) { super(); this.category = category; this.number = number; this.rows = rows; }
|
|
129
|
+
get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC }; }
|
|
130
|
+
|
|
131
|
+
candidates() {
|
|
132
|
+
const { category, number } = this;
|
|
133
|
+
const time = category.kind === 'time';
|
|
134
|
+
const rows = time ? inOrder(this.rows, category.name) : this.rows;
|
|
135
|
+
const data = rows.filter(r => r[number.name] != null).map(r => ({ label: label(r[category.name]), value: r[number.name] }));
|
|
136
|
+
if (time) {
|
|
137
|
+
return [
|
|
138
|
+
{ key: 'line', reason: `${number.name} over ${category.name}`, data },
|
|
139
|
+
{ key: 'area', reason: `${number.name} over ${category.name}, filled`, data },
|
|
140
|
+
{ key: 'bar', reason: `${number.name} for each ${category.name}`, data },
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
const options = [{ key: 'bar', reason: `${number.name} for each ${category.name}`, data }];
|
|
144
|
+
if (this.isShareOfWhole(data)) {
|
|
145
|
+
options.push({ key: 'pie', reason: `each ${category.name}'s share of the total ${number.name}`, data });
|
|
146
|
+
options.push({ key: 'donut', reason: `each ${category.name}'s share of the total ${number.name}`, data });
|
|
147
|
+
}
|
|
148
|
+
return options;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Shares of a whole make sense only for an additive measure, over all of the rows (not a top N),
|
|
153
|
+
* with no negatives, in few slices.
|
|
154
|
+
*/
|
|
155
|
+
isShareOfWhole(data) {
|
|
156
|
+
return this.complete && this.number.measure === 'additive' && data.length >= 2 && data.length <= CategoryValue.MAX_SLICES
|
|
157
|
+
&& data.every(d => d.value >= 0);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
CategoryValue.MAX_SLICES = 8;
|
|
161
|
+
|
|
162
|
+
/** One category (or time) column and several numbers: a series per number. */
|
|
163
|
+
class CategorySeries extends ResultShape {
|
|
164
|
+
static of(columns, rows) {
|
|
165
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
166
|
+
return categories.length === 1 && numbers.length >= 2 ? new CategorySeries(categories[0], numbers, rows) : null;
|
|
167
|
+
}
|
|
168
|
+
constructor(category, numbers, rows) { super(); this.category = category; this.numbers = numbers; this.rows = rows; }
|
|
169
|
+
get provides() { return { category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC, series: AXIS_KIND.CATEGORICAL }; }
|
|
170
|
+
get needsSeries() { return true; }
|
|
171
|
+
|
|
172
|
+
candidates() {
|
|
173
|
+
const { category } = this;
|
|
174
|
+
const time = category.kind === 'time';
|
|
175
|
+
const rows = time ? inOrder(this.rows, category.name) : this.rows;
|
|
176
|
+
// lineChart and groupedBarChart have no gap for a missing value: a null would have to be drawn
|
|
177
|
+
// as 0, a figure the data does not hold. So a result with one is not offered as series.
|
|
178
|
+
if (rows.some(r => this.numbers.some(n => r[n.name] == null))) return [];
|
|
179
|
+
const data = this.numbers.map(n => ({
|
|
180
|
+
name: n.name,
|
|
181
|
+
values: rows.map(r => ({ label: label(r[category.name]), value: r[n.name] })),
|
|
182
|
+
}));
|
|
183
|
+
const names = this.numbers.map(n => n.name).join(', ');
|
|
184
|
+
return time
|
|
185
|
+
? [{ key: 'line', reason: `${names} over ${category.name}`, data }, { key: 'groupedBar', reason: `${names} for each ${category.name}`, data }]
|
|
186
|
+
: [{ key: 'groupedBar', reason: `${names} for each ${category.name}`, data }, { key: 'line', reason: `${names} across ${category.name}`, data }];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Two category (or time) columns and one number: a value per pair. */
|
|
191
|
+
class TwoCategories extends ResultShape {
|
|
192
|
+
static of(columns, rows) {
|
|
193
|
+
const { categories, numbers } = ResultShape.split(columns);
|
|
194
|
+
return categories.length === 2 && numbers.length === 1 ? new TwoCategories(categories, numbers[0], rows) : null;
|
|
195
|
+
}
|
|
196
|
+
constructor(categories, number, rows) {
|
|
197
|
+
super();
|
|
198
|
+
// A time column goes along the x axis, whichever order the columns came in.
|
|
199
|
+
this.across = categories.find(c => c.kind === 'time') ?? categories[0];
|
|
200
|
+
this.down = categories.find(c => c !== this.across);
|
|
201
|
+
this.number = number;
|
|
202
|
+
this.rows = rows;
|
|
203
|
+
}
|
|
204
|
+
get provides() {
|
|
205
|
+
return { x: AXIS_KIND.CATEGORICAL, y: AXIS_KIND.CATEGORICAL, category: AXIS_KIND.CATEGORICAL, value: AXIS_KIND.NUMERIC, series: AXIS_KIND.CATEGORICAL };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
candidates() {
|
|
209
|
+
const { across, down, number } = this;
|
|
210
|
+
const rows = across.kind === 'time' ? inOrder(this.rows, across.name) : this.rows;
|
|
211
|
+
const heat = rows.filter(r => r[number.name] != null)
|
|
212
|
+
.map(r => ({ x: label(r[across.name]), y: label(r[down.name]), value: r[number.name] }));
|
|
213
|
+
const heatmap = { key: 'heatmap', reason: `${number.name} for each ${across.name} and ${down.name}`, data: heat };
|
|
214
|
+
// A series needs a value for every label; where a pair is missing (or null) only the heatmap,
|
|
215
|
+
// which draws just the cells present, shows the data without inventing a 0.
|
|
216
|
+
const labels = [...new Set(rows.map(r => label(r[across.name])))];
|
|
217
|
+
const value = new Map(rows.map(r => [`${label(r[across.name])}\u0000${label(r[down.name])}`, r[number.name]]));
|
|
218
|
+
const names = [...new Set(rows.map(r => label(r[down.name])))];
|
|
219
|
+
if (names.some(name => labels.some(l => value.get(`${l}\u0000${name}`) == null))) return [heatmap];
|
|
220
|
+
const series = names.map(name => ({ name, values: labels.map(l => ({ label: l, value: value.get(`${l}\u0000${name}`) })) }));
|
|
221
|
+
const grouped = { key: 'groupedBar', reason: `${number.name} for each ${across.name}, by ${down.name}`, data: series };
|
|
222
|
+
return across.kind === 'time'
|
|
223
|
+
? [{ key: 'line', reason: `${number.name} over ${across.name}, a line per ${down.name}`, data: series }, heatmap, grouped]
|
|
224
|
+
: [grouped, heatmap];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Shapes in the order they are tried; the first that recognises the columns wins. */
|
|
229
|
+
const RESULT_SHAPES = [SingleFigure, CategoryValue, CategorySeries, TwoCategories];
|
|
@@ -59,6 +59,9 @@ import {
|
|
|
59
59
|
* toolbar container) to itself.
|
|
60
60
|
*/
|
|
61
61
|
export class DiagramEditor {
|
|
62
|
+
/** How a selected shape (besides the one with handles) is marked: an outline `pad` outside it, tinted. */
|
|
63
|
+
static SELECTION_OUTLINE = Object.freeze({ pad: 4, stroke: '#0066cc', strokeWidth: 2, dash: '5,3', fill: 'rgba(0, 102, 204, 0.08)' });
|
|
64
|
+
|
|
62
65
|
/**
|
|
63
66
|
* @param {Object} options
|
|
64
67
|
* @param {string} [options.diagramContainerId] - Id of the SVG (or its container) to draw into
|
|
@@ -389,6 +392,7 @@ export class DiagramEditor {
|
|
|
389
392
|
svgElement.addEventListener('contextmenu', (e) => {
|
|
390
393
|
e.stopPropagation();
|
|
391
394
|
e.preventDefault();
|
|
395
|
+
this.selectForMenu(elementId, { extend: e.ctrlKey || e.metaKey || e.shiftKey });
|
|
392
396
|
this._showOrderingMenu(elementId, e.clientX, e.clientY);
|
|
393
397
|
});
|
|
394
398
|
}
|
|
@@ -914,16 +918,31 @@ export class DiagramEditor {
|
|
|
914
918
|
showModelCheck() {
|
|
915
919
|
const checker = this.model?.checker?.(this.checkerOptions());
|
|
916
920
|
if (!checker) return [];
|
|
917
|
-
|
|
921
|
+
return this.showFindings(checker.run());
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Some findings in the model check's dialog (each shown when chosen, with
|
|
926
|
+
* its actions), under `title` (text, or a function of the findings) and
|
|
927
|
+
* saying `empty` when there are none.
|
|
928
|
+
*/
|
|
929
|
+
showFindings(findings, { title = null, empty = undefined } = {}) {
|
|
918
930
|
new ModelCheckDialog({
|
|
919
931
|
findings,
|
|
920
932
|
onReveal: (finding) => this.revealFinding(finding),
|
|
921
933
|
actionsFor: (finding) => this.findingActions(finding),
|
|
922
934
|
document: this.document ?? document,
|
|
935
|
+
title: typeof title === 'function' ? title(findings) : title,
|
|
936
|
+
...(empty !== undefined && { empty }),
|
|
923
937
|
}).open();
|
|
924
938
|
return findings;
|
|
925
939
|
}
|
|
926
940
|
|
|
941
|
+
/** Edits of the whole model this editor offers (the Edit menu shows them): none here; a subclass adds its own. */
|
|
942
|
+
modelMenuItems() {
|
|
943
|
+
return [];
|
|
944
|
+
}
|
|
945
|
+
|
|
927
946
|
/** What can be done about a finding, beside showing it (`[{label, action}]`): nothing here; a subclass offers its own (Merge…). */
|
|
928
947
|
findingActions(finding) {
|
|
929
948
|
return [];
|
|
@@ -1051,9 +1070,11 @@ export class DiagramEditor {
|
|
|
1051
1070
|
if (!t) continue;
|
|
1052
1071
|
const b = box(t);
|
|
1053
1072
|
if (id !== this.selectedTemplateId) {
|
|
1073
|
+
// Set off the shape's own border, and tinted, so it shows on any box.
|
|
1074
|
+
const { pad, stroke, strokeWidth, dash, fill } = this.constructor.SELECTION_OUTLINE;
|
|
1054
1075
|
layer.appendChild(this._svg('rect', {
|
|
1055
|
-
class: 'selection-outline', x: b.x1, y: b.y1, width: b.x2 - b.x1, height: b.y2 - b.y1,
|
|
1056
|
-
fill
|
|
1076
|
+
class: 'selection-outline', x: b.x1 - pad, y: b.y1 - pad, width: b.x2 - b.x1 + 2 * pad, height: b.y2 - b.y1 + 2 * pad,
|
|
1077
|
+
fill, stroke, 'stroke-width': strokeWidth, 'stroke-dasharray': dash, rx: 3,
|
|
1057
1078
|
}));
|
|
1058
1079
|
}
|
|
1059
1080
|
if (this.arrangement.isLocked(id)) {
|
|
@@ -3146,6 +3167,17 @@ export class DiagramEditor {
|
|
|
3146
3167
|
* @param {string} templateId
|
|
3147
3168
|
* @param {{extend?: boolean}} [options]
|
|
3148
3169
|
*/
|
|
3170
|
+
/**
|
|
3171
|
+
* Before a shape's menu: with `extend` (Ctrl, ⌘ or Shift held) the shape
|
|
3172
|
+
* joins the selection; otherwise a shape outside the selection becomes it.
|
|
3173
|
+
* A shape already selected leaves the selection as it is, so the menu acts
|
|
3174
|
+
* on all of it (Merge, Match size, Properties of N shapes).
|
|
3175
|
+
*/
|
|
3176
|
+
selectForMenu(templateId, { extend = false } = {}) {
|
|
3177
|
+
if (this.selectedIds.has(templateId)) return;
|
|
3178
|
+
this.selectTemplate(templateId, { extend });
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3149
3181
|
selectTemplate(templateId, { extend = false } = {}) {
|
|
3150
3182
|
if (extend) {
|
|
3151
3183
|
if (this.selectedIds.has(templateId)) {
|