@peter.prib/renderengine 0.10.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 (37) hide show
  1. package/README.md +10 -4
  2. package/dist/renderengine-workspace.umd.js +5779 -3731
  3. package/dist/renderengine.umd.js +5757 -3725
  4. package/documentation/REFERENCE.md +3291 -3077
  5. package/package.json +2 -2
  6. package/src/charting/Axis.js +47 -5
  7. package/src/charting/Chart.js +25 -0
  8. package/src/charting/ChartChooser.js +5 -4
  9. package/src/charting/DiagramEditor.js +239 -34
  10. package/src/charting/Legend.js +16 -0
  11. package/src/charting/ModelFileFormat.js +47 -0
  12. package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
  13. package/src/charting/archimate/deleteFromModel.js +57 -0
  14. package/src/charting/archimate/elementMenuItems.js +3 -1
  15. package/src/charting/archimate/heatMaps.js +169 -0
  16. package/src/charting/archimate/index.js +1 -0
  17. package/src/charting/charts/BarChart.js +5 -5
  18. package/src/charting/charts/BarChartBase.js +21 -3
  19. package/src/charting/charts/GroupedBarChart.js +42 -22
  20. package/src/charting/charts/LineChart.js +9 -8
  21. package/src/charting/charts/PieChart.js +2 -2
  22. package/src/charting/charts/TableChart.js +34 -5
  23. package/src/charting/diagrams/ArchimateModel.js +33 -0
  24. package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
  25. package/src/charting/diagrams/EdgeGeometry.js +16 -0
  26. package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
  27. package/src/charting/pivot/PivotChart.js +50 -0
  28. package/src/charting/pivot/PivotGridChart.js +191 -0
  29. package/src/charting/pivot/PivotView.js +740 -0
  30. package/src/charting/table/TableView.js +84 -15
  31. package/src/charting/table/TableViewChart.js +5 -3
  32. package/src/charting/templates/Template.js +10 -0
  33. package/src/exportImage.js +45 -18
  34. package/src/index.js +2899 -2807
  35. package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
  36. package/src/workspace/parts/DiagramEditorPart.js +2 -0
  37. package/src/workspace/parts/ModelHosted.js +15 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peter.prib/renderengine",
3
- "version": "0.10.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",
@@ -28,7 +28,7 @@
28
28
  "archimate": "node scripts/open.mjs examples/archimate.html",
29
29
  "diagram": "node scripts/open.mjs examples/diagram-editor.html",
30
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/table/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
31
+ "test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/table/*.js ./test/charting/pivot/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
32
32
  "build": "rollup -c",
33
33
  "prepublishOnly": "npm test && npm run build",
34
34
  "screenshots": "node scripts/screenshots/screenshots.mjs"
@@ -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
  }
@@ -4,6 +4,10 @@
4
4
  * Subclasses implement `_render()` and are instantiated by RenderEngine's
5
5
  * chart methods. Keeping each chart in its own class removes the giant
6
6
  * RenderEngine God class one chart at a time.
7
+ *
8
+ * A chart that draws one mark per value (a bar, a slice, a point) records it with `_datum()`; when
9
+ * the options carry `onDatumClick(datum, event)` and the renderer's shapes take clicks (SVG), each
10
+ * such mark answers a click with its datum, `{ label, series }` — how a host drills from a chart.
7
11
  */
8
12
  export class Chart {
9
13
  /** Registry key / catalog key for this chart type. Subclasses override. */
@@ -16,13 +20,34 @@ export class Chart {
16
20
  this.renderer = renderer;
17
21
  this.data = data;
18
22
  this.options = options;
23
+ /** The marks drawn for values, `{ shape, datum }`, as the last render() drew them. */
24
+ this.datums = [];
19
25
  }
20
26
 
21
27
  /** Render the chart. Subclasses implement `_render()`. */
22
28
  render() {
23
29
  if (!this.renderer) return;
24
30
  this.renderer.clear();
31
+ this.datums = [];
25
32
  this._render();
33
+ this._wireDatums();
34
+ }
35
+
36
+ /** Records that `shape` (what the renderer returned) shows one value, `datum` = { label, series? }. */
37
+ _datum(shape, datum) {
38
+ this.datums.push({ shape, datum: { series: null, ...datum } });
39
+ return shape;
40
+ }
41
+
42
+ /** Gives each recorded mark the host's onDatumClick, where the renderer's shapes take clicks. */
43
+ _wireDatums() {
44
+ const handler = this.options?.onDatumClick;
45
+ if (typeof handler !== 'function' || this.renderer.supportsSceneGraph !== true) return;
46
+ for (const { shape, datum } of this.datums) {
47
+ if (typeof shape?.addEventListener !== 'function') continue;
48
+ shape.addEventListener('click', (event) => { event.stopPropagation?.(); handler(datum, event); });
49
+ if (shape.style) shape.style.cursor = 'pointer';
50
+ }
26
51
  }
27
52
 
28
53
  /** Subclasses implement the actual drawing here. */
@@ -186,14 +186,15 @@ class CategorySeries extends ResultShape {
186
186
  const { category } = this;
187
187
  const time = category.kind === 'time';
188
188
  const rows = time ? inOrder(this.rows, category.name) : this.rows;
189
- // lineChart and groupedBarChart have no gap for a missing value: a null would have to be drawn
190
- // as 0, a figure the data does not hold. So a result with one is not offered as series.
191
- if (rows.some(r => this.numbers.some(n => r[n.name] == null))) return [];
189
+ // groupedBarChart leaves a gap for a missing value; lineChart has none (a null would have to be
190
+ // drawn as 0, a figure the data does not hold), so a result with one is offered as bars only.
191
+ const gaps = rows.some(r => this.numbers.some(n => r[n.name] == null));
192
192
  const data = this.numbers.map(n => ({
193
193
  name: n.name,
194
- values: rows.map(r => ({ label: label(r[category.name]), value: r[n.name] })),
194
+ values: rows.map(r => ({ label: label(r[category.name]), value: r[n.name] ?? null })),
195
195
  }));
196
196
  const names = this.numbers.map(n => n.name).join(', ');
197
+ if (gaps) return [{ key: 'groupedBar', reason: `${names} for each ${category.name} (a gap where there is no value)`, data }];
197
198
  return time
198
199
  ? [{ key: 'line', reason: `${names} over ${category.name}`, data }, { key: 'groupedBar', reason: `${names} for each ${category.name}`, data }]
199
200
  : [{ key: 'groupedBar', reason: `${names} for each ${category.name}`, data }, { key: 'line', reason: `${names} across ${category.name}`, data }];
@@ -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 =====
@@ -36,6 +36,19 @@ export class Legend {
36
36
  * @param {number} drawingWidth - The renderer's width
37
37
  * @param {object} options - As the constructor's, without x and y
38
38
  */
39
+ /**
40
+ * The plot width that leaves room for this legend beside the plot, so it never covers the data:
41
+ * narrower by as much as the legend would run off the drawing — unless that would leave the plot
42
+ * under MIN_PLOT_SHARE of its width; then the plot keeps its width and beside() insets the legend.
43
+ */
44
+ static plotWidthBeside(box, drawingWidth, options) {
45
+ const { width } = new Legend({ ...options, x: 0, y: 0 }).size();
46
+ const over = box.x + box.width + 20 + width - drawingWidth;
47
+ if (!(drawingWidth > 0) || over <= 0) return box.width;
48
+ const narrower = box.width - over;
49
+ return narrower >= box.width * Legend.MIN_PLOT_SHARE ? narrower : box.width;
50
+ }
51
+
39
52
  static beside(box, drawingWidth, options) {
40
53
  const legend = new Legend({ ...options, x: 0, y: 0 });
41
54
  const { width } = legend.size();
@@ -99,3 +112,6 @@ export class Legend {
99
112
  }
100
113
  }
101
114
  }
115
+
116
+ /** The least share of its width a plot keeps when it narrows for a legend beside it. */
117
+ Legend.MIN_PLOT_SHARE = 0.65;