@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.
- package/README.md +10 -4
- package/dist/renderengine-workspace.umd.js +5779 -3731
- package/dist/renderengine.umd.js +5757 -3725
- package/documentation/REFERENCE.md +3291 -3077
- package/package.json +2 -2
- package/src/charting/Axis.js +47 -5
- package/src/charting/Chart.js +25 -0
- package/src/charting/ChartChooser.js +5 -4
- package/src/charting/DiagramEditor.js +239 -34
- package/src/charting/Legend.js +16 -0
- package/src/charting/ModelFileFormat.js +47 -0
- package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
- package/src/charting/archimate/deleteFromModel.js +57 -0
- package/src/charting/archimate/elementMenuItems.js +3 -1
- package/src/charting/archimate/heatMaps.js +169 -0
- package/src/charting/archimate/index.js +1 -0
- package/src/charting/charts/BarChart.js +5 -5
- package/src/charting/charts/BarChartBase.js +21 -3
- package/src/charting/charts/GroupedBarChart.js +42 -22
- package/src/charting/charts/LineChart.js +9 -8
- package/src/charting/charts/PieChart.js +2 -2
- package/src/charting/charts/TableChart.js +34 -5
- package/src/charting/diagrams/ArchimateModel.js +33 -0
- package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
- package/src/charting/diagrams/EdgeGeometry.js +16 -0
- package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
- package/src/charting/pivot/PivotChart.js +50 -0
- package/src/charting/pivot/PivotGridChart.js +191 -0
- package/src/charting/pivot/PivotView.js +740 -0
- package/src/charting/table/TableView.js +84 -15
- package/src/charting/table/TableViewChart.js +5 -3
- package/src/charting/templates/Template.js +10 -0
- package/src/exportImage.js +45 -18
- package/src/index.js +2899 -2807
- package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
- package/src/workspace/parts/DiagramEditorPart.js +2 -0
- 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.
|
|
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"
|
package/src/charting/Axis.js
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
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 =
|
|
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
|
|
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
|
|
128
|
+
return { min, max };
|
|
87
129
|
}
|
|
88
130
|
}
|
package/src/charting/Chart.js
CHANGED
|
@@ -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
|
-
//
|
|
190
|
-
// as 0, a figure the data does not hold
|
|
191
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
|
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); '
|
|
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:
|
|
2010
|
+
// Empty canvas: canvasContextMenu there (a shape's own menu stops the event first).
|
|
1852
2011
|
container.addEventListener('contextmenu', (e) => {
|
|
1853
|
-
const
|
|
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
|
|
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.
|
|
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
|
|
2370
|
-
const label = this.
|
|
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:
|
|
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
|
-
*
|
|
2416
|
-
*
|
|
2417
|
-
*
|
|
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 {
|
|
2625
|
+
const { frame, items } = DiagramEditor.legendLayout(entries.length, this.legendPosition());
|
|
2424
2626
|
const stamp = Date.now().toString(36);
|
|
2425
|
-
const note = (i, name,
|
|
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}`,
|
|
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 =====
|
package/src/charting/Legend.js
CHANGED
|
@@ -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;
|