@peter.prib/renderengine 0.12.0 → 0.14.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 +454 -296
- package/dist/renderengine-workspace.umd.js +4122 -1167
- package/dist/renderengine.umd.js +4103 -1164
- package/documentation/REFERENCE.md +96 -6
- package/package.json +8 -3
- package/src/chartCatalog.js +50 -3
- package/src/charting/Axis.js +47 -5
- package/src/charting/Chart3D.js +4 -2
- package/src/charting/DiagramEditor.js +239 -34
- package/src/charting/Hierarchy.js +28 -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/ask/AskBar.js +388 -0
- package/src/charting/ask/AskView.js +400 -0
- package/src/charting/charts/AreaChart.js +20 -3
- package/src/charting/charts/BarChart.js +5 -5
- package/src/charting/charts/BarChart3D.js +2 -2
- package/src/charting/charts/BarChart3DOrbit.js +3 -1
- package/src/charting/charts/BarChartBase.js +21 -3
- package/src/charting/charts/BoxPlotChart.js +2 -2
- package/src/charting/charts/BubbleChart.js +4 -1
- package/src/charting/charts/ChordDiagram.js +4 -4
- package/src/charting/charts/DensityChart.js +1 -1
- package/src/charting/charts/FunnelChart.js +1 -1
- package/src/charting/charts/GroupedBarChart.js +7 -9
- package/src/charting/charts/HeatmapChart.js +4 -1
- package/src/charting/charts/HistogramChart.js +2 -2
- package/src/charting/charts/IcicleChart.js +47 -0
- package/src/charting/charts/LollipopChart.js +104 -0
- package/src/charting/charts/MarimekkoChart.js +75 -0
- package/src/charting/charts/OutlineChart.js +10 -8
- package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
- package/src/charting/charts/ParetoChart.js +92 -0
- package/src/charting/charts/PipeChart.js +5 -2
- package/src/charting/charts/PopulationPyramidChart.js +4 -4
- package/src/charting/charts/RadarChart.js +5 -4
- package/src/charting/charts/RadialBarChart.js +2 -2
- package/src/charting/charts/SankeyDiagram.js +4 -4
- package/src/charting/charts/ScatterChart.js +6 -3
- package/src/charting/charts/StackedBarChart.js +82 -11
- package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
- package/src/charting/charts/StreamGraphChart.js +8 -0
- package/src/charting/charts/SunburstChart.js +2 -2
- package/src/charting/charts/TableChart.js +18 -2
- package/src/charting/charts/TreemapChart.js +4 -4
- package/src/charting/charts/ViolinChart.js +2 -2
- package/src/charting/charts/WaterfallChart.js +2 -2
- package/src/charting/charts/WordCloudChart.js +2 -2
- 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 +205 -13
- package/src/charting/pivot/PivotGridChart.js +1 -1
- package/src/charting/pivot/PivotView.js +555 -23
- package/src/charting/table/TableView.js +10 -1
- package/src/charting/table/TableViewChart.js +1 -1
- package/src/charting/templates/Template.js +10 -0
- package/src/exportImage.js +45 -18
- package/src/index.js +115 -6
- package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
- package/src/workspace/parts/DiagramEditorPart.js +2 -0
- package/src/workspace/parts/ModelHosted.js +15 -4
|
@@ -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 =====
|
|
@@ -145,6 +145,34 @@ export class Hierarchy {
|
|
|
145
145
|
return result;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Recursive rectangular partition (icicle / flame graph) layout over a node tree.
|
|
150
|
+
* Partitions `width` proportionally to node value at each level and steps down by `bandHeight` per depth.
|
|
151
|
+
* @returns {Array<{node: object, x: number, y: number, width: number, height: number, depth: number, isLeaf: boolean, topAncestor: object}>}
|
|
152
|
+
*/
|
|
153
|
+
static icicleLayout(nodes, x, y, width, bandHeight, depth = 0, topAncestors = null) {
|
|
154
|
+
const items = (nodes ?? [])
|
|
155
|
+
.map(node => ({ node, value: Hierarchy.value(node) }))
|
|
156
|
+
.filter(it => it.value > 0);
|
|
157
|
+
const total = items.reduce((s, it) => s + it.value, 0) || 1;
|
|
158
|
+
|
|
159
|
+
const result = [];
|
|
160
|
+
let cursorX = x;
|
|
161
|
+
for (const it of items) {
|
|
162
|
+
const { node } = it;
|
|
163
|
+
const spanW = (it.value / total) * width;
|
|
164
|
+
const isLeaf = !node.children?.length;
|
|
165
|
+
const topAncestor = topAncestors ? topAncestors.get(node) : node;
|
|
166
|
+
result.push({ node, x: cursorX, y, width: spanW, height: bandHeight, depth, isLeaf, topAncestor });
|
|
167
|
+
if (!isLeaf) {
|
|
168
|
+
const childTopAncestors = new Map(node.children.map(c => [c, topAncestor]));
|
|
169
|
+
result.push(...Hierarchy.icicleLayout(node.children, cursorX, y + bandHeight, spanW, bandHeight, depth + 1, childTopAncestors));
|
|
170
|
+
}
|
|
171
|
+
cursorX += spanW;
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
148
176
|
/**
|
|
149
177
|
* Flat, pre-order list of *visible* rows for an outline view. A branch
|
|
150
178
|
* present in `collapsed` contributes its own row but none of its
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { pickFile } from './toolbar/pickFile.js';
|
|
2
2
|
import { downloadBlob } from '../exportImage.js';
|
|
3
|
+
import { FormDialog } from './FormDialog.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* ModelFileFormat — one file format a model is exported to and imported
|
|
@@ -100,6 +101,52 @@ export function modelFileMenuItems(formats, {
|
|
|
100
101
|
return groups.flatMap((group, i) => (i ? [{ separator: true }, ...group] : group));
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The part of pasted text that is a model in `format`: the text as it is when
|
|
106
|
+
* the format recognizes it; else, as an assistant or a page would wrap it, a
|
|
107
|
+
* fenced block (```json … ```), or the text from its first { (or [) to its
|
|
108
|
+
* last } (or ]). Null when none is.
|
|
109
|
+
* @param {ModelFileFormat} format - one that reads text
|
|
110
|
+
* @param {string} text
|
|
111
|
+
* @returns {string|null}
|
|
112
|
+
*/
|
|
113
|
+
export function findPayload(format, text) {
|
|
114
|
+
const candidates = [String(text ?? '').trim()];
|
|
115
|
+
for (const m of candidates[0].matchAll(/```[\w-]*[ \t]*\r?\n([\s\S]*?)```/g)) candidates.push(m[1].trim());
|
|
116
|
+
for (const [open, close] of [['{', '}'], ['[', ']']]) {
|
|
117
|
+
const from = candidates[0].indexOf(open), to = candidates[0].lastIndexOf(close);
|
|
118
|
+
if (from >= 0 && to > from) candidates.push(candidates[0].slice(from, to + 1));
|
|
119
|
+
}
|
|
120
|
+
return candidates.find(c => c && format.recognizes(c)) ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Import a model from pasted text rather than a file: a dialog with a text
|
|
125
|
+
* box; what is pasted may be wrapped in other text (findPayload). The new
|
|
126
|
+
* model goes to `onImport`; text with no model in it is refused in the dialog.
|
|
127
|
+
* @param {ModelFileFormat} format - one that reads text
|
|
128
|
+
* @returns {FormDialog}
|
|
129
|
+
*/
|
|
130
|
+
export function askPasteImport(format, { onImport, document: doc = globalThis.document } = {}) {
|
|
131
|
+
let model = null;
|
|
132
|
+
const dialog = new FormDialog({
|
|
133
|
+
title: `Paste ${format.label}`,
|
|
134
|
+
description: `Paste the ${format.label} text: a file's contents, or an answer or page that has it in it (in a \`\`\` block, say).`,
|
|
135
|
+
fields: [{ key: 'text', label: 'Text', type: 'textarea', rows: 14, value: '' }],
|
|
136
|
+
submitLabel: 'Import',
|
|
137
|
+
idPrefix: 'paste-import-',
|
|
138
|
+
document: doc,
|
|
139
|
+
validate: ({ text }) => {
|
|
140
|
+
const payload = findPayload(format, text);
|
|
141
|
+
if (!payload) return `No ${format.label} found in what is pasted`;
|
|
142
|
+
try { model = format.import(payload); return null; } catch (err) { return `It could not be read: ${err.message}`; }
|
|
143
|
+
},
|
|
144
|
+
onApply: () => onImport(model),
|
|
145
|
+
});
|
|
146
|
+
dialog.open();
|
|
147
|
+
return dialog;
|
|
148
|
+
}
|
|
149
|
+
|
|
103
150
|
/**
|
|
104
151
|
* Ask for a file and import it in `format`; resolves to the new model, or
|
|
105
152
|
* null when no file was chosen or it could not be read (reported).
|