@peter.prib/renderengine 0.13.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.
Files changed (124) hide show
  1. package/README.md +480 -302
  2. package/bin/renderengine.mjs +157 -0
  3. package/dist/renderengine-workspace.umd.js +49217 -37484
  4. package/dist/renderengine.umd.js +24899 -13093
  5. package/documentation/REFERENCE.md +68 -1
  6. package/package.json +13 -3
  7. package/src/CanvasRenderer.js +225 -200
  8. package/src/SVGRenderer.js +476 -454
  9. package/src/archimate.js +22 -2
  10. package/src/archimateArchiFormat.js +1735 -1701
  11. package/src/archimateArchive.js +36 -7
  12. package/src/archimateCSV.js +93 -37
  13. package/src/archimateDrawio.js +2 -0
  14. package/src/archimateExchangeFormat.js +3 -1
  15. package/src/chartCatalog.js +269 -3
  16. package/src/charting/Annotations.js +169 -0
  17. package/src/charting/ArchimateDiagramEditor.js +54 -10
  18. package/src/charting/Axis.js +217 -5
  19. package/src/charting/Chart.js +99 -9
  20. package/src/charting/Chart3D.js +7 -2
  21. package/src/charting/ChartBox.js +8 -1
  22. package/src/charting/ColorPalette.js +42 -4
  23. package/src/charting/DiagramEditor.js +174 -39
  24. package/src/charting/DiagramEditorCommands.js +28 -0
  25. package/src/charting/Edge.js +7 -0
  26. package/src/charting/Hierarchy.js +142 -0
  27. package/src/charting/InteractionManager.js +4 -0
  28. package/src/charting/ModelFileFormat.js +5 -1
  29. package/src/charting/SettingsManager.js +35 -0
  30. package/src/charting/ShapePaths.js +82 -10
  31. package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
  32. package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
  33. package/src/charting/ask/AskBar.js +388 -0
  34. package/src/charting/ask/AskView.js +400 -0
  35. package/src/charting/charts/AlluvialDiagram.js +311 -0
  36. package/src/charting/charts/AreaChart.js +69 -15
  37. package/src/charting/charts/BarChart.js +38 -3
  38. package/src/charting/charts/BarChart3D.js +6 -4
  39. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  40. package/src/charting/charts/BarChartBase.js +26 -6
  41. package/src/charting/charts/BeeswarmChart.js +164 -0
  42. package/src/charting/charts/BoxPlotChart.js +25 -3
  43. package/src/charting/charts/BubbleChart.js +4 -1
  44. package/src/charting/charts/CalendarHeatmapChart.js +2 -1
  45. package/src/charting/charts/CandlestickChart.js +172 -4
  46. package/src/charting/charts/ChordDiagram.js +107 -4
  47. package/src/charting/charts/CirclePackChart.js +81 -0
  48. package/src/charting/charts/ComboChart.js +233 -0
  49. package/src/charting/charts/ContourChart.js +310 -0
  50. package/src/charting/charts/DensityChart.js +1 -1
  51. package/src/charting/charts/DonutChart3D.js +13 -0
  52. package/src/charting/charts/DumbbellChart.js +217 -0
  53. package/src/charting/charts/EcdfChart.js +150 -0
  54. package/src/charting/charts/FunnelChart.js +5 -2
  55. package/src/charting/charts/GeoMapChart.js +132 -11
  56. package/src/charting/charts/GroupedBarChart.js +44 -3
  57. package/src/charting/charts/GroupedDistributionChart.js +26 -1
  58. package/src/charting/charts/HeatmapChart.js +6 -3
  59. package/src/charting/charts/HexbinChart.js +137 -0
  60. package/src/charting/charts/HierarchyChart.js +77 -3
  61. package/src/charting/charts/HistogramChart.js +2 -2
  62. package/src/charting/charts/HorizonChart.js +105 -0
  63. package/src/charting/charts/IcicleChart.js +47 -0
  64. package/src/charting/charts/KagiChart.js +167 -0
  65. package/src/charting/charts/LineChart.js +74 -18
  66. package/src/charting/charts/LollipopChart.js +104 -0
  67. package/src/charting/charts/MarimekkoChart.js +75 -0
  68. package/src/charting/charts/OhlcChart.js +79 -0
  69. package/src/charting/charts/OutlineChart.js +10 -8
  70. package/src/charting/charts/ParallelCoordinatesChart.js +11 -3
  71. package/src/charting/charts/ParetoChart.js +92 -0
  72. package/src/charting/charts/PieChart.js +195 -17
  73. package/src/charting/charts/PieChart3D.js +137 -0
  74. package/src/charting/charts/PipeChart.js +5 -2
  75. package/src/charting/charts/PointAndFigureChart.js +198 -0
  76. package/src/charting/charts/PolarAreaChart.js +189 -0
  77. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  78. package/src/charting/charts/RadarChart.js +12 -5
  79. package/src/charting/charts/RadialBarChart.js +6 -3
  80. package/src/charting/charts/RenkoChart.js +178 -0
  81. package/src/charting/charts/RidgelineChart.js +113 -0
  82. package/src/charting/charts/SankeyDiagram.js +129 -6
  83. package/src/charting/charts/ScatterChart.js +19 -3
  84. package/src/charting/charts/ScatterMatrixChart.js +119 -0
  85. package/src/charting/charts/SlopegraphChart.js +205 -0
  86. package/src/charting/charts/SparklineTableChart.js +166 -0
  87. package/src/charting/charts/StackedBarChart.js +125 -11
  88. package/src/charting/charts/StackedBarChart3DOrbit.js +14 -6
  89. package/src/charting/charts/StreamGraphChart.js +18 -3
  90. package/src/charting/charts/SunburstChart.js +28 -8
  91. package/src/charting/charts/SurfaceChart3D.js +157 -0
  92. package/src/charting/charts/TreemapChart.js +31 -11
  93. package/src/charting/charts/ViolinChart.js +20 -2
  94. package/src/charting/charts/WaterfallChart.js +2 -2
  95. package/src/charting/charts/WordCloudChart.js +2 -2
  96. package/src/charting/datamodel/ArchimateDataLink.js +70 -0
  97. package/src/charting/datamodel/DataLineage.js +150 -0
  98. package/src/charting/datamodel/DataModel.js +83 -1
  99. package/src/charting/datamodel/ModelMapping.js +87 -0
  100. package/src/charting/datamodel/SqlDdl.js +110 -85
  101. package/src/charting/datamodel/index.js +3 -0
  102. package/src/charting/decision/DecisionWorkbench.js +295 -0
  103. package/src/charting/diagrams/ArchimateDiagram.js +25 -9
  104. package/src/charting/diagrams/ArchimateModel.js +1382 -1238
  105. package/src/charting/diagrams/GraphDiagram.js +1424 -1350
  106. package/src/charting/diagrams/SketchView.js +121 -0
  107. package/src/charting/diagrams/sketchBoxKinds.js +30 -0
  108. package/src/charting/modelTree/AnalysisPanel.js +101 -23
  109. package/src/charting/modelTree/NavigatorModel.js +410 -0
  110. package/src/charting/modelTree/NavigatorPanel.js +453 -0
  111. package/src/charting/pivot/PivotChart.js +310 -13
  112. package/src/charting/pivot/PivotGridChart.js +322 -34
  113. package/src/charting/pivot/PivotView.js +626 -21
  114. package/src/charting/report/TemplatedReport.js +547 -0
  115. package/src/charting/templates/BoxTemplate.js +28 -27
  116. package/src/charting/templates/PackageTemplate.js +26 -25
  117. package/src/charting/templates/SwimlaneTemplate.js +19 -18
  118. package/src/charting/templates/Template.js +219 -212
  119. package/src/cli.js +146 -0
  120. package/src/index.js +3370 -2899
  121. package/src/workspace/Workspace.js +126 -7
  122. package/src/workspace/archimateServices.js +4 -0
  123. package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
  124. package/src/workspace/styles.js +36 -1
@@ -72,8 +72,9 @@ export class Chart3D extends Chart {
72
72
 
73
73
  /**
74
74
  * Draw one 3D box through the orbit camera. Returns average corner depth.
75
+ * When `datum` is supplied (`{ label, series? }`), each face is recorded via `_datum()`.
75
76
  */
76
- drawBox(renderer, x0, x1, y0, y1, z0, z1, color, toScreen) {
77
+ drawBox(renderer, x0, x1, y0, y1, z0, z1, color, toScreen, datum = null) {
77
78
  const camera = this.camera;
78
79
  const faceXSign = Math.sin(camera.theta) >= 0 ? 1 : -1;
79
80
  const faceZSign = Math.cos(camera.theta) >= 0 ? 1 : -1;
@@ -85,13 +86,17 @@ export class Chart3D extends Chart {
85
86
  const xSide = [c(xf, y0, z0), c(xf, y1, z0), c(xf, y1, z1), c(xf, y0, z1)];
86
87
  const zSide = [c(x0, y0, zf), c(x1, y0, zf), c(x1, y1, zf), c(x0, y1, zf)];
87
88
 
89
+ const faceEls = [];
88
90
  const drawFace = (pts, shade) => {
89
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`;
90
- renderer.path(d, ColorPalette.lighten(color, shade), { color: '#333', width: 1 });
92
+ const el = renderer.path(d, ColorPalette.lighten(color, shade), { color: '#333', width: 1 });
93
+ if (datum) this._datum(el, datum);
94
+ if (el) faceEls.push(el);
91
95
  };
92
96
  drawFace(top, 0.15);
93
97
  drawFace(xSide, faceXSign > 0 ? -0.05 : -0.2);
94
98
  drawFace(zSide, faceZSign > 0 ? -0.1 : -0.25);
99
+ this._wireHoverTransform(faceEls, { dx: 0, dy: -5 });
95
100
 
96
101
  const allDepths = [...top, ...xSide, ...zSide].map(p => p.depth);
97
102
  return allDepths.reduce((s, d) => s + d, 0) / allDepths.length;
@@ -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
- static scaleColor(stops, vMin, vMax, v) {
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
- return [];
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 format (Template#format: paint and text style) for
2639
- * pasteFormat and the format painter. Returns it, or null.
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(templateId) {
2642
- const template = this.getTemplate(templateId);
2643
- if (!template) return null;
2644
- this.copiedFormat = template.format();
2645
- this._log?.(`Copied the format of ${template.name || 'the shape'}`);
2646
- return this.copiedFormat;
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 (each the keys it has among its
2651
- * `formatKeys`), as one undo step. Null when nothing changes or none is
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(templateIds = this.getSelectedIds()) {
2743
+ pasteFormat(elementIds = this.getSelectedIds()) {
2655
2744
  const format = this.copiedFormat;
2656
- const templates = templateIds.map(id => this.getTemplate(id)).filter(Boolean);
2657
- if (!format || !templates.length) return null;
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 the
2674
- * held one to each shape clicked after; Esc or the 🖌 button turns it off.
2675
- * Turned on with a shape selected, it starts with that shape's format.
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) this.copyFormat(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(templateId) {
2690
- if (!this.copiedFormat) { this.copyFormat(templateId); return null; }
2691
- return this.pasteFormat([templateId]);
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 (to the selection, when the shape is part of it). */
2809
+ /** Copy format, and Paste format when one is held. */
2695
2810
  formatMenuItems(elementId) {
2696
- const targets = () => (this.selectedIds.has(elementId) ? this.getSelectedIds() : [elementId]);
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
- ...(this.copiedFormat ? [{ label: 'Paste format', action: () => this.pasteFormat(targets()) }] : []),
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 dropped on another (its centre on it, sticking out) nests there. Never here; a subclass says when. */
3549
- nestsOnDrop(parentId, childId) {
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
- /** The shape this one is drawn inside (nested in), or null. None here: a subclass with nesting says. */
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
- return null;
3682
+ const t = this.getTemplate(templateId);
3683
+ return t?.parentId ?? null;
3556
3684
  }
3557
3685
 
3558
- /** Whether a shape can be nested in another. Never here; a subclass with nesting says when. */
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 null;
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
+ }
@@ -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
  */
@@ -145,6 +145,148 @@ 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
+
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
+
148
290
  /**
149
291
  * Flat, pre-order list of *visible* rows for an outline view. A branch
150
292
  * present in `collapsed` contributes its own row but none of its