@peter.prib/renderengine 0.12.0 → 0.13.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 (31) hide show
  1. package/README.md +10 -4
  2. package/dist/renderengine-workspace.umd.js +1920 -1055
  3. package/dist/renderengine.umd.js +1901 -1052
  4. package/documentation/REFERENCE.md +66 -5
  5. package/package.json +1 -1
  6. package/src/charting/Axis.js +47 -5
  7. package/src/charting/DiagramEditor.js +239 -34
  8. package/src/charting/ModelFileFormat.js +47 -0
  9. package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
  10. package/src/charting/archimate/deleteFromModel.js +57 -0
  11. package/src/charting/archimate/elementMenuItems.js +3 -1
  12. package/src/charting/archimate/heatMaps.js +169 -0
  13. package/src/charting/archimate/index.js +1 -0
  14. package/src/charting/charts/BarChart.js +5 -5
  15. package/src/charting/charts/BarChartBase.js +21 -3
  16. package/src/charting/charts/GroupedBarChart.js +7 -9
  17. package/src/charting/charts/TableChart.js +18 -2
  18. package/src/charting/diagrams/ArchimateModel.js +33 -0
  19. package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
  20. package/src/charting/diagrams/EdgeGeometry.js +16 -0
  21. package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
  22. package/src/charting/pivot/PivotGridChart.js +1 -1
  23. package/src/charting/pivot/PivotView.js +11 -2
  24. package/src/charting/table/TableView.js +10 -1
  25. package/src/charting/table/TableViewChart.js +1 -1
  26. package/src/charting/templates/Template.js +10 -0
  27. package/src/exportImage.js +45 -18
  28. package/src/index.js +17 -4
  29. package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
  30. package/src/workspace/parts/DiagramEditorPart.js +2 -0
  31. package/src/workspace/parts/ModelHosted.js +15 -4
@@ -1057,7 +1057,10 @@ types — see `ARCHITECTURE.md`):
1057
1057
 
1058
1058
  **In the workbench:** `DataModelPart` (type `data-model`, `RenderEngineWorkspace.DataModelPart`) shows one
1059
1059
  model: File ▸ Open…, Save, Save As… (Ctrl+O, Ctrl+S; `part.file`, the `ModelFile` editors use, • while changed,
1060
- asking before the page is left with changes unsaved), and hosted by a `ModelsPart` (`editorType: 'data-model'`)
1060
+ asking before the page is left with changes unsaved), File ▸ **Import from pasted text (.datamodel.json)…** (a model from
1061
+ pasted text rather than a file: a file's contents, or an assistant's answer or a page with the JSON in a ``` block
1062
+ or between other words, found by `findPayload(format, text)`; `askPasteImport(format, { onImport })`; every
1063
+ `ModelHosted` part offers it for its text formats, `part.pasteItems()`; it opens as Open… does), and hosted by a `ModelsPart` (`editorType: 'data-model'`)
1061
1064
  New ▸ (New model, From base: …), Duplicate model, Close model and This browser ▸ (with `keep`: kept in this browser
1062
1065
  as it changes, reopened next time, backed up to a folder chosen once, as the modeller's models are); Edit ▸ Undo
1063
1066
  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
@@ -1081,6 +1084,13 @@ this to `line`/`area`/`stackedBar`/etc. is the same three-line change: call
1081
1084
  `this._drawYAxisTicks(x, y, width, height, rawMax)` before drawing marks, and scale against its
1082
1085
  return value instead of the raw max — not done yet for the rest, tracked as a follow-up.
1083
1086
 
1087
+ **Negative values.** `bar` and `groupedBar` (and so `pivotChart`, which charts through the grouped
1088
+ bar) take in the smallest value as well as the largest: when one is negative the axis goes below zero
1089
+ on one common step (`Axis.niceRange(min, max)`, drawn by `Axis.drawYRange`), a zero line is drawn
1090
+ across the plot where it crosses, and a negative bar hangs down from it, its value written below it.
1091
+ Money signed both ways (income positive, spending negative) charts as it is, and a total over both is
1092
+ the net. Without a negative value nothing changes: the axis starts at 0 at the bottom.
1093
+
1084
1094
  ### Choosing a chart for result rows — `ChartChooser`
1085
1095
 
1086
1096
  `new ChartChooser().choose(rows, columns?, { complete? })` says which chart types fit a set of result rows (a query
@@ -1138,6 +1148,7 @@ const view = new RenderEngine(container, { rendererType: 'svg' }).resultView(row
1138
1148
  pageSize: 50,
1139
1149
  menus: { row: ({ row }) => [{ label: 'Open order', action: () => open(row.id) }] },
1140
1150
  highlight: (row) => row.id === selectedId, // rows drawn highlighted: the host's selection
1151
+ format: { Revenue: new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }) },
1141
1152
  });
1142
1153
  ```
1143
1154
 
@@ -1159,6 +1170,12 @@ or `chartHeight`, 360); pass `fitHeight: false` to size it yourself. A host adds
1159
1170
  any menu with `menus: { cell, row, column, table }`, each `(context) → PopupMenu items`, shown after
1160
1171
  a separator.
1161
1172
 
1173
+ **Number format.** `format` says how numbers show: a function (number → text) or an
1174
+ `Intl.NumberFormat` for every number, or an object naming either per column, where a column it does
1175
+ not name stays plain (116028; a fraction to at most 4 decimals). Only the cells' text changes: sorting
1176
+ and filtering use the values, and every copy (the table, a column, a row, a value) keeps the raw
1177
+ numbers, so a pasted copy still adds up. `pivotView` takes the same `format`, per measure.
1178
+
1162
1179
  Interaction needs the SVG renderer (its shapes take clicks); on canvas the same table is drawn
1163
1180
  without it. `TableView` (`src/charting/table/TableView.js`) holds all of the state and builds every
1164
1181
  menu as data, so it can be driven and tested without a DOM; `TableViewChart` draws it.
@@ -1211,6 +1228,10 @@ grid, as `resultView`'s does. `PivotView` (`src/charting/pivot/`) holds all the
1211
1228
  menus as data, so it can be tested without a DOM; `PivotGridChart` draws it, with the interactive
1212
1229
  table's click handling (SVG for clicks; canvas draws the grid without them).
1213
1230
 
1231
+ `format` (as `resultView`'s, per measure: `{ Revenue: new Intl.NumberFormat(…) }`, or one for every
1232
+ measure) writes the cells, the totals and the traffic lights' cut-offs ("Revenue ≥ $3,000.00"); the
1233
+ grid copy keeps the raw numbers.
1234
+
1214
1235
  #### Saved views
1215
1236
 
1216
1237
  A saved view is plain data: the layout (`view.layout()`) and how the grid shows it
@@ -2098,8 +2119,32 @@ colour by, then a colour for each of its values (a soft palette by default, `Dia
2098
2119
  colour" leaves a value's shapes as they are), and whether to add a legend. Each shape takes its value's colour as
2099
2120
  its own fill, so the colours are kept like any other (an Archi file included), and one undo puts them back. The
2100
2121
  template editor colours by the kind of shape; the ArchiMate canvas by **Layer**, **Type**, **Specialization** or any
2101
- **property** its elements have (notes and pictures have no value and stay as they are). A legend is a title and a
2102
- box per value in its colour, in a row under the drawing (notes; on a canvas, stickies). **Add legend of the kinds
2122
+ **property** its elements have (notes and pictures have no value and stay as they are), and two more:
2123
+
2124
+ - **Change (create, update, delete)**, offered when the model has a deliverable or work package: each element related
2125
+ to one (either way round) takes the change the relationship's name says, or, when the relationship has no name, the
2126
+ deliverable's or work package's name. Synonyms count, in any form: delete, remove, retire, decommission, drop,
2127
+ sunset, phase out, shut down, …; create, add, new, introduce, build, implement, set up, launch, …; update, change,
2128
+ modify, upgrade, enhance, migrate, replace, … (`CHANGE_WORDS`, whole words only). Where an element is given more
2129
+ than one, the one with most consequence wins: delete, then create, then update. The colours offered are
2130
+ **Create** yellow #FDD835, **Update** blue #1E88E5 and **Delete** magenta #D81B60 (`CHANGE_COLOURS`), each still
2131
+ changeable (`changeAction(text)`, `elementChange(model, elementId)`). The lines are coloured too: each relationship
2132
+ to a deliverable or work package is drawn in the colour of the change it says itself (`relationshipChange(model,
2133
+ rel)`), so an element changed two ways shows both on its lines; other lines keep their colour. Lines only drawn
2134
+ (derived ones) are left as they are (`editor.colourEdgeValue(edge, key)`, which a subclass overrides to colour
2135
+ lines by a key).
2136
+ - **A formula over properties…**, offered when elements have properties: a number from each element's numeric
2137
+ properties, in the formula language `colorCol` uses (`kernelExpr.js`: arithmetic, comparisons, `a ? b : c`,
2138
+ maths functions), each property by its name with anything but letters, digits and _ made _ ("Risk score" is
2139
+ `Risk_score`), e.g. `Cost * Risk_score` or `Cost > 10000 ? 1 : 0`. A formula naming no property is refused
2140
+ with what the names are. An element without a number for a property the formula uses is left as it is. Up to
2141
+ eight different results are each a value; more are put in five equal bands, smallest first (a band with nothing in
2142
+ it left out), and the colours offered run light to dark (`expressionValues`, `resultBands`,
2143
+ `sequentialColours`; the key is `expression:<formula>`).
2144
+
2145
+ A legend is a box under the
2146
+ drawing, titled at its top-left, holding a box per value in its colour (in rows of up to four,
2147
+ `DiagramEditor.legendLayout`), all grouped so they move as one (notes; on a canvas, stickies). **Add legend of the kinds
2103
2148
  shown** adds one for the kinds of shape (ArchiMate: the element types) in the colour of each's first shape. A
2104
2149
  locked shape keeps its colour. `colourKeys()`, `colourValue(template, key)`, `colourValues(key)`,
2105
2150
  `colourByCommand(key, colours, { legend })`, `kindLegendEntries()` and `legendCommand(entries, title)`, which a
@@ -2224,7 +2269,23 @@ label shown, in the same box (or none), with the same lines drawn (each relation
2224
2269
  thing at the other end), as one undo step with its lines (`view.repeatedBoxes()`, `editor.removeRepeatedBoxes()`).
2225
2270
  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
2226
2271
  as an image: PNG, SVG or a copy to the clipboard (`editor.exportImage('png' | 'svg' | 'copy')`, without the grid
2227
- or selection; the ArchiMate editor's is off with `features.exportImage: false`). **Generate View For This**, on
2272
+ or selection; the ArchiMate editor's is off with `features.exportImage: false`), and so does a right-click on empty
2273
+ canvas (**Export image**, `editor.canvasContextMenu(at)`). By default a picture takes in **the whole drawing**,
2274
+ wherever the view is scrolled: every shape and line, line labels and arrowheads included, with 20px clear all round
2275
+ so nothing touches the edge (`editor.drawingBounds({ margin })`), at **100%**. **Export image…** asks the format,
2276
+ the area (the whole drawing, or only what is in sight) and the scale in percent, and the quick items use its last
2277
+ choice (`editor.exportOptions`, `setExportOptions({ area, scale })`; `exportImage(format, { area, scale })`).
2278
+ **⚙ Settings ▸ Export area** shows where the whole drawing's picture ends as a faint dashed box, never itself
2279
+ exported (`editor.showExportArea`). An SVG cropped to a viewBox keeps a white background over all of it.
2280
+
2281
+ **🗑 Delete from model…**, on an element's menu in the tree and on its box's menu in the diagram (the elements
2282
+ selected, when it is one of them), takes it out of the model rather than off one view, as Archi's "Delete from
2283
+ Model". First a dialog says where it is used: each view it is on (with how many boxes), and each relationship that
2284
+ goes too (those ending on it, and those ending on such a relationship); **Delete from model** goes ahead. Every
2285
+ box and line of them goes from every view, with any view's own connection to one of its boxes, as one undo step
2286
+ that puts it all back, ids and all (`model.usage(elementIds)`, `model.deleteFromModelCommand(elementIds)`,
2287
+ `editor.askDeleteFromModel(ids)` / `deleteFromModel(ids)`, `deleteFromModelMessage(model, ids)`;
2288
+ `elementMenuItems` offers it when the host gives `deleteFromModel`). **Generate View For This**, on
2228
2289
  an element's menu in the diagram or the tree, makes a view of the element with everything related to it
2229
2290
  around it and every relationship between them (`model.generateView(elementIds, { name, related })`, Archi's
2230
2291
  "Generate View For").
@@ -2587,7 +2648,7 @@ single list of cascading properties:
2587
2648
  | Key | Values (default first) |
2588
2649
  |---|---|
2589
2650
  | `routing` | `straight`, `orthogonal`, `curved`, `polyline` (bends around other shapes) |
2590
- | `attach` | `floating` (outline, aimed at the other end), `sides` (middle of the facing side) |
2651
+ | `attach` | `shortest` (the default, as Archi draws: where two shapes face each other across a gap with their widths or heights overlapping, a straight line straight across the middle of the overlap, outline to outline; otherwise as `floating`), `floating` (outline, aimed at the other end), `sides` (middle of the facing side). `shortest` leaves a line with bends, a pinned end, routing other than straight, or a rotated shape as `floating` draws it |
2591
2652
  | `lineStyle` | `solid`, `dashed`, `dotted` (same dash patterns as `GraphDiagram`, via `LINE_STYLE_DASH`) |
2592
2653
  | `strokeColor`, `strokeWidth` | `#666666`, `2` |
2593
2654
  | `startMarker`, `endMarker` | `none`, or an `EdgeMarker` key |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peter.prib/renderengine",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "ArchiMate modeller, diagram editor and chart library for the browser, with no runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -27,6 +27,30 @@ export class Axis {
27
27
  return ticks;
28
28
  }
29
29
 
30
+ /**
31
+ * Ticks over a range that may go below zero: one common "nice" step, from at or below `minVal`
32
+ * to at or above `maxVal`, always taking in 0 so bars have a baseline. Without a negative it is
33
+ * `niceTicks(maxVal)`.
34
+ * @param {number} minVal
35
+ * @param {number} maxVal
36
+ * @param {number} [count=4] desired number of intervals
37
+ * @returns {number[]}
38
+ */
39
+ static niceRange(minVal, maxVal, count = 4) {
40
+ const lo = Math.min(0, minVal || 0);
41
+ const hi = Math.max(0, maxVal || 0);
42
+ if (lo === 0) return Axis.niceTicks(hi, count);
43
+ const raw = (hi - lo) / count;
44
+ const mag = Math.pow(10, Math.floor(Math.log10(raw)));
45
+ const frac = raw / mag;
46
+ const step = frac < 1.5 ? mag : frac < 3.5 ? 2 * mag : frac < 7.5 ? 5 * mag : 10 * mag;
47
+ const first = Math.floor(lo / step + 1e-9);
48
+ const last = Math.ceil(hi / step - 1e-9);
49
+ const ticks = [];
50
+ for (let i = first; i <= last; i++) ticks.push(parseFloat((i * step).toFixed(10)));
51
+ return ticks;
52
+ }
53
+
30
54
  /**
31
55
  * How often a category label fits along an axis `width` wide: 1 (every
32
56
  * label), 2 (every other), … — so a long series (a minute of samples, a
@@ -71,18 +95,36 @@ export class Axis {
71
95
  * @returns {number} the nice domain max the ticks actually span
72
96
  */
73
97
  drawYAxis(renderer, box, rawMax) {
74
- const ticks = Axis.niceTicks(rawMax);
75
- const niceMax = ticks[ticks.length - 1] || 1;
98
+ return this.drawYRange(renderer, box, 0, rawMax).max;
99
+ }
100
+
101
+ /**
102
+ * Draw a y-axis over a range that may go below zero (`niceRange`): ticks, labels, gridlines,
103
+ * and, when the range does go below zero, a zero line across the plot where it crosses.
104
+ *
105
+ * @param {*} renderer a renderer implementing .path() and .text()
106
+ * @param {{x:number, y:number, width:number, height:number}} box plot-area box
107
+ * @param {number} rawMin the raw data minimum (0 or more: the axis starts at 0)
108
+ * @param {number} rawMax the raw data maximum
109
+ * @returns {{min:number, max:number}} the nice domain the ticks actually span
110
+ */
111
+ drawYRange(renderer, box, rawMin, rawMax) {
112
+ const ticks = Axis.niceRange(rawMin, rawMax);
113
+ const min = ticks[0];
114
+ const max = ticks.length > 1 ? ticks[ticks.length - 1] : 1;
115
+ const yOf = (v) => box.y + box.height - ((v - min) / (max - min)) * box.height;
76
116
  for (const t of ticks) {
77
- const ty = box.y + box.height - (t / niceMax) * box.height;
117
+ const ty = yOf(t);
78
118
  renderer.path(`M${box.x - 5},${ty} L${box.x},${ty}`, 'none', { color: '#999', width: 1 });
79
- if (t > 0) {
119
+ if (t !== 0) {
80
120
  renderer.path(`M${box.x},${ty} L${box.x + box.width},${ty}`, 'none', { color: '#eee', width: 1 });
121
+ } else if (min < 0) {
122
+ renderer.path(`M${box.x},${ty} L${box.x + box.width},${ty}`, 'none', { color: '#666', width: 1, zeroLine: true });
81
123
  }
82
124
  renderer.text(Axis.formatValue(t), box.x - 10, ty, {
83
125
  textAnchor: 'end', verticalAlign: 'middle', fontSize: '10px', fill: '#666',
84
126
  });
85
127
  }
86
- return niceMax;
128
+ return { min, max };
87
129
  }
88
130
  }
@@ -30,7 +30,7 @@ const SHAPE_FIELD_KEYS = Object.freeze(['name', 'x', 'y', 'zIndex', 'fillColor',
30
30
  const WARNING_COLOR = '#e74c3c';
31
31
  const WARNING_DASH = '4 3';
32
32
  import { EMPHASIS_OPACITY, strongerEmphasis } from './emphasis/EmphasisRule.js';
33
- import { downloadPng, downloadSvg, copyImage, prepareSvgMarkup } from '../exportImage.js';
33
+ import { downloadPng, downloadSvg, copyImage, prepareSvgMarkup, svgMarkupToPngBlob, copyPngBlob, downloadSvgMarkup, downloadBlob, imageFileName } from '../exportImage.js';
34
34
  import { el } from '../workspace/dom.js';
35
35
  import { applyViewDecorations } from './viewDecorations.js';
36
36
  import { ToolbarButton, ToolbarMenu, ToolbarText } from './toolbar/index.js';
@@ -59,6 +59,15 @@ import {
59
59
  * toolbar container) to itself.
60
60
  */
61
61
  export class DiagramEditor {
62
+ /** An exported picture's defaults: the whole drawing, at 100%. */
63
+ static EXPORT_DEFAULTS = Object.freeze({ area: 'drawing', scale: 1 });
64
+
65
+ /** Space kept clear round an exported drawing, so nothing touches the picture's edge. */
66
+ static EXPORT_MARGIN = 20;
67
+
68
+ /** How far an arrowhead (or other line-end marker) reaches past its line's end. */
69
+ static MARKER_REACH = 10;
70
+
62
71
  /** How a selected shape (besides the one with handles) is marked: an outline `pad` outside it, tinted. */
63
72
  static SELECTION_OUTLINE = Object.freeze({ pad: 4, stroke: '#0066cc', strokeWidth: 2, dash: '5,3', fill: 'rgba(0, 102, 204, 0.08)' });
64
73
 
@@ -122,6 +131,10 @@ export class DiagramEditor {
122
131
  /** Off unless asked for (the `showGrid` option, or ⚙ Settings → Grid), as the settings' default says. */
123
132
  this.showGrid = options.showGrid ?? false;
124
133
  this.gridSize = 20;
134
+ /** A faint box where an exported picture's edges fall (⚙ Settings → Export area); never exported itself. */
135
+ this.showExportArea = options.showExportArea ?? false;
136
+ /** What an exported picture takes in, and at what size (Export image… remembers the last choice). */
137
+ this.exportOptions = { ...DiagramEditor.EXPORT_DEFAULTS };
125
138
 
126
139
  // Zoom and pan
127
140
  this.zoomLevel = 1.0;
@@ -429,6 +442,17 @@ export class DiagramEditor {
429
442
  return [];
430
443
  }
431
444
 
445
+ /**
446
+ * The whole menu a right-click on empty canvas at `at` opens: paste here,
447
+ * canvasMenuItems, then Export image (imageMenuItems), each set apart.
448
+ */
449
+ canvasContextMenu(at) {
450
+ const paste = this.canClipboardPaste() ? [{ label: '📋 Paste here', action: () => this.paste({ at }) }] : [];
451
+ const image = this.imageMenuItems();
452
+ const sections = [paste, this.canvasMenuItems(at), image.length ? [{ label: 'Export image', items: image }] : []].filter(s => s.length);
453
+ return sections.flatMap((s, i) => (i ? [{ separator: true }, ...s] : s));
454
+ }
455
+
432
456
  /**
433
457
  * The menu for empty canvas at `at` (drawing coordinates); none here, a
434
458
  * subclass offers its own (place a template, ...).
@@ -670,6 +694,7 @@ export class DiagramEditor {
670
694
 
671
695
  // Render edges
672
696
  this._renderEdges(group);
697
+ if (this.showExportArea && container.tagName === 'svg') this._renderExportArea(group);
673
698
 
674
699
  // The selection's groups and other selected shapes, then the primary shape's handles.
675
700
  this._renderArrangement(group);
@@ -768,9 +793,13 @@ export class DiagramEditor {
768
793
  ];
769
794
  }
770
795
 
771
- /** Export the current view as a picture: PNG, SVG, or a copy to the clipboard. */
796
+ /**
797
+ * Export the current view as a picture: PNG, SVG, or a copy to the clipboard,
798
+ * as Export image… last chose (the whole drawing at 100% to begin with).
799
+ */
772
800
  imageMenuItems() {
773
801
  return [
802
+ { label: 'Export image…', action: () => this.askExportImage() },
774
803
  { label: 'Export image (PNG)', action: () => this.exportImage('png') },
775
804
  { label: 'Export image (SVG)', action: () => this.exportImage('svg') },
776
805
  { label: 'Copy image', action: () => this.exportImage('copy') },
@@ -800,17 +829,113 @@ export class DiagramEditor {
800
829
  * The current view as a picture: 'png' or 'svg' downloads it, 'copy' puts a
801
830
  * PNG on the clipboard (or downloads it where the browser cannot). The grid,
802
831
  * the selection and its handles are left out.
832
+ *
833
+ * `area` 'drawing' (the default) takes in everything drawn, wherever the view
834
+ * is scrolled to: every shape and line, labels and arrowheads included, with
835
+ * a margin clear of the edge (drawingBounds). 'view' is what is in sight.
836
+ * `scale` sizes the picture: 1 is 100%.
803
837
  * @param {'png'|'svg'|'copy'} format
838
+ * @param {{area?: 'drawing'|'view', scale?: number}} [options] - Export image…'s last choice by default
804
839
  */
805
- exportImage(format = 'png') {
840
+ exportImage(format = 'png', { area = this.exportOptions.area, scale = this.exportOptions.scale } = {}) {
806
841
  const host = this.drawingHost();
807
842
  if (!host) return null;
808
843
  const name = this.imageName();
809
- return this._withPlainDrawing(() => {
810
- if (format === 'svg') return downloadSvg(host, { name });
811
- if (format === 'copy') return copyImage(host, { name });
812
- return downloadPng(host, { name });
844
+ if (area === 'view' || host.tagName?.toLowerCase() !== 'svg') {
845
+ return this._withPlainDrawing(() => {
846
+ if (format === 'svg') return downloadSvg(host, { name, scale });
847
+ if (format === 'copy') return copyImage(host, { name, scale });
848
+ return downloadPng(host, { name, scale });
849
+ });
850
+ }
851
+ const viewBox = this.drawingBounds();
852
+ if (!viewBox) return null;
853
+ const svg = this._withPlainDrawing(() => this._croppedSvg(host, viewBox, { scale }));
854
+ if (format === 'svg') return downloadSvgMarkup(svg, { name });
855
+ const png = svgMarkupToPngBlob(svg, { width: viewBox.width, height: viewBox.height, scale });
856
+ if (format === 'copy') return png.then(blob => copyPngBlob(blob, { name }));
857
+ return png.then(blob => downloadBlob(blob, imageFileName(name, 'png')));
858
+ }
859
+
860
+ /** Ask what to export: the format, the whole drawing or what is in sight, and the scale (%). */
861
+ askExportImage() {
862
+ const { area, scale } = this.exportOptions;
863
+ new FormDialog({
864
+ title: 'Export image',
865
+ description: 'The whole drawing takes in every shape and line, with a margin; the view, only what is in sight.',
866
+ fields: [
867
+ { key: 'format', label: 'Format', type: 'select', value: 'png', choices: [['png', 'PNG'], ['svg', 'SVG'], ['copy', 'Copy to the clipboard (PNG)']] },
868
+ { key: 'area', label: 'Take in', type: 'select', value: area, choices: [['drawing', 'The whole drawing'], ['view', 'What is in sight']] },
869
+ { key: 'scale', label: 'Scale (%)', type: 'number', value: Math.round(scale * 100), min: 10, max: 800, step: 10 },
870
+ ],
871
+ submitLabel: 'Export',
872
+ document: this.document ?? globalThis.document,
873
+ onApply: (values) => this.exportImage(values.format, this.setExportOptions(values)),
874
+ }).open();
875
+ }
876
+
877
+ /**
878
+ * Remember an export's area and scale (the scale in %), for the next one.
879
+ * A scale that is not a positive number keeps the last. Returns the options.
880
+ */
881
+ setExportOptions({ area, scale } = {}) {
882
+ if (area === 'drawing' || area === 'view') this.exportOptions.area = area;
883
+ const pct = Number(scale);
884
+ if (Number.isFinite(pct) && pct > 0) this.exportOptions.scale = pct / 100;
885
+ return { ...this.exportOptions };
886
+ }
887
+
888
+ /**
889
+ * Where an exported picture of the whole drawing ends (in drawing units):
890
+ * every shape and line shown, with line labels and arrowheads, and
891
+ * `margin` clear all round, so nothing touches the picture's edge. Null
892
+ * when nothing is drawn.
893
+ * @returns {{x, y, width, height}|null}
894
+ */
895
+ drawingBounds({ margin = DiagramEditor.EXPORT_MARGIN } = {}) {
896
+ const boxes = [...this.templates.values()]
897
+ .filter(t => t.visible !== false && this.templateEmphasis(t) !== 'hidden')
898
+ .map(t => ({ left: t.left, top: t.top, right: t.right, bottom: t.bottom }));
899
+ const cascade = this.edgeStyleCascade();
900
+ for (const edge of this.getAllEdges()) {
901
+ if (!edge.visible || this.edgeEmphasis(edge) === 'hidden') continue;
902
+ const { style } = cascade.resolve(edge);
903
+ const path = this._edgePath(edge, style);
904
+ if (!path) continue;
905
+ // Every point the path names (a curve's control points bound it); half the stroke and an arrowhead past them.
906
+ const numbers = path.d.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)?.map(Number) ?? [];
907
+ const reach = (Number(style.strokeWidth) || 1) / 2 + DiagramEditor.MARKER_REACH;
908
+ for (let i = 0; i + 1 < numbers.length; i += 2) {
909
+ boxes.push({ left: numbers[i] - reach, top: numbers[i + 1] - reach, right: numbers[i] + reach, bottom: numbers[i + 1] + reach });
910
+ }
911
+ if (edge.label) {
912
+ // As _renderEdges writes it: centred over the middle, 12px text (about 7px a character).
913
+ const cx = (path.x1 + path.x2) / 2, base = (path.y1 + path.y2) / 2 - 8;
914
+ const half = String(edge.label).length * 7 / 2;
915
+ boxes.push({ left: cx - half, top: base - 12, right: cx + half, bottom: base + 4 });
916
+ }
917
+ }
918
+ const bounds = ViewFit.union(boxes);
919
+ if (!bounds) return null;
920
+ return { x: bounds.x - margin, y: bounds.y - margin, width: bounds.width + 2 * margin, height: bounds.height + 2 * margin };
921
+ }
922
+
923
+ /** The faint box where an exported picture of the whole drawing ends (drawingBounds). @protected */
924
+ _renderExportArea(group) {
925
+ const area = this.drawingBounds();
926
+ if (!area) return;
927
+ const rect = this._svg('rect', {
928
+ id: 'export-area', x: area.x, y: area.y, width: area.width, height: area.height,
929
+ fill: 'rgba(43, 95, 217, 0.03)', stroke: 'rgba(43, 95, 217, 0.25)', 'stroke-dasharray': '6 4', 'stroke-width': 1,
930
+ 'pointer-events': 'none',
813
931
  });
932
+ group.insertBefore(rect, group.firstChild);
933
+ }
934
+
935
+ /** Show or hide the export area. */
936
+ setShowExportArea(shown) {
937
+ this.showExportArea = !!shown;
938
+ this.render();
814
939
  }
815
940
 
816
941
  /**
@@ -819,8 +944,8 @@ export class DiagramEditor {
819
944
  * @protected
820
945
  */
821
946
  _withPlainDrawing(fn) {
822
- const saved = { showGrid: this.showGrid, selectedTemplateId: this.selectedTemplateId, selectedIds: new Set(this.selectedIds), selectedEdgeId: this.selectedEdgeId };
823
- Object.assign(this, { showGrid: false, selectedTemplateId: null, selectedIds: new Set(), selectedEdgeId: null });
947
+ const saved = { showGrid: this.showGrid, showExportArea: this.showExportArea, selectedTemplateId: this.selectedTemplateId, selectedIds: new Set(this.selectedIds), selectedEdgeId: this.selectedEdgeId };
948
+ Object.assign(this, { showGrid: false, showExportArea: false, selectedTemplateId: null, selectedIds: new Set(), selectedEdgeId: null });
824
949
  this.render();
825
950
  try {
826
951
  return fn();
@@ -843,13 +968,7 @@ export class DiagramEditor {
843
968
  if (host?.tagName?.toLowerCase() !== 'svg' || !bounds) return null;
844
969
  const viewBox = { x: bounds.x - margin, y: bounds.y - margin, width: bounds.width + 2 * margin, height: bounds.height + 2 * margin };
845
970
  return this._withPlainDrawing(() => {
846
- const copy = host.cloneNode(true);
847
- copy.querySelector('#grid-layer')?.remove();
848
- copy.querySelector('#elements')?.removeAttribute('transform');
849
- copy.removeAttribute('style');
850
- copy.removeAttribute('id');
851
- copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`);
852
- const svg = prepareSvgMarkup(new (this.document?.defaultView ?? globalThis).XMLSerializer().serializeToString(copy), { width: viewBox.width, height: viewBox.height, background });
971
+ const svg = this._croppedSvg(host, viewBox, { background });
853
972
  const areas = [...this.templates.values()]
854
973
  .filter(t => t.visible !== false && this.templateEmphasis(t) !== 'hidden')
855
974
  .sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0))
@@ -865,6 +984,22 @@ export class DiagramEditor {
865
984
  }
866
985
 
867
986
  /** The drawing's extent in drawing coordinates (ViewFit.union of the visible shapes), or null. */
987
+ /**
988
+ * The drawing as standalone SVG markup cut to `viewBox` (drawing units),
989
+ * unpanned and unzoomed, sized at `scale`. Call it inside _withPlainDrawing.
990
+ * @protected
991
+ */
992
+ _croppedSvg(host, viewBox, { background = '#ffffff', scale = 1 } = {}) {
993
+ const copy = host.cloneNode(true);
994
+ copy.querySelector('#grid-layer')?.remove();
995
+ copy.querySelector('#elements')?.removeAttribute('transform');
996
+ copy.removeAttribute('style');
997
+ copy.removeAttribute('id');
998
+ copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`);
999
+ const markup = new (this.document?.defaultView ?? globalThis).XMLSerializer().serializeToString(copy);
1000
+ return prepareSvgMarkup(markup, { width: viewBox.width * scale, height: viewBox.height * scale, background });
1001
+ }
1002
+
868
1003
  contentBounds() {
869
1004
  return ViewFit.union([...this.templates.values()].filter(t => t.visible !== false)
870
1005
  .map(t => ({ left: t.left, top: t.top, right: t.right, bottom: t.bottom })));
@@ -968,6 +1103,7 @@ export class DiagramEditor {
968
1103
  return [
969
1104
  { label: `Grid: ${this.showGrid ? 'on' : 'off'}`, action: () => { this.showGrid = !this.showGrid; this.render(); } },
970
1105
  { label: `Grid size: ${this.gridSize}px…`, action: () => this.editGridSize() },
1106
+ { label: `Export area: ${this.showExportArea ? 'shown' : 'hidden'}`, action: () => this.setShowExportArea(!this.showExportArea) },
971
1107
  ...(this.edgeStyleLevels.length ? [{ separator: true }] : []),
972
1108
  ...this.edgeStyleLevels.map(level => ({
973
1109
  label: `Line style: ${level.label} defaults…`,
@@ -1328,13 +1464,17 @@ export class DiagramEditor {
1328
1464
  /**
1329
1465
  * Geometry of an edge between its templates' outlines, or null if an end is
1330
1466
  * missing. `style.attach === 'sides'` attaches each unpinned end at the middle
1331
- * of the side facing the other end (or the nearest waypoint); 'polyline'
1467
+ * of the side facing the other end (or the nearest waypoint); 'shortest' (the
1468
+ * default, as Archi draws) runs a straight line straight across where the
1469
+ * shapes overlap, else aims at the other end ('floating'); 'polyline'
1332
1470
  * routing bends around the other shapes.
1333
1471
  */
1334
1472
  _edgePath(edge, style = this.resolveEdgeStyle(edge)) {
1335
1473
  const source = this.getTemplate(edge.sourceId);
1336
1474
  const target = this.getTemplate(edge.targetId);
1337
1475
  if (!source || !target) return null;
1476
+ const across = style.attach === 'shortest' ? this._acrossPath(edge, source, target, style) : null;
1477
+ if (across) return across;
1338
1478
  const box = (t) => ({ w: t.width, h: t.height });
1339
1479
  let sourceSide = edge.sourceSide || 'auto';
1340
1480
  let targetSide = edge.targetSide || 'auto';
@@ -1354,6 +1494,25 @@ export class DiagramEditor {
1354
1494
  );
1355
1495
  }
1356
1496
 
1497
+ /**
1498
+ * The shortest straight line between two shapes that face each other across
1499
+ * a gap (EdgeGeometry.acrossOverlap): straight across the middle of their
1500
+ * overlap, from outline to outline. Null where that does not apply: a line
1501
+ * with bends, a pinned end, other routing, a rotated shape, or no overlap.
1502
+ * @protected
1503
+ */
1504
+ _acrossPath(edge, source, target, style) {
1505
+ if (edge.waypoints.length || (edge.sourceSide || 'auto') !== 'auto' || (edge.targetSide || 'auto') !== 'auto') return null;
1506
+ if ((style.routing ?? 'straight') !== 'straight' || source.rotation || target.rotation) return null;
1507
+ const across = EdgeGeometry.acrossOverlap(source, target);
1508
+ if (!across) return null;
1509
+ const step = across.forward ? 1 : -1;
1510
+ const [a, b] = across.axis === 'v'
1511
+ ? [source.exitPoint(across.at, source.y, 0, step), target.exitPoint(across.at, target.y, 0, -step)]
1512
+ : [source.exitPoint(source.x, across.at, step, 0), target.exitPoint(target.x, across.at, -step, 0)];
1513
+ return { x1: a.x, y1: a.y, x2: b.x, y2: b.y, d: `M${a.x},${a.y} L${b.x},${b.y}` };
1514
+ }
1515
+
1357
1516
  /** Anchors and waypoints in order: the polyline a waypointed edge follows. */
1358
1517
  _edgeVertices(edge, edgePath = this._edgePath(edge)) {
1359
1518
  return [{ x: edgePath.x1, y: edgePath.y1 }, ...edge.waypoints, { x: edgePath.x2, y: edgePath.y2 }];
@@ -1848,12 +2007,9 @@ export class DiagramEditor {
1848
2007
  e.preventDefault();
1849
2008
  this._startMarquee(e, container);
1850
2009
  });
1851
- // Empty canvas: what canvasMenuItems offers there (a shape's own menu stops the event first).
2010
+ // Empty canvas: canvasContextMenu there (a shape's own menu stops the event first).
1852
2011
  container.addEventListener('contextmenu', (e) => {
1853
- const at = this._clientToDiagram(e.clientX, e.clientY);
1854
- const paste = this.canClipboardPaste() ? [{ label: '📋 Paste here', action: () => this.paste({ at }) }] : [];
1855
- const own = this.canvasMenuItems(at);
1856
- const items = [...paste, ...(paste.length && own.length ? [{ separator: true }] : []), ...own];
2012
+ const items = this.canvasContextMenu(this._clientToDiagram(e.clientX, e.clientY));
1857
2013
  if (!items.length) return;
1858
2014
  e.preventDefault();
1859
2015
  this._showMenu(e.clientX, e.clientY, items);
@@ -2308,11 +2464,27 @@ export class DiagramEditor {
2308
2464
  return [['kind', 'Kind of shape']];
2309
2465
  }
2310
2466
 
2467
+ /** What a colour key is called (colourKeys' label; a key not listed there, itself). */
2468
+ colourKeyLabel(key) {
2469
+ return this.colourKeys().find(([k]) => k === key)?.[1] ?? key;
2470
+ }
2471
+
2472
+ /** The colours offered for a key's values, in order: the palette's, round again when there are more. */
2473
+ colourDefaults(key, values) {
2474
+ const palette = this.constructor.COLOUR_PALETTE;
2475
+ return values.map((_, i) => palette[i % palette.length]);
2476
+ }
2477
+
2311
2478
  /** A shape's value for a colourKeys key, as text (undefined: it has none, and is left as it is). */
2312
2479
  colourValue(template, key) {
2313
2480
  return key === 'kind' ? this.layoutCategory(template) : undefined;
2314
2481
  }
2315
2482
 
2483
+ /** A line's value for a colourKeys key (undefined: lines are not coloured by it, as here; a subclass's may be). */
2484
+ colourEdgeValue(edge, key) {
2485
+ return undefined;
2486
+ }
2487
+
2316
2488
  /** The values of `key` among the shapes, in order of first appearance, each with its shapes. */
2317
2489
  colourValues(key, templates = [...this.templates.values()].filter(t => t.visible !== false)) {
2318
2490
  const values = new Map();
@@ -2328,19 +2500,26 @@ export class DiagramEditor {
2328
2500
 
2329
2501
  /**
2330
2502
  * Colour the shapes by what `key` says of them (a heat map): each value's
2331
- * shapes filled with its colour (`colours`, by value; a value left out or
2503
+ * shapes filled with its colour, and lines with a value of their own
2504
+ * (colourEdgeValue) drawn in it (`colours`, by value; a value left out or
2332
2505
  * empty is left as it is), and with `legend` a legend of the colours
2333
2506
  * under the drawing. One undo step; locked shapes are left as they are.
2334
2507
  * @returns {Command|null}
2335
2508
  */
2336
2509
  colourByCommand(key, colours, { legend = false } = {}) {
2337
- const label = this.colourKeys().find(([k]) => k === key)?.[1] ?? key;
2510
+ const label = this.colourKeyLabel(key);
2338
2511
  const steps = [];
2339
2512
  for (const [value, templates] of this.colourValues(key)) {
2340
2513
  const fillColor = colours[value];
2341
2514
  if (!fillColor) continue;
2342
2515
  for (const t of templates) if (!this.isFixed(t.id) && t.fillColor !== fillColor) steps.push(new SetPropertiesCommand(t, { fillColor }, 'Colour'));
2343
2516
  }
2517
+ // Lines with a value of their own take its colour too (colourEdgeValue).
2518
+ for (const edge of this.getAllEdges()) {
2519
+ if (edge.editable === false) continue;
2520
+ const strokeColor = colours[this.colourEdgeValue(edge, key)];
2521
+ if (strokeColor && edge.strokeColor !== strokeColor) steps.push(new SetStyleCommand(edge, { strokeColor }, 'Colour'));
2522
+ }
2344
2523
  if (legend) {
2345
2524
  const entries = [...this.colourValues(key).keys()].filter(v => colours[v]).map(v => ({ label: v, fillColor: colours[v] }));
2346
2525
  const cmd = entries.length && this.legendCommand(entries, label);
@@ -2366,13 +2545,13 @@ export class DiagramEditor {
2366
2545
  /** The colour of each value of `key` (the palette's by default), then colour by it. @protected */
2367
2546
  askColours(key) {
2368
2547
  const values = [...this.colourValues(key).keys()];
2369
- const palette = this.constructor.COLOUR_PALETTE;
2370
- const label = this.colourKeys().find(([k]) => k === key)?.[1] ?? key;
2548
+ const defaults = this.colourDefaults(key, values);
2549
+ const label = this.colourKeyLabel(key);
2371
2550
  if (!values.length) { (globalThis.alert ?? (() => {}))(`No shape here has a value for ${label}.`); return; }
2372
2551
  new FormDialog({
2373
2552
  title: `Colour by ${label}`,
2374
2553
  fields: [
2375
- ...values.map((value, i) => ({ key: `value-${i}`, label: value, type: 'color', value: palette[i % palette.length] })),
2554
+ ...values.map((value, i) => ({ key: `value-${i}`, label: value, type: 'color', value: defaults[i] })),
2376
2555
  { key: 'legend', label: 'Add a legend', type: 'checkbox', value: true },
2377
2556
  ],
2378
2557
  submitLabel: 'Colour',
@@ -2412,19 +2591,45 @@ export class DiagramEditor {
2412
2591
  }
2413
2592
 
2414
2593
  /**
2415
- * The command that adds a legend: a title and a box per entry, in a row
2416
- * under the drawing (legendPosition). Here, notes; a subclass makes its own
2417
- * kind of box. Null with no entries.
2594
+ * Where a legend's boxes go, its top-left at `{x, y}`: a frame titled at its
2595
+ * top-left that holds every entry, the entries in rows of up to
2596
+ * `perRow` inside it. Centres and sizes, as boxes are placed.
2597
+ * @returns {{frame: {x, y, width, height}, items: Array<{x, y, width, height}>}}
2598
+ */
2599
+ static legendLayout(count, { x, y }, { perRow = 4, itemWidth = 120, itemHeight = 40, gap = 10, pad = 10, titleHeight = 24 } = {}) {
2600
+ const columns = Math.max(1, Math.min(count, perRow));
2601
+ const rows = Math.max(1, Math.ceil(count / columns));
2602
+ const width = 2 * pad + columns * itemWidth + (columns - 1) * gap;
2603
+ const height = titleHeight + rows * itemHeight + (rows - 1) * gap + pad;
2604
+ const items = Array.from({ length: count }, (_, i) => ({
2605
+ x: x + pad + (i % columns) * (itemWidth + gap) + itemWidth / 2,
2606
+ y: y + titleHeight + Math.floor(i / columns) * (itemHeight + gap) + itemHeight / 2,
2607
+ width: itemWidth, height: itemHeight,
2608
+ }));
2609
+ return { frame: { x: x + width / 2, y: y + height / 2, width, height }, items };
2610
+ }
2611
+
2612
+ /** The legend frame's text: its title, bold at the top-left (as Archi's legends are). */
2613
+ static LEGEND_TITLE_STYLE = Object.freeze({ textAlign: 'left', textPosition: 'top', fontWeight: 'bold' });
2614
+
2615
+ /**
2616
+ * The command that adds a legend under the drawing (legendPosition): a
2617
+ * frame titled `title` with a box per entry inside it, grouped so they move
2618
+ * as one. Here, notes; a subclass makes its own kind of box. Null with no
2619
+ * entries.
2418
2620
  * @param {Array<{label: string, fillColor: string}>} entries
2419
2621
  * @param {string} title
2420
2622
  */
2421
2623
  legendCommand(entries, title) {
2422
2624
  if (!entries.length) return null;
2423
- const { x, y } = this.legendPosition();
2625
+ const { frame, items } = DiagramEditor.legendLayout(entries.length, this.legendPosition());
2424
2626
  const stamp = Date.now().toString(36);
2425
- const note = (i, name, fillColor, width = 120) => new NoteTemplate(`legend-${stamp}-${i}`, name, { x: x + width / 2 + i * 130, y: y + 25, width, height: 40, fillColor });
2426
- const boxes = [note(0, title, '#ffffff'), ...entries.map((e, i) => note(i + 1, e.label, e.fillColor))];
2427
- return new CompositeCommand(`Add legend: ${title}`, boxes.map(t => new CreateTemplateCommand(this, t)));
2627
+ const note = (i, name, box, more) => new NoteTemplate(`legend-${stamp}-${i}`, name, { ...box, ...more });
2628
+ const boxes = [note(0, title, frame, { fillColor: '#ffffff', ...DiagramEditor.LEGEND_TITLE_STYLE }), ...entries.map((e, i) => note(i + 1, e.label, items[i], { fillColor: e.fillColor }))];
2629
+ return new CompositeCommand(`Add legend: ${title}`, [
2630
+ ...boxes.map(t => new CreateTemplateCommand(this, t)),
2631
+ new GroupCommand(this.arrangement, boxes.map(t => t.id), { name: title }),
2632
+ ]);
2428
2633
  }
2429
2634
 
2430
2635
  // ===== Format painter =====