@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.
Files changed (74) hide show
  1. package/README.md +4 -0
  2. package/REFERENCE.md +297 -17
  3. package/dist/renderengine-workspace.umd.js +5369 -2952
  4. package/dist/renderengine.umd.js +4466 -2320
  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/chartCatalog.js +10 -0
  11. package/src/charting/ArchimateDiagramEditor.js +1 -1
  12. package/src/charting/Axis.js +20 -0
  13. package/src/charting/ChartChooser.js +229 -0
  14. package/src/charting/DiagramEditor.js +35 -3
  15. package/src/charting/Dialog.js +195 -0
  16. package/src/charting/DuplicatesDialog.js +108 -0
  17. package/src/charting/FormDialog.js +28 -97
  18. package/src/charting/InteractionManager.js +3 -0
  19. package/src/charting/Legend.js +45 -2
  20. package/src/charting/MergeCompareDialog.js +81 -0
  21. package/src/charting/PopupMenu.js +12 -3
  22. package/src/charting/SettingsDialog.js +6 -2
  23. package/src/charting/SettingsManager.js +14 -1
  24. package/src/charting/archimate/ArchimateCanvasEditor.js +190 -15
  25. package/src/charting/archimate/ArchimatePalette.js +1 -1
  26. package/src/charting/charts/AreaChart.js +5 -2
  27. package/src/charting/charts/BarChart.js +5 -3
  28. package/src/charting/charts/BarChart3DOrbit.js +5 -7
  29. package/src/charting/charts/BubbleChart.js +5 -7
  30. package/src/charting/charts/BubbleChart3D.js +5 -7
  31. package/src/charting/charts/CandlestickChart.js +3 -1
  32. package/src/charting/charts/DensityChart.js +4 -6
  33. package/src/charting/charts/GroupedBarChart.js +2 -2
  34. package/src/charting/charts/LineChart.js +5 -2
  35. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  36. package/src/charting/charts/PipeChart.js +4 -6
  37. package/src/charting/charts/RadarChart.js +5 -6
  38. package/src/charting/charts/ScatterChart.js +5 -7
  39. package/src/charting/charts/StackedBarChart.js +4 -2
  40. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -7
  41. package/src/charting/charts/StreamGraphChart.js +5 -2
  42. package/src/charting/charts/TableChart.js +98 -0
  43. package/src/charting/charts/WaterfallChart.js +2 -1
  44. package/src/charting/content/ContentSource.js +129 -0
  45. package/src/charting/content/ContentSources.js +77 -0
  46. package/src/charting/datamodel/DataModel.js +116 -0
  47. package/src/charting/diagrams/ArchimateModel.js +38 -3
  48. package/src/charting/diagrams/ArchimateModelChecker.js +13 -4
  49. package/src/charting/diagrams/MergeElementsCommand.js +124 -101
  50. package/src/charting/diagrams/SvgZoom.js +20 -1
  51. package/src/charting/diagrams/duplicates.js +95 -13
  52. package/src/charting/duplicateSearchForm.js +58 -0
  53. package/src/charting/modelTree/ModelTree.js +239 -202
  54. package/src/charting/modelTree/ModelTreePanel.js +955 -919
  55. package/src/charting/modelTree/TreeBuilder.js +93 -45
  56. package/src/charting/modelTree/TreeViewMode.js +9 -6
  57. package/src/charting/modelTree/viewFolders.js +15 -6
  58. package/src/icons/IconSet.js +86 -0
  59. package/src/icons/index.js +59 -0
  60. package/src/index.js +86 -10
  61. package/src/workspace/dom.js +56 -42
  62. package/src/workspace/index.js +1 -1
  63. package/src/workspace/nav/NavEntry.js +42 -0
  64. package/src/workspace/nav/NavGroup.js +43 -0
  65. package/src/workspace/nav/NavItem.js +53 -0
  66. package/src/workspace/nav/NavMenu.js +143 -0
  67. package/src/workspace/nav/NavSection.js +17 -0
  68. package/src/workspace/nav/index.js +5 -0
  69. package/src/workspace/parts/ArchimateCanvasPart.js +8 -3
  70. package/src/workspace/parts/DataModelPart.js +91 -0
  71. package/src/workspace/parts/EditorPart.js +8 -0
  72. package/src/workspace/parts/ModelHosted.js +1 -0
  73. package/src/workspace/parts/ModelsPart.js +765 -622
  74. package/src/workspace/styles.js +28 -1
package/package.json CHANGED
@@ -1,70 +1,70 @@
1
- {
2
- "name": "@peter.prib/renderengine",
3
- "version": "0.5.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`). @private */
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
- const direct = items.filter(item => !item.archiFolder?.length);
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
- body.push(..._inSubFolders(viewArray, (view, indent) => _serializeView(view).map(line => indent.slice(4) + line)));
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
- walk(child, [...path, step], false);
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 organizationLines = _organizationLines({ elements, relationships, views: diagrams, ids });
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
- for (const item of items.filter(i => !i.archiFolder?.length)) lines.push(`${indent}<item identifierRef="${ids.ref(item.id)}"/>`);
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 => [
@@ -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());
@@ -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
- const findings = checker.run();
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: 'none', stroke: '#0066cc', 'stroke-width': 1.5, 'stroke-dasharray': '4,3',
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)) {