@peter.prib/renderengine 0.14.0 → 0.15.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 +45 -19
- package/bin/renderengine.mjs +157 -0
- package/dist/renderengine-workspace.umd.js +49391 -39748
- package/dist/renderengine.umd.js +25366 -15650
- package/documentation/REFERENCE.md +38 -0
- package/package.json +6 -1
- package/src/CanvasRenderer.js +225 -200
- package/src/SVGRenderer.js +476 -454
- package/src/archimate.js +22 -2
- package/src/archimateArchiFormat.js +1735 -1701
- package/src/archimateArchive.js +36 -7
- package/src/archimateCSV.js +93 -37
- package/src/archimateDrawio.js +2 -0
- package/src/archimateExchangeFormat.js +3 -1
- package/src/chartCatalog.js +219 -0
- package/src/charting/Annotations.js +169 -0
- package/src/charting/ArchimateDiagramEditor.js +54 -10
- package/src/charting/Axis.js +217 -5
- package/src/charting/Chart.js +99 -9
- package/src/charting/Chart3D.js +3 -0
- package/src/charting/ChartBox.js +8 -1
- package/src/charting/ColorPalette.js +42 -4
- package/src/charting/DiagramEditor.js +174 -39
- package/src/charting/DiagramEditorCommands.js +28 -0
- package/src/charting/Edge.js +7 -0
- package/src/charting/Hierarchy.js +114 -0
- package/src/charting/InteractionManager.js +4 -0
- package/src/charting/ModelFileFormat.js +5 -1
- package/src/charting/SettingsManager.js +35 -0
- package/src/charting/ShapePaths.js +82 -10
- package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
- package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
- package/src/charting/charts/AlluvialDiagram.js +311 -0
- package/src/charting/charts/AreaChart.js +52 -15
- package/src/charting/charts/BarChart.js +38 -3
- package/src/charting/charts/BarChart3D.js +5 -3
- package/src/charting/charts/BarChartBase.js +26 -6
- package/src/charting/charts/BeeswarmChart.js +164 -0
- package/src/charting/charts/BoxPlotChart.js +23 -1
- package/src/charting/charts/CalendarHeatmapChart.js +2 -1
- package/src/charting/charts/CandlestickChart.js +172 -4
- package/src/charting/charts/ChordDiagram.js +105 -2
- package/src/charting/charts/CirclePackChart.js +81 -0
- package/src/charting/charts/ComboChart.js +233 -0
- package/src/charting/charts/ContourChart.js +310 -0
- package/src/charting/charts/DonutChart3D.js +13 -0
- package/src/charting/charts/DumbbellChart.js +217 -0
- package/src/charting/charts/EcdfChart.js +150 -0
- package/src/charting/charts/FunnelChart.js +4 -1
- package/src/charting/charts/GeoMapChart.js +132 -11
- package/src/charting/charts/GroupedBarChart.js +44 -3
- package/src/charting/charts/GroupedDistributionChart.js +26 -1
- package/src/charting/charts/HeatmapChart.js +2 -2
- package/src/charting/charts/HexbinChart.js +137 -0
- package/src/charting/charts/HierarchyChart.js +77 -3
- package/src/charting/charts/HorizonChart.js +105 -0
- package/src/charting/charts/KagiChart.js +167 -0
- package/src/charting/charts/LineChart.js +74 -18
- package/src/charting/charts/OhlcChart.js +79 -0
- package/src/charting/charts/ParallelCoordinatesChart.js +9 -1
- package/src/charting/charts/PieChart.js +195 -17
- package/src/charting/charts/PieChart3D.js +137 -0
- package/src/charting/charts/PointAndFigureChart.js +198 -0
- package/src/charting/charts/PolarAreaChart.js +189 -0
- package/src/charting/charts/RadarChart.js +7 -1
- package/src/charting/charts/RadialBarChart.js +5 -2
- package/src/charting/charts/RenkoChart.js +178 -0
- package/src/charting/charts/RidgelineChart.js +113 -0
- package/src/charting/charts/SankeyDiagram.js +127 -4
- package/src/charting/charts/ScatterChart.js +13 -0
- package/src/charting/charts/ScatterMatrixChart.js +119 -0
- package/src/charting/charts/SlopegraphChart.js +205 -0
- package/src/charting/charts/SparklineTableChart.js +166 -0
- package/src/charting/charts/StackedBarChart.js +48 -5
- package/src/charting/charts/StackedBarChart3DOrbit.js +10 -5
- package/src/charting/charts/StreamGraphChart.js +10 -3
- package/src/charting/charts/SunburstChart.js +27 -7
- package/src/charting/charts/SurfaceChart3D.js +157 -0
- package/src/charting/charts/TreemapChart.js +28 -8
- package/src/charting/charts/ViolinChart.js +18 -0
- package/src/charting/datamodel/ArchimateDataLink.js +70 -0
- package/src/charting/datamodel/DataLineage.js +150 -0
- package/src/charting/datamodel/DataModel.js +83 -1
- package/src/charting/datamodel/ModelMapping.js +87 -0
- package/src/charting/datamodel/SqlDdl.js +110 -85
- package/src/charting/datamodel/index.js +3 -0
- package/src/charting/decision/DecisionWorkbench.js +295 -0
- package/src/charting/diagrams/ArchimateDiagram.js +25 -9
- package/src/charting/diagrams/ArchimateModel.js +1382 -1238
- package/src/charting/diagrams/GraphDiagram.js +1424 -1350
- package/src/charting/diagrams/SketchView.js +121 -0
- package/src/charting/diagrams/sketchBoxKinds.js +30 -0
- package/src/charting/modelTree/AnalysisPanel.js +101 -23
- package/src/charting/modelTree/NavigatorModel.js +410 -0
- package/src/charting/modelTree/NavigatorPanel.js +453 -0
- package/src/charting/pivot/PivotChart.js +109 -4
- package/src/charting/pivot/PivotGridChart.js +321 -33
- package/src/charting/pivot/PivotView.js +82 -0
- package/src/charting/report/TemplatedReport.js +547 -0
- package/src/charting/templates/BoxTemplate.js +28 -27
- package/src/charting/templates/PackageTemplate.js +26 -25
- package/src/charting/templates/SwimlaneTemplate.js +19 -18
- package/src/charting/templates/Template.js +219 -212
- package/src/cli.js +146 -0
- package/src/index.js +3370 -2995
- package/src/workspace/Workspace.js +126 -7
- package/src/workspace/archimateServices.js +4 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
- package/src/workspace/styles.js +36 -1
package/src/charting/Chart3D.js
CHANGED
|
@@ -86,14 +86,17 @@ export class Chart3D extends Chart {
|
|
|
86
86
|
const xSide = [c(xf, y0, z0), c(xf, y1, z0), c(xf, y1, z1), c(xf, y0, z1)];
|
|
87
87
|
const zSide = [c(x0, y0, zf), c(x1, y0, zf), c(x1, y1, zf), c(x0, y1, zf)];
|
|
88
88
|
|
|
89
|
+
const faceEls = [];
|
|
89
90
|
const drawFace = (pts, shade) => {
|
|
90
91
|
const d = `M${pts[0].x},${pts[0].y} L${pts[1].x},${pts[1].y} L${pts[2].x},${pts[2].y} L${pts[3].x},${pts[3].y} Z`;
|
|
91
92
|
const el = renderer.path(d, ColorPalette.lighten(color, shade), { color: '#333', width: 1 });
|
|
92
93
|
if (datum) this._datum(el, datum);
|
|
94
|
+
if (el) faceEls.push(el);
|
|
93
95
|
};
|
|
94
96
|
drawFace(top, 0.15);
|
|
95
97
|
drawFace(xSide, faceXSign > 0 ? -0.05 : -0.2);
|
|
96
98
|
drawFace(zSide, faceZSign > 0 ? -0.1 : -0.25);
|
|
99
|
+
this._wireHoverTransform(faceEls, { dx: 0, dy: -5 });
|
|
97
100
|
|
|
98
101
|
const allDepths = [...top, ...xSide, ...zSide].map(p => p.depth);
|
|
99
102
|
return allDepths.reduce((s, d) => s + d, 0) / allDepths.length;
|
package/src/charting/ChartBox.js
CHANGED
|
@@ -48,11 +48,13 @@ export class ChartLabels {
|
|
|
48
48
|
* @param {string} [options.title]
|
|
49
49
|
* @param {string} [options.xAxisLabel]
|
|
50
50
|
* @param {string} [options.yAxisLabel]
|
|
51
|
+
* @param {string} [options.secondaryYAxisLabel]
|
|
51
52
|
*/
|
|
52
|
-
constructor({ title, xAxisLabel, yAxisLabel }) {
|
|
53
|
+
constructor({ title, xAxisLabel, yAxisLabel, secondaryYAxisLabel } = {}) {
|
|
53
54
|
this.title = title;
|
|
54
55
|
this.xAxisLabel = xAxisLabel;
|
|
55
56
|
this.yAxisLabel = yAxisLabel;
|
|
57
|
+
this.secondaryYAxisLabel = secondaryYAxisLabel;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/**
|
|
@@ -71,6 +73,11 @@ export class ChartLabels {
|
|
|
71
73
|
textAnchor: 'middle', rotation: -Math.PI / 2,
|
|
72
74
|
});
|
|
73
75
|
}
|
|
76
|
+
if (this.secondaryYAxisLabel) {
|
|
77
|
+
renderer.text(this.secondaryYAxisLabel, box.x + box.width + 45, box.y + box.height / 2, {
|
|
78
|
+
textAnchor: 'middle', rotation: Math.PI / 2,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
74
81
|
if (this.xAxisLabel) {
|
|
75
82
|
renderer.text(this.xAxisLabel, box.x + box.width / 2, box.y + box.height + 45, {
|
|
76
83
|
textAnchor: 'middle', fontSize: '14px', fill: '#333',
|
|
@@ -35,6 +35,15 @@ export class ColorPalette {
|
|
|
35
35
|
return ColorPalette.lighten(color, amount);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Darken a hex color by `amount` in [0, 1]. */
|
|
39
|
+
static darken(color, amount) {
|
|
40
|
+
return ColorPalette.lighten(color, -Math.abs(amount));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
darken(color, amount) {
|
|
44
|
+
return ColorPalette.darken(color, amount);
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
/** Parse "#rrggbb" into {r, g, b}. */
|
|
39
48
|
static hexToRgb(hex) {
|
|
40
49
|
const num = parseInt(hex.replace('#', ''), 16);
|
|
@@ -60,8 +69,12 @@ export class ColorPalette {
|
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
/** Maps a value within [vMin, vMax] to a color along an N-stop scale
|
|
63
|
-
* (linear interpolation between adjacent stops).
|
|
64
|
-
|
|
72
|
+
* (linear interpolation between adjacent stops). If midValue is provided,
|
|
73
|
+
* it delegates to scaleDiverging. */
|
|
74
|
+
static scaleColor(stops, vMin, vMax, v, midValue) {
|
|
75
|
+
if (midValue !== undefined && stops.length >= 3) {
|
|
76
|
+
return ColorPalette.scaleDiverging(stops, vMin, vMax, v, midValue);
|
|
77
|
+
}
|
|
65
78
|
if (stops.length === 1) return stops[0];
|
|
66
79
|
const range = (vMax - vMin) || 1;
|
|
67
80
|
const t = Math.min(1, Math.max(0, (v - vMin) / range));
|
|
@@ -72,8 +85,33 @@ export class ColorPalette {
|
|
|
72
85
|
return ColorPalette.interpolate(stops[segIndex], stops[segIndex + 1], localT);
|
|
73
86
|
}
|
|
74
87
|
|
|
75
|
-
scaleColor(stops, vMin, vMax, v) {
|
|
76
|
-
return ColorPalette.scaleColor(stops, vMin, vMax, v);
|
|
88
|
+
scaleColor(stops, vMin, vMax, v, midValue) {
|
|
89
|
+
return ColorPalette.scaleColor(stops, vMin, vMax, v, midValue);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Maps a value within [vMin, vMax] centered around a neutral midValue
|
|
94
|
+
* (e.g. 0) to a diverging color scale.
|
|
95
|
+
*/
|
|
96
|
+
static scaleDiverging(stops, vMin, vMax, v, midValue = 0) {
|
|
97
|
+
if (!stops || stops.length === 0) return '#000000';
|
|
98
|
+
if (stops.length === 1) return stops[0];
|
|
99
|
+
const midIdx = Math.floor(stops.length / 2);
|
|
100
|
+
if (v <= midValue) {
|
|
101
|
+
const range = (midValue - vMin) || 1;
|
|
102
|
+
const t = Math.min(1, Math.max(0, (v - vMin) / range));
|
|
103
|
+
const subStops = stops.slice(0, midIdx + 1);
|
|
104
|
+
return ColorPalette.scaleColor(subStops, 0, 1, t);
|
|
105
|
+
} else {
|
|
106
|
+
const range = (vMax - midValue) || 1;
|
|
107
|
+
const t = Math.min(1, Math.max(0, (v - midValue) / range));
|
|
108
|
+
const subStops = stops.slice(midIdx);
|
|
109
|
+
return ColorPalette.scaleColor(subStops, 0, 1, t);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
scaleDiverging(stops, vMin, vMax, v, midValue = 0) {
|
|
114
|
+
return ColorPalette.scaleDiverging(stops, vMin, vMax, v, midValue);
|
|
77
115
|
}
|
|
78
116
|
|
|
79
117
|
/** Perceptual-ish luma check for choosing readable text on a color. */
|
|
@@ -38,7 +38,7 @@ import { Edge } from './Edge.js';
|
|
|
38
38
|
import { PropertiesPanel } from './PropertiesPanel.js';
|
|
39
39
|
import {
|
|
40
40
|
MoveElementCommand, BringToFrontCommand, SendToBackCommand, RaiseCommand, LowerCommand,
|
|
41
|
-
ResizeCommand, RotateCommand, SetPropertiesCommand, DeleteTemplateCommand, CreateTemplateCommand,
|
|
41
|
+
ResizeCommand, RotateCommand, SetPropertiesCommand, DeleteTemplateCommand, CreateTemplateCommand, NestElementCommand,
|
|
42
42
|
} from './DiagramEditorCommands.js';
|
|
43
43
|
import { Arrangement, GroupCommand, UngroupCommand, LockCommand, arrangeMenuItems, align, distribute, matchSize, ALIGNMENTS, DISTRIBUTIONS, SIZE_MATCHES, layout, nestLayout, LAYOUTS } from './arrangement/index.js';
|
|
44
44
|
import {
|
|
@@ -221,11 +221,15 @@ export class DiagramEditor {
|
|
|
221
221
|
const act = { c: () => this.copySelection(), x: () => this.cutSelection(), v: () => this.paste(), d: () => this.duplicateSelection() }[e.key.toLowerCase()];
|
|
222
222
|
if (act) { e.preventDefault(); act(); }
|
|
223
223
|
}
|
|
224
|
-
// Ctrl+A selects every shape.
|
|
224
|
+
// Ctrl+A selects every shape, Ctrl+P prints the view.
|
|
225
225
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
|
|
226
226
|
e.preventDefault();
|
|
227
227
|
this.selectTemplates(this.selectableIds());
|
|
228
228
|
}
|
|
229
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'p') {
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
this.print();
|
|
232
|
+
}
|
|
229
233
|
// The arrow keys move the selection a grid step (one pixel without a grid; ten times with Shift).
|
|
230
234
|
const ARROWS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
|
231
235
|
if (ARROWS[e.key] && this.selectedTemplateId && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
@@ -481,6 +485,8 @@ export class DiagramEditor {
|
|
|
481
485
|
this._showMenu(x, y, [
|
|
482
486
|
{ label: '⚙ Properties', action: () => this._showEdgeProperties(edge) },
|
|
483
487
|
{ separator: true },
|
|
488
|
+
...this.formatMenuItems(edgeId),
|
|
489
|
+
{ separator: true },
|
|
484
490
|
...(at ? [{ label: 'Add waypoint here', action: () => this.addWaypoint(edgeId, at) }] : []),
|
|
485
491
|
...(bends ? [{ label: `Straighten (remove ${bends} waypoint${bends === 1 ? '' : 's'})`, action: () => this.clearWaypoints(edgeId) }] : []),
|
|
486
492
|
{ separator: true },
|
|
@@ -593,7 +599,13 @@ export class DiagramEditor {
|
|
|
593
599
|
* @returns {Array<string>}
|
|
594
600
|
*/
|
|
595
601
|
containedIn(templateId) {
|
|
596
|
-
|
|
602
|
+
const inner = [];
|
|
603
|
+
for (const [id, t] of this.templates) {
|
|
604
|
+
if (id !== templateId && this.parentOf(id) === templateId) {
|
|
605
|
+
inner.push(id);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return inner;
|
|
597
609
|
}
|
|
598
610
|
|
|
599
611
|
executeOrderCommand(elementId, operation) {
|
|
@@ -681,9 +693,9 @@ export class DiagramEditor {
|
|
|
681
693
|
// Apply zoom and pan transform only to elements, not grid
|
|
682
694
|
group.setAttribute('transform', `translate(${this.panX}, ${this.panY}) scale(${this.zoomLevel})`);
|
|
683
695
|
|
|
684
|
-
// Sort templates by z-index and render each
|
|
696
|
+
// Sort templates by z-index and render each (containers before contained children)
|
|
685
697
|
const sorted = Array.from(this.templates.values())
|
|
686
|
-
.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
|
|
698
|
+
.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0) || (this.isAncestor(a.id, b.id) ? -1 : this.isAncestor(b.id, a.id) ? 1 : 0));
|
|
687
699
|
|
|
688
700
|
for (const template of sorted) {
|
|
689
701
|
if (template.visible === false) continue;
|
|
@@ -803,9 +815,76 @@ export class DiagramEditor {
|
|
|
803
815
|
{ label: 'Export image (PNG)', action: () => this.exportImage('png') },
|
|
804
816
|
{ label: 'Export image (SVG)', action: () => this.exportImage('svg') },
|
|
805
817
|
{ label: 'Copy image', action: () => this.exportImage('copy') },
|
|
818
|
+
{ label: 'Print…', action: () => this.print() },
|
|
806
819
|
];
|
|
807
820
|
}
|
|
808
821
|
|
|
822
|
+
/**
|
|
823
|
+
* Print the current view. Isolates the diagram SVG in a printable frame,
|
|
824
|
+
* scaled cleanly to fit page margins, and opens the print dialog.
|
|
825
|
+
* @param {{area?: 'drawing'|'view'}} [options]
|
|
826
|
+
* @returns {boolean|null} true when print was triggered, or null when unavailable
|
|
827
|
+
*/
|
|
828
|
+
print({ area = 'drawing' } = {}) {
|
|
829
|
+
const doc = this.document ?? (typeof document !== 'undefined' ? document : null);
|
|
830
|
+
if (!doc) return null;
|
|
831
|
+
const host = this.drawingHost();
|
|
832
|
+
if (!host) return null;
|
|
833
|
+
const name = this.imageName();
|
|
834
|
+
let svgMarkup = null;
|
|
835
|
+
if (area === 'drawing' && host.tagName?.toLowerCase() === 'svg') {
|
|
836
|
+
const viewBox = this.drawingBounds();
|
|
837
|
+
if (viewBox) {
|
|
838
|
+
svgMarkup = this._withPlainDrawing(() => this._croppedSvg(host, viewBox));
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (!svgMarkup) {
|
|
842
|
+
svgMarkup = this._withPlainDrawing(() => prepareSvgMarkup(host));
|
|
843
|
+
}
|
|
844
|
+
if (!svgMarkup) return null;
|
|
845
|
+
|
|
846
|
+
const iframe = doc.createElement('iframe');
|
|
847
|
+
iframe.style.position = 'fixed';
|
|
848
|
+
iframe.style.right = '0';
|
|
849
|
+
iframe.style.bottom = '0';
|
|
850
|
+
iframe.style.width = '0';
|
|
851
|
+
iframe.style.height = '0';
|
|
852
|
+
iframe.style.border = '0';
|
|
853
|
+
doc.body?.appendChild(iframe);
|
|
854
|
+
|
|
855
|
+
const frameDoc = iframe.contentDocument || iframe.contentWindow?.document;
|
|
856
|
+
if (!frameDoc) {
|
|
857
|
+
iframe.remove?.();
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
frameDoc.open?.();
|
|
861
|
+
frameDoc.write?.(`<!DOCTYPE html><html><head><title>${name}</title>
|
|
862
|
+
<style>
|
|
863
|
+
@page { size: auto; margin: 10mm; }
|
|
864
|
+
body { margin: 0; padding: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; }
|
|
865
|
+
svg { max-width: 100%; max-height: 100vh; height: auto; }
|
|
866
|
+
</style>
|
|
867
|
+
</head><body>${svgMarkup}</body></html>`);
|
|
868
|
+
frameDoc.close?.();
|
|
869
|
+
|
|
870
|
+
const triggerPrint = () => {
|
|
871
|
+
try {
|
|
872
|
+
iframe.contentWindow?.focus?.();
|
|
873
|
+
iframe.contentWindow?.print?.();
|
|
874
|
+
} finally {
|
|
875
|
+
setTimeout(() => iframe.remove?.(), 1000);
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
if (iframe.contentWindow) {
|
|
880
|
+
iframe.contentWindow.onload = triggerPrint;
|
|
881
|
+
setTimeout(triggerPrint, 250);
|
|
882
|
+
} else {
|
|
883
|
+
iframe.remove?.();
|
|
884
|
+
}
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
|
|
809
888
|
/** Whether the drawing is on the page and not hidden (in a tab not selected, say). */
|
|
810
889
|
isShown() {
|
|
811
890
|
const host = this.drawingHost();
|
|
@@ -1404,6 +1483,7 @@ export class DiagramEditor {
|
|
|
1404
1483
|
// A line that is only drawn (edge.editable false) takes no clicks; it still shows its tooltip.
|
|
1405
1484
|
if (edge.editable !== false) hitArea.addEventListener('click', (e) => {
|
|
1406
1485
|
e.stopPropagation();
|
|
1486
|
+
if (this.formatPainter) { this.formatPainterClick(edge.id); return; }
|
|
1407
1487
|
this.selectEdge(edge.id);
|
|
1408
1488
|
});
|
|
1409
1489
|
if (edge.editable !== false) hitArea.addEventListener('dblclick', (e) => {
|
|
@@ -2339,8 +2419,10 @@ export class DiagramEditor {
|
|
|
2339
2419
|
deleteTemplatesCommand(templateIds) {
|
|
2340
2420
|
const ids = new Set(templateIds);
|
|
2341
2421
|
const lines = this.getAllEdges().filter(e => e.editable !== false && (ids.has(e.sourceId) || ids.has(e.targetId)));
|
|
2422
|
+
const unnestSteps = [...ids].flatMap(id => this.containedIn(id).filter(cid => !ids.has(cid)).map(cid => this.nestCommand(cid, null))).filter(Boolean);
|
|
2342
2423
|
return new CompositeCommand(this.deleteLabel([...ids]), [
|
|
2343
2424
|
...lines.map(edge => this.deleteEdgeCommand(edge.id)),
|
|
2425
|
+
...unnestSteps,
|
|
2344
2426
|
...[...ids].map(id => this.removeTemplateCommand(id)),
|
|
2345
2427
|
]);
|
|
2346
2428
|
}
|
|
@@ -2635,26 +2717,55 @@ export class DiagramEditor {
|
|
|
2635
2717
|
// ===== Format painter =====
|
|
2636
2718
|
|
|
2637
2719
|
/**
|
|
2638
|
-
* Pick up a shape's
|
|
2639
|
-
*
|
|
2720
|
+
* Pick up a shape's or line's format for pasteFormat and the format painter.
|
|
2721
|
+
* Returns it, or null.
|
|
2640
2722
|
*/
|
|
2641
|
-
copyFormat(
|
|
2642
|
-
const template = this.getTemplate(
|
|
2643
|
-
if (
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2723
|
+
copyFormat(elementId) {
|
|
2724
|
+
const template = this.getTemplate(elementId);
|
|
2725
|
+
if (template) {
|
|
2726
|
+
this.copiedFormat = { ...template.format(), _kind: 'shape' };
|
|
2727
|
+
this._log?.(`Copied the format of ${template.name || 'the shape'}`);
|
|
2728
|
+
return this.copiedFormat;
|
|
2729
|
+
}
|
|
2730
|
+
const edge = this.getEdge(elementId);
|
|
2731
|
+
if (edge) {
|
|
2732
|
+
this.copiedFormat = { ...edge.format(), _kind: 'edge' };
|
|
2733
|
+
this._log?.(`Copied the line format of ${edge.label || 'connection'}`);
|
|
2734
|
+
return this.copiedFormat;
|
|
2735
|
+
}
|
|
2736
|
+
return null;
|
|
2647
2737
|
}
|
|
2648
2738
|
|
|
2649
2739
|
/**
|
|
2650
|
-
* Give shapes the copied format
|
|
2651
|
-
*
|
|
2652
|
-
* copied; a locked shape stops it.
|
|
2740
|
+
* Give shapes or lines the copied format, as one undo step. Null when
|
|
2741
|
+
* nothing changes or none is copied; a locked shape stops it.
|
|
2653
2742
|
*/
|
|
2654
|
-
pasteFormat(
|
|
2743
|
+
pasteFormat(elementIds = this.getSelectedIds()) {
|
|
2655
2744
|
const format = this.copiedFormat;
|
|
2656
|
-
|
|
2657
|
-
|
|
2745
|
+
if (!format) return null;
|
|
2746
|
+
const ids = Array.isArray(elementIds) ? elementIds : [elementIds];
|
|
2747
|
+
const isEdgeFormat = format._kind === 'edge' || format.routing !== undefined || format.lineStyle !== undefined || format.startMarker !== undefined || format.endMarker !== undefined;
|
|
2748
|
+
|
|
2749
|
+
if (isEdgeFormat) {
|
|
2750
|
+
const edges = ids.map(id => this.getEdge(id)).filter(Boolean);
|
|
2751
|
+
if (!edges.length) return null;
|
|
2752
|
+
const steps = edges.map(edge => {
|
|
2753
|
+
const own = edge.format();
|
|
2754
|
+
const patch = Object.fromEntries(
|
|
2755
|
+
(edge.constructor.formatKeys ?? EDGE_STYLE_PROPERTIES.map(p => p.key))
|
|
2756
|
+
.filter(k => k in format && own[k] !== format[k])
|
|
2757
|
+
.map(k => [k, format[k]])
|
|
2758
|
+
);
|
|
2759
|
+
return Object.keys(patch).length ? new SetStyleCommand(edge, patch, 'Paste line format') : null;
|
|
2760
|
+
}).filter(Boolean);
|
|
2761
|
+
if (!steps.length) return null;
|
|
2762
|
+
const cmd = steps.length === 1 ? steps[0] : new CompositeCommand('Paste line format', steps);
|
|
2763
|
+
this._executeCommand(cmd);
|
|
2764
|
+
return cmd;
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
const templates = ids.map(id => this.getTemplate(id)).filter(Boolean);
|
|
2768
|
+
if (!templates.length) return null;
|
|
2658
2769
|
const locked = templates.find(t => this.isFixed(t.id));
|
|
2659
2770
|
if (locked) { this.tellLocked(`"${locked.name || 'A shape'}"`, locked.id); return null; }
|
|
2660
2771
|
const steps = templates.map(t => {
|
|
@@ -2663,40 +2774,46 @@ export class DiagramEditor {
|
|
|
2663
2774
|
return Object.keys(patch).length ? new SetPropertiesCommand(t, patch, 'Paste format') : null;
|
|
2664
2775
|
}).filter(Boolean);
|
|
2665
2776
|
if (!steps.length) return null;
|
|
2666
|
-
const cmd = new CompositeCommand('Paste format', steps);
|
|
2777
|
+
const cmd = steps.length === 1 ? steps[0] : new CompositeCommand('Paste format', steps);
|
|
2667
2778
|
this._executeCommand(cmd);
|
|
2668
2779
|
return cmd;
|
|
2669
2780
|
}
|
|
2670
2781
|
|
|
2671
2782
|
/**
|
|
2672
2783
|
* Turn the format painter on or off (Archi's Format Painter). While on, a
|
|
2673
|
-
* click on a shape picks up its format when none is held, and gives
|
|
2674
|
-
* held one to each shape clicked after; Esc or the 🖌 button
|
|
2675
|
-
*
|
|
2784
|
+
* click on a shape or line picks up its format when none is held, and gives
|
|
2785
|
+
* the held one to each shape or line clicked after; Esc or the 🖌 button
|
|
2786
|
+
* turns it off.
|
|
2676
2787
|
*/
|
|
2677
2788
|
setFormatPainter(on) {
|
|
2678
2789
|
this.formatPainter = !!on;
|
|
2679
2790
|
if (on) {
|
|
2680
2791
|
this.copiedFormat = null;
|
|
2681
|
-
if (this.selectedTemplateId)
|
|
2792
|
+
if (this.selectedTemplateId) {
|
|
2793
|
+
this.copyFormat(this.selectedTemplateId);
|
|
2794
|
+
} else if (this.selectedEdgeId) {
|
|
2795
|
+
this.copyFormat(this.selectedEdgeId);
|
|
2796
|
+
}
|
|
2682
2797
|
}
|
|
2683
2798
|
const host = this.drawingHost?.();
|
|
2684
2799
|
if (host?.style) host.style.cursor = on ? 'copy' : '';
|
|
2685
|
-
this._log?.(on ? 'Format painter: click shapes to give them the format' : 'Format painter off');
|
|
2800
|
+
this._log?.(on ? 'Format painter: click shapes or lines to give them the format' : 'Format painter off');
|
|
2686
2801
|
}
|
|
2687
2802
|
|
|
2688
|
-
/** A click on a shape while the format painter is on. @protected */
|
|
2689
|
-
formatPainterClick(
|
|
2690
|
-
if (!this.copiedFormat) { this.copyFormat(
|
|
2691
|
-
return this.pasteFormat([
|
|
2803
|
+
/** A click on a shape or line while the format painter is on. @protected */
|
|
2804
|
+
formatPainterClick(elementId) {
|
|
2805
|
+
if (!this.copiedFormat) { this.copyFormat(elementId); return null; }
|
|
2806
|
+
return this.pasteFormat([elementId]);
|
|
2692
2807
|
}
|
|
2693
2808
|
|
|
2694
|
-
/** Copy format, and Paste format when one is held
|
|
2809
|
+
/** Copy format, and Paste format when one is held. */
|
|
2695
2810
|
formatMenuItems(elementId) {
|
|
2696
|
-
const
|
|
2811
|
+
const isEdge = !!this.getEdge(elementId);
|
|
2812
|
+
const targets = () => (isEdge ? [elementId] : (this.selectedIds.has(elementId) ? this.getSelectedIds() : [elementId]));
|
|
2813
|
+
const hasMatchingFormat = this.copiedFormat && (isEdge ? this.copiedFormat._kind === 'edge' : this.copiedFormat._kind !== 'edge');
|
|
2697
2814
|
return [
|
|
2698
2815
|
{ label: '🖌 Copy format', action: () => this.copyFormat(elementId) },
|
|
2699
|
-
...(
|
|
2816
|
+
...(hasMatchingFormat ? [{ label: 'Paste format', action: () => this.pasteFormat(targets()) }] : []),
|
|
2700
2817
|
];
|
|
2701
2818
|
}
|
|
2702
2819
|
|
|
@@ -3545,24 +3662,42 @@ export class DiagramEditor {
|
|
|
3545
3662
|
return [...bounds].map(([id, b]) => ({ id, bounds: b }));
|
|
3546
3663
|
}
|
|
3547
3664
|
|
|
3548
|
-
/** Whether a shape
|
|
3549
|
-
|
|
3665
|
+
/** Whether a shape is an ancestor of another in nesting containment hierarchy. */
|
|
3666
|
+
isAncestor(ancestorId, descendantId) {
|
|
3667
|
+
let curr = this.parentOf(descendantId);
|
|
3668
|
+
while (curr) {
|
|
3669
|
+
if (curr === ancestorId) return true;
|
|
3670
|
+
curr = this.parentOf(curr);
|
|
3671
|
+
}
|
|
3550
3672
|
return false;
|
|
3551
3673
|
}
|
|
3552
3674
|
|
|
3553
|
-
/**
|
|
3675
|
+
/** Whether a shape dropped on another (its centre on it, sticking out) nests there. */
|
|
3676
|
+
nestsOnDrop(parentId, childId) {
|
|
3677
|
+
return typeof this.canHold === 'function' ? this.canHold(parentId, childId) : false;
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3680
|
+
/** The shape this one is drawn inside (nested in), or null. */
|
|
3554
3681
|
parentOf(templateId) {
|
|
3555
|
-
|
|
3682
|
+
const t = this.getTemplate(templateId);
|
|
3683
|
+
return t?.parentId ?? null;
|
|
3556
3684
|
}
|
|
3557
3685
|
|
|
3558
|
-
/** Whether a shape can be nested in another.
|
|
3686
|
+
/** Whether a shape can be nested in another. */
|
|
3559
3687
|
canHold(parentId, childId) {
|
|
3560
|
-
return false;
|
|
3688
|
+
if (!parentId || !childId || parentId === childId) return false;
|
|
3689
|
+
const parent = this.getTemplate(parentId);
|
|
3690
|
+
const child = this.getTemplate(childId);
|
|
3691
|
+
if (!parent || !child) return false;
|
|
3692
|
+
const isContainer = Boolean(parent.isContainer || parent.constructor.isContainer);
|
|
3693
|
+
if (!isContainer) return false;
|
|
3694
|
+
if (this.isAncestor(childId, parentId)) return false;
|
|
3695
|
+
return true;
|
|
3561
3696
|
}
|
|
3562
3697
|
|
|
3563
3698
|
/** The command that nests a shape in another (or, with null, takes it out); null when it can't. */
|
|
3564
3699
|
nestCommand(childId, parentId) {
|
|
3565
|
-
return
|
|
3700
|
+
return new NestElementCommand(this, childId, parentId);
|
|
3566
3701
|
}
|
|
3567
3702
|
|
|
3568
3703
|
/** After a move nested shapes or took them out (nestingChanges' list): a subclass's follow-up. None here. */
|
|
@@ -235,3 +235,31 @@ export class SetPropertiesCommand extends Command {
|
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* NestElementCommand — nest a child element in a parent container (or set parent to null).
|
|
241
|
+
* Works on any DiagramEditor or state with getTemplate/parentOf.
|
|
242
|
+
*/
|
|
243
|
+
export class NestElementCommand extends Command {
|
|
244
|
+
constructor(editor, childId, parentId) {
|
|
245
|
+
super(parentId ? 'Nest' : 'Unnest');
|
|
246
|
+
this.editor = editor;
|
|
247
|
+
this.childId = childId;
|
|
248
|
+
this.parentId = parentId;
|
|
249
|
+
this.previousParentId = typeof editor.parentOf === 'function' ? editor.parentOf(childId) : (editor.getTemplate(childId)?.parentId ?? null);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
execute() {
|
|
253
|
+
const child = this.editor.getTemplate(this.childId);
|
|
254
|
+
if (child) {
|
|
255
|
+
child.parentId = this.parentId;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
undo() {
|
|
260
|
+
const child = this.editor.getTemplate(this.childId);
|
|
261
|
+
if (child) {
|
|
262
|
+
child.parentId = this.previousParentId;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
package/src/charting/Edge.js
CHANGED
|
@@ -50,6 +50,8 @@ export class Edge {
|
|
|
50
50
|
return true;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
static formatKeys = EDGE_STYLE_PROPERTIES.map(p => p.key);
|
|
54
|
+
|
|
53
55
|
/** The style properties this line sets for itself (the properties dialog offers these): all of them here. */
|
|
54
56
|
get styleProperties() {
|
|
55
57
|
return EDGE_STYLE_PROPERTIES;
|
|
@@ -65,6 +67,11 @@ export class Edge {
|
|
|
65
67
|
return Object.fromEntries(EDGE_STYLE_PROPERTIES.filter(p => this[p.key] !== undefined).map(p => [p.key, this[p.key]]));
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
/** Picked-up line format for DiagramEditor#formatPainter. */
|
|
71
|
+
format() {
|
|
72
|
+
return this.styleOverrides();
|
|
73
|
+
}
|
|
74
|
+
|
|
68
75
|
/**
|
|
69
76
|
* Create a copy of this edge; waypoints are copied, not shared.
|
|
70
77
|
*/
|
|
@@ -173,6 +173,120 @@ export class Hierarchy {
|
|
|
173
173
|
return result;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Pack a list of circles with unscaled `radii` around the origin using a
|
|
178
|
+
* deterministic tangent-spiral placement, then normalize them into a unit
|
|
179
|
+
* enclosing disc `(0, 0, 1)`.
|
|
180
|
+
* @param {number[]} radii
|
|
181
|
+
* @param {number} [gap=0.04]
|
|
182
|
+
* @returns {Array<{x: number, y: number, r: number}>}
|
|
183
|
+
*/
|
|
184
|
+
static packSiblingCircles(radii, gap = 0.04) {
|
|
185
|
+
if (!radii.length) return [];
|
|
186
|
+
if (radii.length === 1) return [{ x: 0, y: 0, r: 1 }];
|
|
187
|
+
|
|
188
|
+
const placed = [];
|
|
189
|
+
for (let i = 0; i < radii.length; i++) {
|
|
190
|
+
const r = radii[i];
|
|
191
|
+
if (i === 0) {
|
|
192
|
+
placed.push({ x: 0, y: 0, r });
|
|
193
|
+
} else if (i === 1) {
|
|
194
|
+
placed.push({ x: placed[0].r + r + gap, y: 0, r });
|
|
195
|
+
} else {
|
|
196
|
+
// Find the position closest to the centroid that does not overlap
|
|
197
|
+
// any already-placed circle, sampling tangent candidates around
|
|
198
|
+
// existing circles along golden-angle / angular rays.
|
|
199
|
+
let bestX = 0, bestY = 0, bestDist = Infinity;
|
|
200
|
+
const steps = 36;
|
|
201
|
+
for (let s = 0; s < steps; s++) {
|
|
202
|
+
const angle = (s / steps) * Math.PI * 2 + i * 0.37;
|
|
203
|
+
const ux = Math.cos(angle), uy = Math.sin(angle);
|
|
204
|
+
let minProj = 0;
|
|
205
|
+
for (const p of placed) {
|
|
206
|
+
const minSep = p.r + r + gap;
|
|
207
|
+
// Solve |(t*ux - p.x)^2 + (t*uy - p.y)^2| = minSep^2
|
|
208
|
+
const b = ux * p.x + uy * p.y;
|
|
209
|
+
const c = p.x * p.x + p.y * p.y - minSep * minSep;
|
|
210
|
+
const disc = b * b - c;
|
|
211
|
+
if (disc > 0) {
|
|
212
|
+
const t = b + Math.sqrt(disc);
|
|
213
|
+
if (t > minProj) minProj = t;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (minProj < bestDist) {
|
|
217
|
+
bestDist = minProj;
|
|
218
|
+
bestX = ux * minProj;
|
|
219
|
+
bestY = uy * minProj;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
placed.push({ x: bestX, y: bestY, r });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Compute bounding circle center and radius, then normalize to radius 1.
|
|
227
|
+
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
228
|
+
for (const p of placed) {
|
|
229
|
+
if (p.x - p.r < minX) minX = p.x - p.r;
|
|
230
|
+
if (p.x + p.r > maxX) maxX = p.x + p.r;
|
|
231
|
+
if (p.y - p.r < minY) minY = p.y - p.r;
|
|
232
|
+
if (p.y + p.r > maxY) maxY = p.y + p.r;
|
|
233
|
+
}
|
|
234
|
+
const midX = (minX + maxX) / 2;
|
|
235
|
+
const midY = (minY + maxY) / 2;
|
|
236
|
+
let boundR = 0;
|
|
237
|
+
for (const p of placed) {
|
|
238
|
+
const d = Math.hypot(p.x - midX, p.y - midY) + p.r;
|
|
239
|
+
if (d > boundR) boundR = d;
|
|
240
|
+
}
|
|
241
|
+
boundR = boundR || 1;
|
|
242
|
+
return placed.map(p => ({
|
|
243
|
+
x: (p.x - midX) / boundR,
|
|
244
|
+
y: (p.y - midY) / boundR,
|
|
245
|
+
r: p.r / boundR,
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Recursive nested-circle layout over a hierarchy node tree.
|
|
251
|
+
* @returns {Array<{node: object, cx: number, cy: number, r: number, depth: number, isLeaf: boolean, topAncestor: object}>}
|
|
252
|
+
*/
|
|
253
|
+
static circlePackLayout(nodes, cx, cy, radius, depth = 0, topAncestors = null, padding = 3) {
|
|
254
|
+
const items = (nodes ?? [])
|
|
255
|
+
.map(node => ({ node, value: Hierarchy.value(node) }))
|
|
256
|
+
.filter(it => it.value > 0)
|
|
257
|
+
.sort((a, b) => b.value - a.value);
|
|
258
|
+
if (!items.length || radius <= 0) return [];
|
|
259
|
+
|
|
260
|
+
const maxVal = items[0].value || 1;
|
|
261
|
+
const rawRadii = items.map(it => Math.sqrt(it.value / maxVal));
|
|
262
|
+
const unitCircles = Hierarchy.packSiblingCircles(rawRadii, 0.06);
|
|
263
|
+
const usableRadius = Math.max(1, radius - padding);
|
|
264
|
+
|
|
265
|
+
const result = [];
|
|
266
|
+
for (let i = 0; i < items.length; i++) {
|
|
267
|
+
const { node, value } = items[i];
|
|
268
|
+
const u = unitCircles[i];
|
|
269
|
+
const nodeCx = cx + u.x * usableRadius;
|
|
270
|
+
const nodeCy = cy + u.y * usableRadius;
|
|
271
|
+
const nodeR = Math.max(1.5, u.r * usableRadius - padding * 0.5);
|
|
272
|
+
const isLeaf = !node.children?.length;
|
|
273
|
+
const topAncestor = topAncestors ? topAncestors.get(node) : node;
|
|
274
|
+
|
|
275
|
+
result.push({ node, value, cx: nodeCx, cy: nodeCy, r: nodeR, depth, isLeaf, topAncestor });
|
|
276
|
+
if (!isLeaf && nodeR > 8) {
|
|
277
|
+
const childTopAncestors = new Map(node.children.map(c => [c, topAncestor]));
|
|
278
|
+
// Reserve a small top band for the branch label when large enough
|
|
279
|
+
const labelReserve = nodeR > 26 ? Math.min(10, nodeR * 0.16) : 0;
|
|
280
|
+
const innerR = Math.max(2, nodeR - labelReserve * 0.65 - padding);
|
|
281
|
+
const innerCy = nodeCy + labelReserve * 0.35;
|
|
282
|
+
result.push(...Hierarchy.circlePackLayout(
|
|
283
|
+
node.children, nodeCx, innerCy, innerR, depth + 1, childTopAncestors, Math.max(1.5, padding * 0.75)
|
|
284
|
+
));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
176
290
|
/**
|
|
177
291
|
* Flat, pre-order list of *visible* rows for an outline view. A branch
|
|
178
292
|
* present in `collapsed` contributes its own row but none of its
|
|
@@ -594,6 +594,10 @@ export class InteractionManager {
|
|
|
594
594
|
|
|
595
595
|
const onMouseMove = (event) => {
|
|
596
596
|
const hitShape = engine.hitTest(event.clientX, event.clientY);
|
|
597
|
+
if (engine.renderer?.type === 'canvas' && engine.renderer.canvas) {
|
|
598
|
+
const hasClick = hitShape && (hitShape.legendItem || typeof engine._activeChart?.options?.onDatumClick === 'function');
|
|
599
|
+
engine.renderer.canvas.style.cursor = (hitShape?.tooltip || hasClick) ? 'pointer' : 'default';
|
|
600
|
+
}
|
|
597
601
|
if (hitShape && hitShape.tooltip) {
|
|
598
602
|
tooltip.show(hitShape.tooltip, event.clientX, event.clientY);
|
|
599
603
|
} else {
|
|
@@ -84,7 +84,7 @@ export class ModelFileFormat {
|
|
|
84
84
|
* @returns {Array<Object>} menu items (with separators between formats)
|
|
85
85
|
*/
|
|
86
86
|
export function modelFileMenuItems(formats, {
|
|
87
|
-
model, onImport, enabled = () => true, document: doc = () => globalThis.document,
|
|
87
|
+
model, onImport, onMerge = null, enabled = () => true, document: doc = () => globalThis.document,
|
|
88
88
|
download = null, chooseFile = null, report = null, only = null,
|
|
89
89
|
} = {}) {
|
|
90
90
|
const save = download ?? (({ data, mimeType }, fileName) => downloadBlob(new (doc().defaultView ?? globalThis).Blob([data], { type: mimeType }), fileName));
|
|
@@ -96,6 +96,10 @@ export function modelFileMenuItems(formats, {
|
|
|
96
96
|
label: only ? `${format.label}…` : `Import ${format.label}…`,
|
|
97
97
|
action: () => importFile(format, { choose, onImport, report: tell }),
|
|
98
98
|
},
|
|
99
|
+
only !== 'export' && format.imports && onMerge && enabled(format, 'merge') && {
|
|
100
|
+
label: only ? `${format.label} (merge)…` : `Merge ${format.label}…`,
|
|
101
|
+
action: () => importFile(format, { choose, onImport: onMerge, report: tell }),
|
|
102
|
+
},
|
|
99
103
|
].filter(Boolean)).filter(group => group.length);
|
|
100
104
|
if (only) return groups.flat();
|
|
101
105
|
return groups.flatMap((group, i) => (i ? [{ separator: true }, ...group] : group));
|