@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/README.md CHANGED
@@ -29,6 +29,10 @@ open the pages directly:
29
29
  - `examples/diagram-editor.html`: the diagram editor
30
30
  - `examples/data-model.html`: the data modeller
31
31
 
32
+ The models they open come from [modelengine](https://gitlab.com/peter.prib/modelengine), kept apart from the tools: with
33
+ it checked out beside renderengine, the pages open on its models (File ▸ Open from ▸ modelengine lists them by domain);
34
+ without it they start empty.
35
+
32
36
  All work in current Chrome, Edge, Firefox and Safari. Save writes back to the file you opened in Chrome and Edge;
33
37
  other browsers download the file instead.
34
38
 
package/REFERENCE.md CHANGED
@@ -88,6 +88,7 @@ catalog `key` for you.
88
88
  | `treemap` | `treemapChart(data, opts)` | `Array<{label, value?, children?}>` (hierarchical) | squarified layout, palette by top-level branch |
89
89
  | `sunburst` | `sunburstChart(data, opts)` | same hierarchical shape as `treemap` | nested rings by depth |
90
90
  | `outline` | `outlineChart(data, opts)` | same hierarchical shape as `treemap`/`sunburst` | `onNodeClick`; collapsible indented list |
91
+ | `table` | `tableChart(data, opts)` | `Array<object>` — a row per object, a column per key | `columns` (names or `{key, label?, align?}`), `maxRows`, `nullText` (`'—'`); values exact, numeric columns right-aligned; drawn with the renderer's primitives, so SVG or canvas |
91
92
  | `gauge-dial` | `dialGauge(value, opts)` | `number`, `{value, target?}`, or `[{value, label?, color?}]` for a needle each (0–100 unless `min`/`max`) | the gauge options below; `cx`, `cy`, `r`, `thickness`, `startAngle`, `sweep`, `needleColors`; several needles get a legend, the first fills the arc |
92
93
  | `gauge-bar` | `barGauge(value, opts)` | `number` or `{value, target?}` (0–100 unless `min`/`max`) | the gauge options below; `x`, `y`, `width`, `height`, `orientation: 'vertical'` |
93
94
  | `gauge-arc` | `arcGauge(value, opts)` | `number` or `{value, target?}` | the gauge options below; `cx`, `cy`, `r`, `thickness`, `startAngle`, `sweep` (a half circle by default), `cap` |
@@ -812,6 +813,22 @@ types — see `ARCHITECTURE.md`):
812
813
  is not there. Model ▸ **Check model…** lists the findings in the `ModelCheckDialog` the diagram editors' check uses;
813
814
  choosing one shows it (the entity's level, the service domain seen through, the mapping view).
814
815
 
816
+ **Likely duplicate entities.** **Find likely duplicates…** (Model ▸, Tools ▸ and the model's tab; the part's
817
+ `modelMenuItems()`) asks for a level (the one in front first) and how to compare, with the form the ArchiMate
818
+ modeller uses (`duplicateSearchFields`): names, definitions or attributes (their names together), exactly or by their
819
+ words, and the noise to leave out. The groups found are listed as the ArchiMate modeller lists them
820
+ (`DuplicatesDialog`, below), and merging shows the entities side by side first (`model.entityComparison(level, ids)`:
821
+ name, definition, each attribute, relationships, what each is mapped to; `MergeCompareDialog`): choose the entity
822
+ kept and, when their definitions differ, which (or all joined); entities alike merge without asking. The kept one takes the
823
+ attributes it lacks (by name, any case), their relationships (one left joining it to itself, or doubling one there,
824
+ goes) and their mapping pairs; the others go, perspectives and contexts forgetting them, one undo step
825
+ (`model.duplicateEntities(level, options)`, `model.mergeEntitiesCommand(level, keepId, otherIds, { description })`).
826
+
827
+ ```js
828
+ model.duplicateEntities('logical', { by: 'attributes' }); // [{ code: 'possible-duplicate', level, ids, message }]
829
+ part.runModelEdit(model.mergeEntitiesCommand('logical', 'customer', ['client']));
830
+ ```
831
+
815
832
  **Checked as DMBOK2 reviews a model.** The check also finds, at the logical and physical levels: an entity with
816
833
  attributes and no primary key (`no-primary-key`); a foreign key whose `references` (an entity, or `entity.attribute`,
817
834
  by id or name) is not there (`reference-missing`) or has no relationship with its entity either way
@@ -949,8 +966,9 @@ types — see `ARCHITECTURE.md`):
949
966
  service domains.
950
967
  `model.serviceDomainsCommand(specs, source)` brings them in as one undo step: a new one is added; one already
951
968
  there (by id, else by name) keeps its control record, qualifiers and the rest, taking its area and domain from
952
- the landscape, and its asset and pattern where it has none. `examples/bian-landscape-sample.xml` is a small
953
- excerpt in that shape to try it with (not BIAN's landscape, which is BIAN's to publish).
969
+ the landscape, and its asset and pattern where it has none. modelengine's banking domain has a small excerpt in
970
+ that shape to try it with (`test/fixtures/bian-landscape-sample.xml` here; not BIAN's landscape, which is BIAN's to
971
+ publish).
954
972
 
955
973
  ```javascript
956
974
  const specs = new BianLandscapeReader().read(BianLandscapeReader.parse(xml));
@@ -1045,7 +1063,8 @@ types — see `ARCHITECTURE.md`):
1045
1063
  and Redo; Tools ▸ Compare with and Merge from (the other open models and the bases; a merge is one undo step); Model ▸ New / Edit / Delete perspective and context (those dialogs), Perspective and Context selects (when
1046
1064
  it has some; a perspective brings its context, and the context's scope shows beside them), Level (Conceptual,
1047
1065
  Logical, Physical, and Mapping), Notation, and Export ▸ PNG / SVG. A box or note dragged stays on the level it
1048
- was dragged on (the mapping view's in its own layout). `examples/data-model.html` (`npm run datamodel`) uses every
1066
+ was dragged on (the mapping view's in its own layout). modelengine's enterprise ontology, which
1067
+ `examples/data-model.html` (`npm run datamodel`) opens when modelengine is checked out beside renderengine, uses every
1049
1068
  feature above. A data model can also be registered like a chart:
1050
1069
  `engine.register(id, 'dataModel', { entities, relationships, level })` then `engine.refreshChart(id)` (each
1051
1070
  diagram catalog entry names its data options, `args`).
@@ -1062,6 +1081,50 @@ this to `line`/`area`/`stackedBar`/etc. is the same three-line change: call
1062
1081
  `this._drawYAxisTicks(x, y, width, height, rawMax)` before drawing marks, and scale against its
1063
1082
  return value instead of the raw max — not done yet for the rest, tracked as a follow-up.
1064
1083
 
1084
+ ### Choosing a chart for result rows — `ChartChooser`
1085
+
1086
+ `new ChartChooser().choose(rows, columns?, { complete? })` says which chart types fit a set of result rows (a query
1087
+ answer, a CSV), best first, each with the rows already shaped as that type's `dataShape` — so
1088
+ drawing the best one is `engine.renderChartType(best.key, best.data)`. A `table` always fits and is
1089
+ always offered, last.
1090
+
1091
+ ```javascript
1092
+ import { RenderEngine, ChartChooser } from '@peter.prib/renderengine';
1093
+
1094
+ const rows = [{ Region: 'West', Revenue: 200 }, { Region: 'East', Revenue: 405 }];
1095
+ const choices = new ChartChooser().choose(rows, [{ name: 'Region' }, { name: 'Revenue', measure: 'additive' }]);
1096
+ // → bar, pie, donut, table — each { key, label, data, reason }
1097
+ const [best] = choices;
1098
+ new RenderEngine(container).renderChartType(best.key, best.data, { title: best.reason });
1099
+ ```
1100
+
1101
+ `columns` are optional hints, `{ name, kind?, measure? }`: `kind` is `'categorical'`, `'numeric'`
1102
+ or `'time'` (without a hint, a column whose values are all numbers is numeric, else categorical);
1103
+ `measure` is the data model's measure kind (`'additive'`, `'semi-additive'`, `'non-additive'`).
1104
+ `complete: false` says the rows are only part of the answer (a top N, a first page).
1105
+
1106
+ | Rows look like | Offered, best first |
1107
+ |---|---|
1108
+ | one number, one row | `kpi-tile` |
1109
+ | one category, one number | `bar`; also `pie`, `donut` when the measure is additive, the rows are complete, no value is negative and there are 2–8 categories |
1110
+ | one time column, one number | `line`, `area`, `bar` — in time order |
1111
+ | one category, several numbers | `groupedBar`, `line` (a series per number); with time, `line` first |
1112
+ | two categories, one number | `groupedBar`, `heatmap`; with a time column, `line` (a line per category) first |
1113
+ | anything else | `table` only |
1114
+
1115
+ Three rules keep a choice from misleading: only an **additive** measure is offered as shares of a
1116
+ whole (a slice of an average or a balance means nothing); only **complete** rows are (three
1117
+ customers' slices of a top 3 would claim to be the whole); and a **missing value is never drawn as
1118
+ 0** — a result with a hole in a series is not offered as series charts (the heatmap, which draws
1119
+ only the cells present, still is), and a bar chart simply has no bar for a null.
1120
+
1121
+ A table has no axes, so it takes none of a chart's axis margins. `TableChart.heightFor(rowCount,
1122
+ { title?, maxRows? })` is the height it will draw, for sizing its container first.
1123
+
1124
+ Every suggestion is checked against the registry the chooser is given (`chartRegistry` by default):
1125
+ a type is offered only if the catalog has it and every axis it marks `required` is one the result's
1126
+ shape provides, so the catalog stays the single source of truth for what each chart needs.
1127
+
1065
1128
  ## Common options
1066
1129
 
1067
1130
  Not every method takes every option below — the table above calls out what's notable per type —
@@ -1077,6 +1140,13 @@ but these recur across most 2D chart types:
1077
1140
  }
1078
1141
  ```
1079
1142
 
1143
+ - **Legend placement** (`Legend.beside`, every chart with a legend): to the right of the plot when there is
1144
+ room in the drawing; otherwise, since the plot usually fills it, inside the plot's top-right corner on a
1145
+ light backing. A chart of points (scatter, bubble) marks its entries with circles, the others with squares.
1146
+ - **Category labels** (line, area, bar, stacked bar, candlestick, waterfall, streamgraph): as many as fit.
1147
+ When they would overprint, every n-th is drawn, n from the longest label and the plot width
1148
+ (`Axis.categoryLabelStep(labels, width, { fontSize })`).
1149
+
1080
1150
  Data-resolution options (used with `register()`/`refreshChart()`, see below):
1081
1151
 
1082
1152
  ```javascript
@@ -1112,6 +1182,8 @@ engine.setGlobalData(data) // set a global object referenceable by
1112
1182
  engine.resolvePath(path, mapper?) // e.g. resolvePath('metrics.sales.0.value')
1113
1183
  engine.register(id, type, options) // remember a {type, options} pair for a container id
1114
1184
  engine.refreshChart(id) // re-invoke it — resolves options.data or dataPath+dataMapper fresh each time
1185
+ engine.refreshAll() // every registered chart
1186
+ engine.setData(data) // setGlobalData(data), then refreshAll(): a live page's poll
1115
1187
  engine.niceTicks(maxVal, count=4) // readable axis tick values
1116
1188
  engine.formatValue(v) // 1234567 -> "1.23M"
1117
1189
  engine.bindTooltip(element, text)
@@ -1120,7 +1192,24 @@ engine.adjustColor(hex, amount) // lighten/darken, used internally for 3
1120
1192
  ```
1121
1193
 
1122
1194
  `register`/`refreshChart` is the registration mechanism `addDataPoint`/`addGraphNode` (next section)
1123
- build on.
1195
+ build on. **One engine keeps a page of charts:** each registered id is drawn in the element with that id, by
1196
+ the engine itself when it is its own container, otherwise by an engine of its own for that element, made with
1197
+ this engine's options and kept while the element is the same, so a zoom survives the next refresh. A chart
1198
+ whose `dataPath` resolves to nothing yet is not drawn.
1199
+
1200
+ ```javascript
1201
+ const charts = new RenderEngine(null, { rendererType: 'svg', zoom: { wheelModifier: 'ctrl' } });
1202
+ charts.register('cpu-gauge', 'gauge-dial', { dataPath: 'cpuPercent' });
1203
+ charts.register('perf-chart', 'line', {
1204
+ dataPath: 'history',
1205
+ dataMapper: (h) => [{ name: 'CPU', values: h.map(x => ({ label: x.t, value: x.cpu })) }],
1206
+ });
1207
+ setInterval(async () => charts.setData(await (await fetch('/api/metrics')).json()), 2000);
1208
+ ```
1209
+
1210
+ **Zoom.** An SVG chart zooms with the mouse wheel. The engine option `zoom: { wheelModifier: 'ctrl' }`
1211
+ (or `'alt'`, `'shift'`; `ctrl` also takes ⌘) zooms only with that key held, so a plain wheel scrolls a page of
1212
+ charts; `zoom: false` turns it off.
1124
1213
 
1125
1214
  ## Interactivity
1126
1215
 
@@ -1359,6 +1448,24 @@ do, and a folder of one type in the Layers tree too (`typeGlyph(typeName, { size
1359
1448
  `archimate/typeGlyph.js`, the one drawing both use; a 3.2 type under ArchiMate 4 is drawn as 3.2 draws it).
1360
1449
  `TreeBuilder`'s `iconFor(node)` gives a node a picture in place of its `icon` text.
1361
1450
 
1451
+ **Tree rows and long folders.** A tree row is one compact line: a label too long for the panel ends in an ellipsis,
1452
+ with the whole of it as the row's tooltip. A folder shows its first `leafLimit` leaves (8 in the model tree,
1453
+ `ModelTree.LEAF_LIMIT`, set per tree by `new ModelTree(model, { leafLimit })` or `setOptions({ leafLimit })`), then
1454
+ a "▸ N more" row that shows the rest; the folders inside it always show. A folder asked for more keeps showing all
1455
+ when the tree is redrawn (`TreeBuilder`'s `shownAll`, a Set of node ids the caller keeps, as it keeps `openState`).
1456
+ `TreeBuilder`'s `filter(node)` says which leaves to show: a folder shows when a leaf under it does, and opens while
1457
+ filtering (without changing what `openState` remembers); one with nothing kept inside is left out, whatever its own
1458
+ name.
1459
+
1460
+ ```js
1461
+ const tree = new TreeBuilder(document, {
1462
+ container, rootNodes, getNodeChildren: (node) => node.kids ?? [],
1463
+ leafLimit: 8, shownAll: new Set(),
1464
+ filter: (node) => node.label.toLowerCase().includes('order'), // null: every node
1465
+ });
1466
+ tree.render();
1467
+ ```
1468
+
1362
1469
  **Nesting.** In the ArchiMate canvas editor, dropping a box wholly inside an element's box nests it there (Archi's
1363
1470
  nested elements); so does dropping an element with its centre on another element's box when a relationship a
1364
1471
  nesting stands for is allowed between them (`nestsOnDrop`), and the parent then grows to show it whole, with its
@@ -1441,9 +1548,15 @@ there (`editor.addImage(dataURL, point)`, one undo step, selected; the base edit
1441
1548
  relationships) under the palette, for an element chosen on the canvas or in the tree. The tree's own filters (its
1442
1549
  "Filters…" dialog: text, types, specializations, properties) dim what they leave out on the canvas too:
1443
1550
  `modelTree.matchingElementIds()` (null with no filter on) and `modelTree.onFilterChange(fn)` feed
1444
- `editor.setElementFilter`. Under the search box, **In:** chooses what the search covers, the whole model or only
1551
+ `editor.setElementFilter`. The search text filters from 3 characters on (`ModelTree.MIN_FILTER_CHARS`; per tree,
1552
+ `minFilterChars` or `setOptions({ minFilterChars })`); shorter, nothing is filtered (`modelTree.filterText` is the
1553
+ text in effect, `getFilterText()` the text typed). While it filters, the box is marked (`data-filtering`, a blue
1554
+ border) and a line under it says what was found ("Filtering: 3 elements, 1 view found"); with fewer characters it
1555
+ says how many more to type. It finds views, templates and folders by name as well as
1556
+ elements, and leaves out the folders with nothing found in them (`modelTree.textMatches(name, documentation)`; type,
1557
+ specialization and property filters are the elements' only). Under the search box, **In:** chooses what the search covers, the whole model or only
1445
1558
  the diagram in front (`modelTree.setSearchScope('model' | 'view')`, `ModelTree.SEARCH_SCOPES`), and
1446
- **Descriptions** looks for the text in elements' and relationships' documentation too
1559
+ **Descriptions** looks for the text in elements', relationships' and views' documentation too
1447
1560
  (`modelTree.setSearchInDescriptions(true)`). The Filters… dialog opens showing the filters on now
1448
1561
  (`getTypeFilter()`, `getSpecializationFilter()`, `getPropertyFilter()`). Its properties start unticked (no property
1449
1562
  filter; `OptInCheckboxTreeSelector`: a value is ticked to keep what has it), and a property with more than 10
@@ -1470,7 +1583,11 @@ leaves it alone). Any `Edge` can say so (`editable`, default true) and add to it
1470
1583
 
1471
1584
  **Selecting several shapes.** In the base editor (so the template editor and the ArchiMate canvas alike): drag a
1472
1585
  box on empty canvas to select the shapes wholly inside it (Ctrl or Cmd adds them to the selection;
1473
- `editor.templatesInside(rect)`, `editor.selectTemplates(ids, { extend })`); Shift- or Ctrl-click adds one;
1586
+ `editor.templatesInside(rect)`, `editor.selectTemplates(ids, { extend })`); Shift- or Ctrl-click adds one, and so
1587
+ does a right-click with Shift or Ctrl, before the menu opens for the whole selection; a plain right-click on a shape
1588
+ outside the selection selects it alone (`editor.selectForMenu(id, { extend })`). The last shape chosen has the
1589
+ handles; every other selected shape is marked by a dashed, tinted outline set off its border
1590
+ (`DiagramEditor.SELECTION_OUTLINE`: `pad`, `stroke`, `strokeWidth`, `dash`, `fill`);
1474
1591
  **Ctrl+A** selects every shape that is drawn (`selectableIds()`). Whatever is selected acts together, each as one
1475
1592
  undo step: dragging any of them moves them all (with their groups and what they hold); the arrow keys nudge
1476
1593
  them a grid step, one pixel without a grid, ten times as far with Shift (`nudgeSelection(dx, dy)`); **Delete**
@@ -1634,6 +1751,28 @@ whose schema changed) gives them afresh with `models.setBases(specs)`; what is o
1634
1751
  (`models.viewBase(i)`) opens a base to look at: a tab of its own copy, named "… (view)", that can be laid out and
1635
1752
  moved about but is never kept (`part.keepOff`).
1636
1753
 
1754
+ **Content: models kept apart from the tools.** Models, data models and user stories by domain live apart from the
1755
+ modellers (modelengine, or any collection laid out like it): a `manifest.json` listing items by domain, each a file in
1756
+ a format a modeller already reads. A `ContentSource` reads one, fetched from its address (`url`: a folder holding
1757
+ `manifest.json`, or the manifest itself) or given whole (`bundle`: `{ manifest, files }`, what modelengine's
1758
+ `dist/modelengine.js` puts on `globalThis.ModelEngineContent` for a page opened off disk; `ContentSource.fromPage()`).
1759
+ Nothing in the tools lists what a collection has: `source.items({ formats })` offers the items a modeller's file
1760
+ formats read, each matched by its file's whole ending (`ContentSource.formatFor(item, formats)`: the one format
1761
+ accepting all after the name's first dot, so `.requirements.json` is not any `.json`), and `source.open(item,
1762
+ formats)` opens one through that format. A `ModelsPart` offers its sources (`ContentSources`: the page's bundle, its
1763
+ spec's `content: [{ label?, url }]`, the host's `contentSources` service, and those added with **Add a source…**,
1764
+ kept in this browser) under File ▸ **Open from** ▸ *source* ▸ *domain* ▸ *item* (`models.contentItems()`); a source
1765
+ not read says why there. An item opens in a tab of its own (`models.openContent(source, item)`) remembering where it
1766
+ came from (`part.contentOrigin`), so its tab menu and Tools offer **Update from *source*…**: the item read again and
1767
+ its changes reviewed as a merge, one undo step, keeping what was changed here (`models.updateFromContent(part)`).
1768
+ `openContent: 'first'` (or an item's id) in the spec opens that item on a first visit, in the empty model's place.
1769
+
1770
+ ```js
1771
+ const source = new ContentSource({ url: 'https://example.org/modelengine/' });
1772
+ const [item] = await source.items({ formats: [new DataModelFileFormat()] }); // what the data modeller reads
1773
+ const { model } = await source.open(item, [new DataModelFileFormat()]);
1774
+ ```
1775
+
1637
1776
  **Kept in this browser, and backed up.** With `keep: true` in its spec, a `ModelsPart` keeps every model it holds
1638
1777
  in this browser, so work outlives the page without saving a file: a moment after each edit (`ModelsPart.KEEP_DELAY`)
1639
1778
  and at once when imported, each in its editor's own file format (`part.keepEntry()`: the file's format, else the
@@ -1840,20 +1979,56 @@ template editor (the testbed) and both ArchiMate editors have them. The **View**
1840
1979
  (`editor.viewMenuItems()`) has **Zoom to fit** (`editor.zoomToFit()`: everything drawn in sight, in the
1841
1980
  drawing's own units; `ViewFit` computes it, as a viewBox for the graph engine or a scale and pan for the
1842
1981
  template editor), **Reset zoom**, **Highlight…** (below), and, when the editor has a `model`, **Check model…**
1843
- (`editor.showModelCheck()`). The checks are the model's own (`model.checker(options)`): a `ViewSetChecker` for
1982
+ (`editor.showModelCheck()`; `showFindings(findings, { title, empty })` shows any findings in its dialog). The checks are the model's own (`model.checker(options)`): a `ViewSetChecker` for
1844
1983
  any view set (empty views; an empty template is not one), and for an ArchiMate model an `ArchimateModelChecker`
1845
1984
  that adds relationships the specification does not allow, likely duplicates (elements of one type whose names
1846
- have the same words, ignoring case, punctuation, word order and filler words such as "of" and "the":
1985
+ have the same words, ignoring case, punctuation, word order, plurals ("Customers" is "Customer", `singular(word)`)
1986
+ and filler words such as "of" and "the":
1847
1987
  `nameKey(name)` and `duplicateGroups(elements)` in `diagrams/duplicates.js`), elements and relationships on no
1848
1988
  view, nested elements with no relationship, and names or documentation with unprintable characters (control
1849
1989
  characters, zero-width spaces; tabs and line breaks are fine in documentation). Each check is a method listed by
1850
1990
  `checks()`, so a subclass changes the set; choosing a finding shows it (`editor.revealFinding(finding)`: its view,
1851
1991
  its shape, or in the ArchiMate tree), and a finding can offer actions beside it (`editor.findingActions(finding)`).
1852
- On the ArchiMate canvas, likely duplicates have **Merge…**: choose the element to keep (each choice says how many
1853
- views and relationships use it) and the others are merged into it, one undo step (`model.mergeCommand(keepId,
1854
- otherIds)`, a `MergeElementsCommand`). The kept element takes their relationships, their boxes on every view, the
1855
- documentation and properties it lacks and a specialization if it has none; a relationship left joining it to
1856
- itself, or the same as another, goes, its lines drawn as the other. The **File** menu (`EditorPart`'s, and the ArchiMate editor's) exports the current view
1992
+ On the ArchiMate canvas, likely duplicates have **Merge…**, and **Find likely duplicates…**, a tool for the whole
1993
+ model, is on the model's tab menu and in **Tools** (a part's `modelMenuItems()`, the editor's for an `EditorPart`;
1994
+ in its **Edit** menu when no `ModelsPart` hosts it). It asks where to look (the whole model, this view, the elements
1995
+ selected: `editor.duplicateScopes()`) and how to compare, then lists the groups found (a `DuplicatesDialog`, one for
1996
+ every modeller): each with a box to tick, its message (choosing it shows it) and **Merge…**; **Merge selected** merges
1997
+ the ticked groups one after another. The list stays open while they are merged, found again after each merge, so what
1998
+ was merged leaves it (`editor.askDuplicates()`, `editor.showDuplicates(options, { within })`, `checker.possibleDuplicates(options, { within })`).
1999
+ Its form is one for every modeller (`duplicateSearchFields({ scope, compare, properties, specializations })`,
2000
+ `duplicateSearchOptions(values)`, `validateDuplicateSearch`). The options are the jArchi dedupe scripts' (`DUPLICATE_OPTIONS`,
2001
+ `duplicateGroups(elements, options)`, `duplicateKey(element, options)`, `wordKey(text, options)`): compare the
2002
+ names, the documentation (an element with none, or only noise, is left out) or a property's value (an application
2003
+ id); leave out noise words (`NOISE_WORDS`: filler, "tbc", "tba" …), numbers and version numbers ("2", "v2",
2004
+ "1.2.3"), one-letter words and words matching a pattern (`^[Aa][0-9A-Fa-f]{5}$` for id codes); take a plural as its
2005
+ word; look across types; leave out a specialization (Decommissioned). **Match** `exact` compares the text as it is,
2006
+ case and spacing aside; `words` (the default) by its words, as above. Any other field can be compared by naming it
2007
+ (`by: 'attributes'`; a list is its items' names together). The model check compares names by their words with the
2008
+ filler words only.
2009
+
2010
+ ```js
2011
+ editor.showDuplicates({ by: 'documentation', noiseWords: NOISE_WORDS, ignoreNumbers: true });
2012
+ editor.showDuplicates({ by: 'property', property: 'AppId', acrossTypes: true });
2013
+ ```
2014
+
2015
+ With two or more elements selected, a shape's menu has **Merge N elements…** for them
2016
+ (`editor.elementIdsOf(boxIds)`: each box's element, once). Merging (`editor.askMerge(ids)`, resolving to the command
2017
+ or null) shows the elements side by side first (a `MergeCompareDialog`, one for every modeller: a column each, a row
2018
+ each for name, type, specialization, documentation, each property, relationships and views, the rows where they
2019
+ differ marked ≠; `editor.mergeComparison(elements)`), and under it asks which element to keep (each choice says how
2020
+ many views and relationships use it) and, where they differ, what it ends with: the documentation (all of it joined,
2021
+ the default, or one element's), the value of each property they have with different values, the specialization
2022
+ (`editor.mergeChoices(elements)`). **OK, merge** merges; Cancel leaves them. Elements that differ only in the views
2023
+ they are on merge into the first without asking. The others are merged into it, one undo step (`model.mergeCommand(keepId,
2024
+ otherIds, { documentation, properties, specialization })`, a `MergeElementsCommand`). The kept element always takes
2025
+ their relationships and their boxes on every view, and the properties it lacks; a relationship left joining it to
2026
+ itself, or the same as another, goes, its lines drawn as the other.
2027
+
2028
+ **View ▸ Remove repeated boxes (N)** takes off the view each box that only repeats another of its element: the same
2029
+ label shown, in the same box (or none), with the same lines drawn (each relationship, or connection, to the same
2030
+ thing at the other end), as one undo step with its lines (`view.repeatedBoxes()`, `editor.removeRepeatedBoxes()`).
2031
+ The first box stays; a box holding others is never taken off. The **File** menu (`EditorPart`'s, and the ArchiMate editor's) exports the current view
1857
2032
  as an image: PNG, SVG or a copy to the clipboard (`editor.exportImage('png' | 'svg' | 'copy')`, without the grid
1858
2033
  or selection; the ArchiMate editor's is off with `features.exportImage: false`). **Generate View For This**, on
1859
2034
  an element's menu in the diagram or the tree, makes a view of the element with everything related to it
@@ -1894,7 +2069,8 @@ changed: `editor.model.duplicateView(id)` opens and shows the copy, deleting a v
1894
2069
 
1895
2070
  **Canvases and templates.** As in Archi, the model tree's **Views** holds every view, canvases (`CanvasView`)
1896
2071
  and sketches included, in the sub-folders the Archi file kept it in (`view.archiFolder`; `viewFolderNodes(views,
1897
- depth)` makes a level of them); templates are apart (`model.diagramViews()`, `model.canvases()`,
2072
+ depth, emptyFolders)` makes a level of them). A sub-folder with no view in it is kept too, as its path
2073
+ (`model.emptyViewFolders`, read from an Archi or Exchange Format file and written back to either); templates are apart (`model.diagramViews()`, `model.canvases()`,
1898
2074
  `model.templates()`). The **Templates** folder holds the model's own templates (canvases
1899
2075
  marked `template`, saved in the `.archimate` file) and a **Library** of templates kept across models: a
1900
2076
  `CanvasTemplateLibrary` (a `TemplateLibrary`, the base any kind of view's library extends: entries, built-ins
@@ -2307,7 +2483,7 @@ for something else in ArchiMate, each as one undo step:
2307
2483
 
2308
2484
  `ArchimatePalette({version, onPick, document})` renders the element types grouped by layer
2309
2485
  (`ArchimatePalette.groups(version)`: the notation's layers, then ArchiMate 4's common behaviour types),
2310
- each tile only its notation glyph in its layer colour, four to a row (`ArchimatePalette.TILE`; the name shows on hover); drag a tile onto the canvas or click it (`onPick`).
2486
+ each tile only its notation glyph in its layer colour, six to a row at 12px (`ArchimatePalette.TILE`; the name shows on hover); drag a tile onto the canvas or click it (`onPick`).
2311
2487
 
2312
2488
  `ArchimateCanvasPart` (workspace bundle, type `archimate-canvas`; `examples/archimate.html`, and the testbed
2313
2489
  `test/archimate-canvas-testbed.html`) is the
@@ -2421,7 +2597,11 @@ model's, then the tool's, then the default (`cascade(view, modelId)`; `getModelO
2421
2597
  what the model level itself sets, `getModelSettings(modelId)` the model's resolved values). Global
2422
2598
  Settings sets the tool's value and the model's (which may inherit the tool's); a view's Properties sets
2423
2599
  the diagram's (which may inherit the model's). "Invalid relationships" (`invalidRelationships`:
2424
- `highlight`, `tooltip`, `off`) is set this way.
2600
+ `highlight`, `tooltip`, `off`) is set this way. Global Settings also sets how the model tree searches and shows, for
2601
+ every model (tool level, `TREE_SETTINGS_PROPERTIES`): the leaves a folder shows before "more" (`treeLeafLimit`, 8)
2602
+ and the characters a search needs (`treeSearchMinChars`, 3). `settingsManager.treeOptions()` gives them as
2603
+ ModelTree's options (`{ leafLimit, minFilterChars }`); the canvas part makes its tree with them and redraws it when
2604
+ they change.
2425
2605
 
2426
2606
  ```js
2427
2607
  settingsManager.saveToolSettings({ invalidRelationships: 'tooltip' }); // every model, unless it says otherwise
@@ -2670,6 +2850,11 @@ diagram). After an item acts, the bar re-renders. Items with live labels (the zo
2670
2850
  | `ToolbarText(text, style)` | Live text |
2671
2851
  | `ToolbarSeparator()` | Divider |
2672
2852
 
2853
+ `PopupMenu.show(x, y, items)` is the one popup or context menu (a toolbar menu's, an editor's, a host's
2854
+ right-click): items `{ label, action, danger?, checked?, disabled? }`, `{ label, items }` for a submenu, or
2855
+ `{ separator: true }`. One is open at a time; it moves back inside the window near an edge, and closes on a
2856
+ choice, a click elsewhere, Escape, or a scroll.
2857
+
2673
2858
  ```javascript
2674
2859
  new MenuBar({ container, editor, items: [
2675
2860
  new ToolbarMenu('File', [{ label: 'Export', action: exportIt }]),
@@ -2677,6 +2862,19 @@ new MenuBar({ container, editor, items: [
2677
2862
  ] }).render();
2678
2863
  ```
2679
2864
 
2865
+ `Dialog` is the one dialog frame, which `FormDialog` (a `<form>`) and any host's own dialog share: a title
2866
+ (drag it to move the dialog), an optional ✕ (`closeButton`), a body that scrolls between the title and the
2867
+ buttons, which stay in view, and Escape or a click outside to close. `buttons` are
2868
+ `[{ label, action(dialog), primary? }]`, `size` is `'tall'` or `'wide'`, and `onClose(applied)` is told once.
2869
+ `open()` shows it over the page; `open(host)` places it in `host` with no backdrop and no ✕, to show a dialog's
2870
+ content as a pane or a tab. A subclass gives its content as `content()` specs; a host fills `dialog.body` after
2871
+ `open()`, and adds to `dialog.titleBar` (a link) or `dialog.footer` (a pager).
2872
+
2873
+ ```javascript
2874
+ const rows = new Dialog({ title: 'orders', closeButton: true, size: 'tall' }).open();
2875
+ rows.body.append(table);
2876
+ ```
2877
+
2680
2878
  `FormDialog` is the one modal form: `fields` of type `text`, `textarea`, `number`, `color`, `select`
2681
2879
  or `checkbox`. A field with `inherit: { text, short? }` may be left unset; it then reads as `undefined` (a select
2682
2880
  offers `text` as its first choice; any other field, a checkbox too, gets a checkbox named `short`, default
@@ -2747,6 +2945,88 @@ to it) / `deleteEdgeCommand(id)`. A subclass whose shapes and lines stand for so
2747
2945
  overrides those factories (ArchimateCanvasEditor does). A template class says whether the editor
2748
2946
  offers it a rotate handle (`static rotatable`, true by default; ArchiMate's templates are not). `pickFile(accept)` resolves with the chosen `File` (or `null`).
2749
2947
 
2948
+ ## Navigation menus
2949
+
2950
+ `NavMenu` is a vertical navigation menu (a sidebar's), written as data: no markup. Each entry is
2951
+ one of three kinds, built from its JSON by `NavMenu.entry`:
2952
+
2953
+ | JSON | Class | Renders |
2954
+ |---|---|---|
2955
+ | `{ label, id?, icon?, href?, action?, selectable?, title?, cls?, visible? }` | `NavItem` | A link (`<a href>`) with `href`, otherwise a button |
2956
+ | `{ group, items, id?, open?, title?, cls?, visible? }` | `NavGroup` | A heading that opens and closes (`<details>`) over its items |
2957
+ | `{ section, title?, cls?, visible? }` | `NavSection` | A heading between entries |
2958
+
2959
+ ```javascript
2960
+ import { NavMenu } from '@peter.prib/renderengine';
2961
+
2962
+ const menu = new NavMenu({
2963
+ label: 'Workbench',
2964
+ context: { role: 'analyst' },
2965
+ open: { devtools: true },
2966
+ items: [
2967
+ { section: 'Notebooks' },
2968
+ { id: 'new-nb', label: '+ New Notebook', action: newNotebook, selectable: false },
2969
+ { group: 'Dev Tools', id: 'devtools', items: [
2970
+ { id: 'datamodel', icon: '◫', label: 'Data Model', href: '#', action: (event) => {
2971
+ event.preventDefault();
2972
+ openTool('datamodel', { beside: event.altKey });
2973
+ } },
2974
+ ] },
2975
+ { id: 'admin', icon: '⚙', label: 'Admin', href: '/admin', visible: (ctx) => ctx.role === 'admin' },
2976
+ ],
2977
+ onToggle: (groupId, open) => save(menu.serialize()),
2978
+ }).render(document.getElementById('nav'));
2979
+ ```
2980
+
2981
+ - **A click** runs the item's `action(event, item, menu)`, then makes the item the active one (unless it
2982
+ has no `id`, or `selectable: false`), then calls `onSelect(item, event)`. A link still navigates unless
2983
+ the action calls `event.preventDefault()`.
2984
+ `href` may be a function of the context, for an address known only later (`setContext({ adminUrl })`). The active item has the class `ws-nav-item-active` and
2985
+ `aria-current="page"`; `setActive(id)` moves it in place (`null`: none).
2986
+ - **`visible`** is a boolean or a function of the menu's `context`. A group none of whose items shows is
2987
+ hidden too. `setContext({ role })` merges into the context and re-renders.
2988
+ - **State:** `serialize()` returns `{ activeId, open: { groupId: boolean } }` (groups with an `id`), and
2989
+ the constructor takes `activeId` and `open` back; `onToggle(groupId, open)` reports a group opened or
2990
+ closed.
2991
+ - `find(id)` returns an entry anywhere in the menu; `all()` lists them depth first.
2992
+ - **Styles** are the workspace's (`ensureStyles`): `ws-nav`, `ws-nav-item`, `ws-nav-icon`, `ws-nav-label`,
2993
+ `ws-nav-group`, `ws-nav-group-summary`, `ws-nav-section`. Colours are inherited, so the menu suits a
2994
+ dark sidebar and a light one; `cls` adds a host's own classes to an entry.
2995
+
2996
+ `NavMenu` imports nothing from the browser at module scope, so it is exported from the node entry
2997
+ (`src/index.js`) as well as the workspace bundle.
2998
+
2999
+ ## Icons
3000
+
3001
+ `IconSet` holds named icons as data, and builds each as an `<svg>` spec for `el()`, so nothing is parsed. An
3002
+ icon is JSON: a `viewBox`, its `paint`, and its shapes as `[tag, attrs]` pairs.
3003
+
3004
+ ```javascript
3005
+ import { ICONS, icon, IconSet } from '@peter.prib/renderengine';
3006
+
3007
+ el(icon('cpu')); // the built-in pool
3008
+ el(ICONS.spec('database', { size: 16, title: 'Catalog' }));
3009
+ const mine = ICONS.with({ // a host's own icons over the built-in ones
3010
+ ring: { viewBox: '0 0 24 24', shapes: [['circle', { cx: 12, cy: 12, r: 10 }]] },
3011
+ });
3012
+ ```
3013
+
3014
+ - **`paint`:** `'stroke'` (the default) outlines the shapes in `currentColor`, so an icon takes its parent's
3015
+ colour. `'fill'` fills them in `currentColor`. `'none'` leaves the colours the shapes carry, as a brand mark does.
3016
+ - **`spec(name, { size = 24, title, cls })`:** returns an `<svg class="re-icon" data-icon="name">` spec. Without
3017
+ a `title` it is `aria-hidden`; with one it is `role="img"` and carries a `<title>`. An unknown name throws.
3018
+ - **Methods:** `define(name, icon)` checks the icon as it is added; `add(defs)`, `has(name)`, `get(name)` and
3019
+ `names()` work on the set; `with(defs)` returns a copy with `defs` added, leaving the original unchanged.
3020
+ - **The built-in icons** (`ICONS`, defined by `ICON_DEFS`): `dashboard`, `zap`, `clock`, `clipboard-list`,
3021
+ `book`, `cpu`, `memory`, `activity`, `database`, and the brand marks `google` and `microsoft`. They are named
3022
+ for the picture, not for where they are used.
3023
+ - **In a `NavMenu`:** an item's `icon` that names an icon in the menu's `icons` (default `ICONS`) is drawn as
3024
+ that icon; any other text, such as an emoji, is shown as it is.
3025
+
3026
+ `el()` builds an `svg` spec, and everything inside it, in the SVG namespace (`SVG_NS`, exported with `el` from
3027
+ the workspace bundle), until a `foreignObject`, whose children are HTML again. A spec's `ns` sets the namespace
3028
+ explicitly.
3029
+
2750
3030
  ## Where to go next
2751
3031
 
2752
3032
  - **`ARCHITECTURE.md`** — module layout, the chart-type catalog mechanics, how to add a new chart type.