@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.
Files changed (109) hide show
  1. package/README.md +45 -19
  2. package/bin/renderengine.mjs +157 -0
  3. package/dist/renderengine-workspace.umd.js +49391 -39748
  4. package/dist/renderengine.umd.js +25366 -15650
  5. package/documentation/REFERENCE.md +38 -0
  6. package/package.json +6 -1
  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 +219 -0
  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 +3 -0
  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 +114 -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/charts/AlluvialDiagram.js +311 -0
  34. package/src/charting/charts/AreaChart.js +52 -15
  35. package/src/charting/charts/BarChart.js +38 -3
  36. package/src/charting/charts/BarChart3D.js +5 -3
  37. package/src/charting/charts/BarChartBase.js +26 -6
  38. package/src/charting/charts/BeeswarmChart.js +164 -0
  39. package/src/charting/charts/BoxPlotChart.js +23 -1
  40. package/src/charting/charts/CalendarHeatmapChart.js +2 -1
  41. package/src/charting/charts/CandlestickChart.js +172 -4
  42. package/src/charting/charts/ChordDiagram.js +105 -2
  43. package/src/charting/charts/CirclePackChart.js +81 -0
  44. package/src/charting/charts/ComboChart.js +233 -0
  45. package/src/charting/charts/ContourChart.js +310 -0
  46. package/src/charting/charts/DonutChart3D.js +13 -0
  47. package/src/charting/charts/DumbbellChart.js +217 -0
  48. package/src/charting/charts/EcdfChart.js +150 -0
  49. package/src/charting/charts/FunnelChart.js +4 -1
  50. package/src/charting/charts/GeoMapChart.js +132 -11
  51. package/src/charting/charts/GroupedBarChart.js +44 -3
  52. package/src/charting/charts/GroupedDistributionChart.js +26 -1
  53. package/src/charting/charts/HeatmapChart.js +2 -2
  54. package/src/charting/charts/HexbinChart.js +137 -0
  55. package/src/charting/charts/HierarchyChart.js +77 -3
  56. package/src/charting/charts/HorizonChart.js +105 -0
  57. package/src/charting/charts/KagiChart.js +167 -0
  58. package/src/charting/charts/LineChart.js +74 -18
  59. package/src/charting/charts/OhlcChart.js +79 -0
  60. package/src/charting/charts/ParallelCoordinatesChart.js +9 -1
  61. package/src/charting/charts/PieChart.js +195 -17
  62. package/src/charting/charts/PieChart3D.js +137 -0
  63. package/src/charting/charts/PointAndFigureChart.js +198 -0
  64. package/src/charting/charts/PolarAreaChart.js +189 -0
  65. package/src/charting/charts/RadarChart.js +7 -1
  66. package/src/charting/charts/RadialBarChart.js +5 -2
  67. package/src/charting/charts/RenkoChart.js +178 -0
  68. package/src/charting/charts/RidgelineChart.js +113 -0
  69. package/src/charting/charts/SankeyDiagram.js +127 -4
  70. package/src/charting/charts/ScatterChart.js +13 -0
  71. package/src/charting/charts/ScatterMatrixChart.js +119 -0
  72. package/src/charting/charts/SlopegraphChart.js +205 -0
  73. package/src/charting/charts/SparklineTableChart.js +166 -0
  74. package/src/charting/charts/StackedBarChart.js +48 -5
  75. package/src/charting/charts/StackedBarChart3DOrbit.js +10 -5
  76. package/src/charting/charts/StreamGraphChart.js +10 -3
  77. package/src/charting/charts/SunburstChart.js +27 -7
  78. package/src/charting/charts/SurfaceChart3D.js +157 -0
  79. package/src/charting/charts/TreemapChart.js +28 -8
  80. package/src/charting/charts/ViolinChart.js +18 -0
  81. package/src/charting/datamodel/ArchimateDataLink.js +70 -0
  82. package/src/charting/datamodel/DataLineage.js +150 -0
  83. package/src/charting/datamodel/DataModel.js +83 -1
  84. package/src/charting/datamodel/ModelMapping.js +87 -0
  85. package/src/charting/datamodel/SqlDdl.js +110 -85
  86. package/src/charting/datamodel/index.js +3 -0
  87. package/src/charting/decision/DecisionWorkbench.js +295 -0
  88. package/src/charting/diagrams/ArchimateDiagram.js +25 -9
  89. package/src/charting/diagrams/ArchimateModel.js +1382 -1238
  90. package/src/charting/diagrams/GraphDiagram.js +1424 -1350
  91. package/src/charting/diagrams/SketchView.js +121 -0
  92. package/src/charting/diagrams/sketchBoxKinds.js +30 -0
  93. package/src/charting/modelTree/AnalysisPanel.js +101 -23
  94. package/src/charting/modelTree/NavigatorModel.js +410 -0
  95. package/src/charting/modelTree/NavigatorPanel.js +453 -0
  96. package/src/charting/pivot/PivotChart.js +109 -4
  97. package/src/charting/pivot/PivotGridChart.js +321 -33
  98. package/src/charting/pivot/PivotView.js +82 -0
  99. package/src/charting/report/TemplatedReport.js +547 -0
  100. package/src/charting/templates/BoxTemplate.js +28 -27
  101. package/src/charting/templates/PackageTemplate.js +26 -25
  102. package/src/charting/templates/SwimlaneTemplate.js +19 -18
  103. package/src/charting/templates/Template.js +219 -212
  104. package/src/cli.js +146 -0
  105. package/src/index.js +3370 -2995
  106. package/src/workspace/Workspace.js +126 -7
  107. package/src/workspace/archimateServices.js +4 -0
  108. package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
  109. package/src/workspace/styles.js +36 -1
@@ -1,1350 +1,1424 @@
1
- import { TextStyle } from '../TextStyle.js';
2
- import { Diagram } from '../Diagram.js';
3
- import { CommandHistory } from '../CommandHistory.js';
4
- import { GraphMetrics } from './GraphMetrics.js';
5
- import { LabelHelper } from './LabelHelper.js';
6
- import { NodeGeometry } from './NodeGeometry.js';
7
- import { SvgCoordinate } from './SvgCoordinate.js';
8
- import { EdgeGeometry } from './EdgeGeometry.js';
9
- import { ContextMenu } from './ContextMenu.js';
10
- import { GridOverlay } from './GridOverlay.js';
11
- import { LINE_STYLE_DASH } from '../edgeStyle/EdgeStyleSchema.js';
12
- import {
13
- AddNodeCommand, DeleteNodeCommand,
14
- AddEdgeCommand, DeleteEdgeCommand, InvertEdgeCommand,
15
- RenameNodeCommand, RenameEdgeCommand,
16
- AddWaypointCommand,
17
- } from './commands/index.js';
18
-
19
- /**
20
- * GraphDiagram — renders an interactive 2D node/edge graph diagram.
21
- */
22
-
23
- /** Marks a selected line's end and bend-point handles (removed when the line is deselected). */
24
- const EDGE_HANDLE_ATTR = 'data-edge-handle';
25
-
26
- export class GraphDiagram extends Diagram {
27
- /**
28
- * Pure. Whether a node without a picture shows centred text: its icon text,
29
- * or (standing in for a name drawn below the shape) its initials. A node
30
- * whose name is written inside it needs no initials.
31
- */
32
- static showsInitials(node, defaultLabelPosition = 'below') {
33
- if (node.icon) return true;
34
- return (node.labelPosition || (node.shape === 'note' ? 'inside' : defaultLabelPosition)) !== 'inside';
35
- }
36
-
37
- static diagramType = 'graph';
38
-
39
- constructor(renderer, nodes, edges, options = {}, engine = null, folders = []) {
40
- super(renderer, { nodes, edges, folders }, options);
41
- this.engine = engine;
42
- this._chartState = { type: 'graph', nodes, edges, folders, options };
43
- this._graph = null;
44
- this._graphCollapsedNodes = new Set();
45
- this._graphFilterKeyword = '';
46
- this._graphFixedPos = new Set();
47
- this._hiddenGraphNodes = new Set();
48
- this._hiddenGraphGroups = new Set();
49
- this._collapsedFolders = new Set(); // Track which folders are collapsed
50
- // A host that re-renders the diagram hands in its own history so undo
51
- // outlives this instance; that host then owns the undo/redo keys too.
52
- this.hostOwnsHistory = !!options.history;
53
- this.history = options.history ?? new CommandHistory({
54
- maxSize: options.historySize ?? 200,
55
- onChange: (canUndo, canRedo) => {
56
- options?.onHistoryChange?.(canUndo, canRedo);
57
- },
58
- });
59
- }
60
-
61
- /**
62
- * Fields that are not caller "extra data" for tooltips, search and the
63
- * properties dialog: the engine's own, plus the host's `internalKeys`
64
- * (bookkeeping a host adds to the items it hands in).
65
- * @param {'node'|'table'|'edge'} kind
66
- * @returns {Set<string>}
67
- */
68
- structuralKeys(kind) {
69
- const E = this.engine?.constructor ?? {};
70
- const own = { node: E.NODE_STRUCTURAL_KEYS, table: E.TABLE_NODE_STRUCTURAL_KEYS, edge: E.EDGE_STRUCTURAL_KEYS }[kind];
71
- return new Set([...(own ?? []), ...(this.options?.internalKeys ?? [])]);
72
- }
73
-
74
- _render() {
75
- const { nodes, edges } = this.data;
76
- const options = this.options;
77
- this._chartState = { type: 'graph', nodes, edges, options };
78
- if (!this.renderer || !this.renderer.supportsSceneGraph) {
79
- console.warn('Advanced graph diagram is only supported in SVG renderer.');
80
- return;
81
- }
82
- this.renderer.clear();
83
-
84
- const { nodeRadius = 14, onNodeClick, onEdgeClick, legend: showLegend = false, colors = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452'], edgeColor: defaultEdgeColor = '#bdc3c7', contextMenuItems, edgeContextMenuItems, canvasContextMenuItems, editable = false } = options;
85
- const { compactness = 1.0 } = options;
86
- // Default auto-routing for edges that don't set their own `edge.routing`
87
- // — 'straight' (default), 'curved', 'orthogonal', or 'polyline' (basic
88
- // node-avoidance). See _computeEdgePath. Editable diagrams also let
89
- // the user drag a manual bend point onto any edge (double-click to
90
- // add/remove, drag to reposition — see _addDragListeners), which
91
- // always wins over whatever routing style is set.
92
- const { edgeRouting: defaultEdgeRouting = 'straight' } = options;
93
- // Resize handle (see _addResizeListeners): off unless `resizable` is
94
- // true (a node can still opt out/in individually via its own
95
- // `node.resizable`), corner defaults to 'top-right', overridable per
96
- // shape (`resizeCornerByShape: {shape: corner}`) or per node
97
- // (`node.resizeCorner`) — see _nodeResizeCorner.
98
- const { resizable = false, resizeCorner = 'top-right', resizeCornerByShape } = options;
99
- // Where a node's icon (icon/iconUrl, see the nodes param doc above)
100
- // sits: 'center' (default — replaces the initials) or 'corner' (a
101
- // small badge, positioned like the resize handle via the same
102
- // node > per-shape > diagram-wide precedence — see _nodeIconCorner).
103
- // A node's own `node.iconPosition` overrides this diagram default.
104
- const { iconPosition: defaultIconPosition = 'center', iconCorner = 'top-right', iconCornerByShape } = options;
105
- // 'below' (default, the original caption-under-the-shape convention)
106
- // or 'inside' (label left-aligned inside the box — see the render
107
- // loop below). A node's own `node.labelPosition` overrides this.
108
- const { labelPosition: defaultLabelPosition = 'below' } = options;
109
- // Horizontal alignment of the label: 'left' | 'center' | 'right'.
110
- // Default keeps the existing behaviour: centred for captions below the
111
- // shape, left-aligned for wrapped labels inside a box. Per-node values
112
- // override the diagram default.
113
- const { textAlign: defaultTextAlign = null } = options;
114
- // Lines over boxes (Archi's connection layer) instead of under them.
115
- const { edgesOnTop = false } = options;
116
- // Vertical alignment, only meaningful for `labelPosition: 'inside'`:
117
- // 'top' | 'middle' | 'bottom'. Default 'top' preserves existing layout.
118
- const { verticalAlign: defaultVerticalAlign = null } = options;
119
- // `sizeBy: 'degree'` sizes nodes by connection count instead of the
120
- // caller pre-populating `node.size` — see _computeNodeDegrees.
121
- const { minRadius = nodeRadius, maxRadius = nodeRadius * 2.5, sizeScale = 'linear', sizeBy } = options;
122
- // Same pattern, for edge thickness — `edge.weight` drives stroke width.
123
- const { minEdgeWidth = 1.4, maxEdgeWidth = minEdgeWidth * 4, edgeWidthScale = 'linear' } = options;
124
- // Grid: `gridSize` > 0 snaps user-driven positions (drag, add node) to
125
- // this many SVG units; `showGrid` draws the grid behind the diagram.
126
- const { gridSize = 0, showGrid = false } = options;
127
-
128
- // Initialize GridOverlay for responsive grid rendering
129
- if (showGrid && gridSize > 0) {
130
- if (!this._gridOverlay) {
131
- this._gridOverlay = new GridOverlay(this.renderer.container, { gridSize, color: '#e5e5e5', width: 0.5 });
132
- } else {
133
- this._gridOverlay.setGridSize(gridSize);
134
- }
135
- } else if (this._gridOverlay) {
136
- this._gridOverlay.destroy();
137
- this._gridOverlay = null;
138
- }
139
-
140
- const W = this.renderer.width, H = this.renderer.height;
141
- const R = nodeRadius;
142
- const PAD = R + 6;
143
- // Degree is computed from the caller's own `edges`, not whatever
144
- // `effectiveEdges` layout inference below may substitute — "number of
145
- // connections" means the graph the caller described, not an internal
146
- // tree-layout artifact.
147
- const degreeMap = sizeBy === 'degree' ? GraphMetrics.computeNodeDegrees(nodes, edges) : null;
148
- const sizeDomain = GraphMetrics.computeSizeDomain(nodes, { sizeBy, degreeMap });
149
- const sizeOptions = { minRadius, maxRadius, sizeScale, sizeBy, degreeMap };
150
-
151
- // A hierarchical-key node set (see _inferHierarchyEdges) replaces the
152
- // caller's edges with the inferred parent/child structure (plus any
153
- // "quasi-leaf" edges it chose to keep) and switches layout from
154
- // force-directed to a top-down tree — the two layouts don't blend.
155
- const hierarchyEdges = this._inferHierarchyEdges(nodes, edges);
156
- const useTreeLayout = hierarchyEdges !== null;
157
- const effectiveEdges = useTreeLayout ? hierarchyEdges : edges;
158
- const childrenMap = useTreeLayout ? this._hierarchyChildrenMap(effectiveEdges) : null;
159
- // Over effectiveEdges, not the caller's raw edges — this is the domain
160
- // for what actually gets drawn below, and in tree-layout mode that's a
161
- // synthesized edge set the caller's `.weight` values don't apply to.
162
- const edgeWidthDomain = GraphMetrics.computeEdgeWidthDomain(effectiveEdges);
163
- const edgeWidthOptions = { minEdgeWidth, maxEdgeWidth, widthScale: edgeWidthScale };
164
- // Collapsed subtrees (toggled via each node's +/− badge, see nodeEls
165
- // below) hide their descendants — not the collapsed node itself,
166
- // whose badge must stay visible/clickable to re-expand it.
167
- const hiddenByCollapse = useTreeLayout
168
- ? this._collapsedDescendantIds(this._graphCollapsedNodes, childrenMap)
169
- : new Set();
170
- // Live keyword filter (see filterGraphNodes) — recomputed every render
171
- // so typing in a search box just calls filterGraphNodes and re-renders.
172
- const filterKeyword = (this._graphFilterKeyword || '').trim();
173
- const hiddenByFilter = this._hiddenByKeywordFilter(nodes, filterKeyword, childrenMap);
174
-
175
- // Initialize positions, respecting any fixed positions from the input
176
- this._graphFixedPos = new Set(nodes.filter(n => n.x != null || n.y != null).map(n => n.id));
177
- let pos;
178
- if (useTreeLayout) {
179
- pos = this._computeTreeLayout(nodes, effectiveEdges, W, H, PAD);
180
- nodes.forEach(n => { // an explicit x/y still overrides the computed tree slot
181
- if (this._graphFixedPos.has(n.id)) {
182
- const p = pos.get(n.id);
183
- if (n.x != null) p.x = n.x;
184
- if (n.y != null) p.y = n.y;
185
- }
186
- });
187
- } else {
188
- pos = new Map(nodes.map(n => [n.id, {
189
- x: n.x ?? (Math.random() * (W - 2 * PAD) + PAD),
190
- y: n.y ?? (Math.random() * (H - 2 * PAD) + PAD),
191
- vx: 0, vy: 0 }]));
192
- const K_FR = Math.sqrt((W * H) / Math.max(nodes.length, 1)) * 0.9 * compactness;
193
- const frStep = (temp) => {
194
- nodes.forEach(n => { if (!this._graphFixedPos.has(n.id)) { const p = pos.get(n.id); p.vx = 0; p.vy = 0; } });
195
- for (let i = 0; i < nodes.length; i++) {
196
- for (let j = i + 1; j < nodes.length; j++) {
197
- const a = pos.get(nodes[i].id), b = pos.get(nodes[j].id);
198
- const dx = b.x - a.x, dy = b.y - a.y;
199
- const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
200
- const f = K_FR * K_FR / dist;
201
- a.vx -= dx / dist * f; a.vy -= dy / dist * f;
202
- b.vx += dx / dist * f; b.vy += dy / dist * f;
203
- }
204
- }
205
- effectiveEdges.forEach(e => {
206
- const a = pos.get(e.source), b = pos.get(e.target);
207
- if (!a || !b) return;
208
- const dx = b.x - a.x, dy = b.y - a.y;
209
- const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
210
- const f = (dist * dist) / (K_FR * 1.5);
211
- a.vx += dx / dist * f; a.vy += dy / dist * f;
212
- b.vx -= dx / dist * f; b.vy -= dy / dist * f;
213
- });
214
- nodes.forEach(n => {
215
- if (this._graphFixedPos.has(n.id)) return;
216
- const p = pos.get(n.id);
217
- p.vx += (W / 2 - p.x) * 0.006;
218
- p.vy += (H / 2 - p.y) * 0.006;
219
- const mag = Math.sqrt(p.vx * p.vx + p.vy * p.vy) || 1;
220
- const dt = Math.min(mag, temp) / mag;
221
- p.x = Math.max(PAD, Math.min(W - PAD, p.x + p.vx * dt));
222
- p.y = Math.max(PAD, Math.min(H - PAD, p.y + p.vy * dt));
223
- });
224
- };
225
-
226
- if (!this._graph) { // Only run full simulation on initial draw
227
- // Run simulation synchronously before drawing
228
- for (let iter = 0; iter < 180; iter++) {
229
- frStep(K_FR * (1 - iter / 180) * 0.25);
230
- }
231
- }
232
- }
233
-
234
- // Other nodes as circular obstacles for 'polyline' routing (see
235
- // _polylineEdgePath) — computed once per render, source/target of
236
- // each edge excluded per-edge below rather than filtered out here.
237
- const routingObstacles = nodes.map(n => {
238
- const p = pos.get(n.id);
239
- if (!p) return null;
240
- const size = NodeGeometry.nodeSize(n, R, sizeDomain, sizeOptions);
241
- return { id: n.id, x: p.x, y: p.y, r: Math.max(size.w, size.h) / 2 };
242
- }).filter(Boolean);
243
-
244
- // The selected line's handles are drawn after the nodes: an end handle
245
- // sits on a node's rim, and a node drawn over it would hide half of it.
246
- const selectionHandles = [];
247
- const svgRoot = this.renderer.svg;
248
- const edgesFrom = svgRoot?.childNodes?.length ?? 0;
249
- const edgeEls = effectiveEdges.map(edge => {
250
- const sourceNode = pos.get(edge.source);
251
- const targetNode = pos.get(edge.target);
252
- if (!sourceNode || !targetNode) return null;
253
- const sourceInfo = nodes.find(n => n.id === edge.source);
254
- const targetInfo = nodes.find(n => n.id === edge.target);
255
- const sourceSize = this.engine._nodeSize(sourceInfo, R, sizeDomain, sizeOptions);
256
- const targetSize = this.engine._nodeSize(targetInfo, R, sizeDomain, sizeOptions);
257
- const edgeObstacles = routingObstacles.filter(o => o.id !== edge.source && o.id !== edge.target);
258
-
259
- const { x1, y1, x2, y2, d } = EdgeGeometry.computeEdgePath(
260
- sourceInfo, sourceNode, sourceSize, targetInfo, targetNode, targetSize, edge, defaultEdgeRouting, edgeObstacles
261
- );
262
-
263
- const edgeColor = edge.color || defaultEdgeColor;
264
- const arrowhead = edge.arrowhead || 'arrow'; // default to 'arrow'
265
- const markerId = arrowhead === 'none' ? '' : this.renderer.markerUrl(arrowhead);
266
- const edgeWidth = GraphMetrics.effectiveEdgeWidth(edge, 1.4, edgeWidthDomain, edgeWidthOptions);
267
- const path = this.renderer.path(d, 'none', { color: edgeColor, width: edgeWidth }, { isEdge: true, edge, tooltip: this._edgeTooltipText(edge) });
268
- if (edge.id) path.setAttribute('edgeId', edge.id);
269
- if (edge.opacity != null && edge.opacity !== 1) {
270
- path.setAttribute('opacity', String(edge.opacity));
271
- }
272
- if (markerId) path.setAttribute('marker-end', markerId);
273
- // marker-start — a second, independent arrowhead/glyph at the
274
- // SOURCE end (e.g. ArchiMate's composition/aggregation diamond,
275
- // or assignment's filled ball, both drawn at the "whole"/source
276
- // end while marker-end carries a different glyph or none at all).
277
- // Existing edges that only ever set `arrowhead` are unaffected —
278
- // this is purely additive.
279
- if (edge.arrowheadStart && edge.arrowheadStart !== 'none') path.setAttribute('marker-start', this.renderer.markerUrl(edge.arrowheadStart));
280
- // `lineStyle` ('solid'|'dashed'|'dotted') is the general form of
281
- // the older boolean `edge.dashed` (kept working, still meaning
282
- // 'dashed', for callers who never adopted lineStyle) — 'dotted'
283
- // is the extra case neither `dashed` nor the old fixed pattern
284
- // could express (ArchiMate's realization/access relationships).
285
- const dashArray = LINE_STYLE_DASH[edge.lineStyle ?? (edge.dashed ? 'dashed' : 'solid')] ?? null;
286
- if (dashArray) path.setAttribute('stroke-dasharray', dashArray);
287
- // onEdgeClick was accepted as an option but never actually wired
288
- // up to anything — direct DOM listener, same pattern node click
289
- // already uses (bubbles fine regardless of hitTest).
290
- if (onEdgeClick) path.style.cursor = 'pointer';
291
- if (onEdgeClick) path.addEventListener('click', () => onEdgeClick(edge));
292
-
293
- // Track edge selection for waypoint visibility (only show waypoints on selected edge)
294
- path.addEventListener('click', (e) => {
295
- e.stopPropagation(); // Prevent canvas deselection
296
- this._selectedEdge = edge;
297
- this._rerenderGraph(); // Redraw to update waypoint visibility
298
- });
299
-
300
- // A collapsed ancestor or the keyword filter hides some nodes
301
- // (see nodeEls below) — hide their connecting edges too, or
302
- // they'd dangle toward an invisible node instead of disappearing
303
- // with it.
304
- const edgeHidden = hiddenByCollapse.has(edge.source) || hiddenByCollapse.has(edge.target)
305
- || hiddenByFilter.has(edge.source) || hiddenByFilter.has(edge.target);
306
- if (edgeHidden) path.setAttribute('visibility', 'hidden');
307
-
308
- if (edge.label) {
309
- const midX = (x1 + x2) / 2;
310
- const midY = (y1 + y2) / 2;
311
- const defaultTooltip = `${sourceInfo.label} → ${targetInfo.label}: ${edge.label}`;
312
-
313
- const labelEl = this.renderer.text(edge.label, midX, midY, { textAnchor: 'middle', fontSize: '9px', fill: '#636e72', paintOrder: 'stroke', stroke: '#fafbfc', 'stroke-width': '2px', 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter', tooltip: edge.tooltip || defaultTooltip });
314
- if (edgeHidden) labelEl.setAttribute('visibility', 'hidden');
315
- }
316
-
317
- // Independent end labels — a UML association's multiplicity
318
- // ('0..1', '1..*', ...) shown near EACH end, distinct from the
319
- // one midpoint `edge.label` above (e.g. the relationship's own
320
- // name) — see dataModelDiagram's 'uml' notation, though this is
321
- // a plain graphDiagram field, not data-model-specific. Placed
322
- // 18% of the way in from each anchor, not right at it, so the
323
- // text doesn't sit on top of an arrowhead/cardinality glyph.
324
- if (edge.sourceLabel) {
325
- const lx = x1 + (x2 - x1) * 0.18, ly = y1 + (y2 - y1) * 0.18;
326
- const labelEl = this.renderer.text(edge.sourceLabel, lx, ly, { textAnchor: 'middle', fontSize: '9px', fill: '#636e72', paintOrder: 'stroke', stroke: '#fafbfc', 'stroke-width': '2px', 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter' });
327
- if (edgeHidden) labelEl.setAttribute('visibility', 'hidden');
328
- }
329
- if (edge.targetLabel) {
330
- const lx = x2 + (x1 - x2) * 0.18, ly = y2 + (y1 - y2) * 0.18;
331
- const labelEl = this.renderer.text(edge.targetLabel, lx, ly, { textAnchor: 'middle', fontSize: '9px', fill: '#636e72', paintOrder: 'stroke', stroke: '#fafbfc', 'stroke-width': '2px', 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter' });
332
- if (edgeHidden) labelEl.setAttribute('visibility', 'hidden');
333
- }
334
-
335
- // Manual bend points (see _computeEdgePath's waypoints branch and
336
- // the drag/double-click handlers in _addDragListeners) only need a
337
- // visible/draggable handle in editable mode — a non-editable
338
- // diagram still routes through them, it just can't gain new ones.
339
- // Only show waypoints when this edge is selected (reduces clutter).
340
- if (editable && this._selectedEdge === edge) selectionHandles.push(() => {
341
- // Endpoint handles (source and target) — red circles, draggable to reposition on node rim
342
- const endpointHandleColor = '#ff6b6b';
343
- {
344
- const sourceHandle = this.renderer.circle(x1, y1, 5, '#ffffff', { color: endpointHandleColor, width: 2 }, { draggable: true, isEndpoint: true, edge, endpointType: 'source' });
345
- sourceHandle.style.cursor = 'grab';
346
- sourceHandle.setAttribute(EDGE_HANDLE_ATTR, '');
347
- if (edgeHidden) sourceHandle.setAttribute('visibility', 'hidden');
348
-
349
- const targetHandle = this.renderer.circle(x2, y2, 5, '#ffffff', { color: endpointHandleColor, width: 2 }, { draggable: true, isEndpoint: true, edge, endpointType: 'target' });
350
- targetHandle.style.cursor = 'grab';
351
- targetHandle.setAttribute(EDGE_HANDLE_ATTR, '');
352
- if (edgeHidden) targetHandle.setAttribute('visibility', 'hidden');
353
- }
354
-
355
- // Waypoint handles (blue circles for manual bends)
356
- if (edge.waypoints?.length) {
357
- edge.waypoints.forEach((wp, waypointIndex) => {
358
- const handle = this.renderer.circle(wp.x, wp.y, 4, '#ffffff', { color: '#4f8ef7', width: 2 }, { draggable: true, isWaypoint: true, edge, waypointIndex });
359
- handle.style.cursor = 'move';
360
- handle.setAttribute(EDGE_HANDLE_ATTR, '');
361
- if (edgeHidden) handle.setAttribute('visibility', 'hidden');
362
- });
363
- }
364
- });
365
- return path;
366
- }).filter(Boolean);
367
-
368
- // Draw edge-crossing bridges (small arcs where edges cross)
369
- const edgePaths = edgeEls.map(el => el ? el.getAttribute('d') : null).filter(Boolean);
370
- if (edgePaths.length > 1) {
371
- // Detect all pairwise crossings
372
- const allCrossings = [];
373
- for (let i = 0; i < edgePaths.length; i++) {
374
- for (let j = i + 1; j < edgePaths.length; j++) {
375
- const crossings = EdgeGeometry.findEdgeCrossings(edgePaths[i], [edgePaths[j]]);
376
- allCrossings.push(...crossings);
377
- }
378
- }
379
-
380
- // Draw bridges at each crossing (small semicircle arcs)
381
- for (const crossing of allCrossings) {
382
- const bridgeRadius = 6;
383
- const dx = Math.cos(crossing.angle);
384
- const dy = Math.sin(crossing.angle);
385
- const px = crossing.x - dy * bridgeRadius;
386
- const py = crossing.y + dx * bridgeRadius;
387
- const qx = crossing.x + dy * bridgeRadius;
388
- const qy = crossing.y - dx * bridgeRadius;
389
-
390
- const bridgePath = `M${px},${py} A${bridgeRadius},${bridgeRadius} 0 0,1 ${qx},${qy}`;
391
- this.renderer.path(bridgePath, 'none', {
392
- color: '#ffffff',
393
- width: 8,
394
- opacity: 0.8
395
- });
396
- // Draw a thin line on top for visibility
397
- this.renderer.path(bridgePath, 'none', {
398
- color: '#bdc3c7',
399
- width: 2,
400
- opacity: 1
401
- });
402
- }
403
- }
404
-
405
- const groups = new Map();
406
- nodes.forEach(node => {
407
- if (node.group && !groups.has(node.group)) {
408
- groups.set(node.group, colors[groups.size % colors.length]);
409
- }
410
- });
411
-
412
- // Get nodes hidden by collapsed folders
413
- const { folders = [] } = this.data;
414
- const hiddenByCollapsedFolders = this._getHiddenByCollapsedFolders(folders);
415
-
416
- // Filter out nodes from hidden groups, collapsed subtrees, keyword filter, and collapsed folders
417
- const visibleNodes = nodes.filter(node =>
418
- (!node.group || !this._hiddenGraphGroups.has(node.group)) && !this._hiddenGraphNodes.has(node.id)
419
- && !hiddenByCollapse.has(node.id) && !hiddenByFilter.has(node.id)
420
- && !hiddenByCollapsedFolders.has(node.id)
421
- );
422
- const visibleNodeIds = new Set(visibleNodes.map(n => n.id));
423
- const visibleEdges = effectiveEdges.filter(edge => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target));
424
-
425
- const edgesTo = svgRoot?.childNodes?.length ?? 0;
426
- // Render folders (visual containers) before nodes so they appear behind
427
- this._renderFolders(this.renderer.svg, folders);
428
-
429
- const nodeEls = nodes.map(node => {
430
- const isHidden = this._hiddenGraphGroups.has(node.group) || this._hiddenGraphNodes.has(node.id)
431
- || hiddenByCollapse.has(node.id) || hiddenByFilter.has(node.id)
432
- || hiddenByCollapsedFolders.has(node.id);
433
- const p = pos.get(node.id);
434
-
435
- // Table nodes previously got just the bare label as a tooltip —
436
- // real detail (column/row counts) already exists on the node
437
- // object at this point but wasn't being surfaced on hover. Either
438
- // way, any extra caller-supplied field not in the structural set
439
- // (e.g. {owner:'Alice'}) is appended as its own "key: value" line.
440
- const resolvedLabel = LabelHelper.resolveNodeLabel(node);
441
- const tooltip = node.shape === 'table'
442
- ? [
443
- `${resolvedLabel} — ${node.columns?.length ?? 0} column${node.columns?.length === 1 ? '' : 's'}` +
444
- (Number.isFinite(node.rowCount) ? ` · ${node.rowCount.toLocaleString()} row${node.rowCount === 1 ? '' : 's'}` : ''),
445
- ...LabelHelper.nodeExtraDataLines(node, this.structuralKeys('table')),
446
- ].join('\n')
447
- : [`Node ${resolvedLabel}`, ...LabelHelper.nodeExtraDataLines(node, this.structuralKeys('node'))].join('\n');
448
- const g = this.renderer.group({ transform: `translate(${p.x},${p.y})`, draggable: true, nodeId: node.id, tooltip });
449
- if (node.opacity != null && node.opacity !== 1) {
450
- g.setAttribute('opacity', String(node.opacity));
451
- }
452
-
453
- if (isHidden) {
454
- g.setAttribute('visibility', 'hidden');
455
- }
456
-
457
- if (node.shape === 'table') {
458
- NodeGeometry.buildTableNodeContent(this.renderer, g, node);
459
- } else {
460
- const nodeColor = node.color || (node.group ? groups.get(node.group) : (options.nodeColor || '#2ecc71'));
461
- // A 'rect' node's own width/height (like a table node's, see
462
- // _nodeSize) override the plain square-of-radius box every
463
- // other shape still uses — needed for ArchiMate-style wider-
464
- // than-tall boxes (archimateDiagram). nodeR (still used below
465
- // for icon sizing/badge placement on non-rect shapes) is the
466
- // radius that SAME square would have had, so nothing changes
467
- // for a 'rect' node that never sets width/height.
468
- const nodeSize = NodeGeometry.nodeSize(node, R, sizeDomain, sizeOptions);
469
- const nodeR = Math.max(nodeSize.w, nodeSize.h) / 2;
470
- const { el: shapeEl, suppressInner } = NodeGeometry.nodeShapeEl(this.renderer, node.shape, nodeR, nodeColor, nodeSize, node.cornerStyle, node.strokeColor, node.strokeWidth, node.iconUrl, node.icon);
471
- if (node.strokeDasharray) shapeEl.setAttribute?.('stroke-dasharray', node.strokeDasharray);
472
- g.appendChild(shapeEl);
473
-
474
- // `iconShape` — a shape NAME (any _nodeShapeEl one) drawn
475
- // small in the corner as line art, instead of an emoji/image
476
- // badge. This is how the real ArchiMate notation works: the
477
- // corner badge is the element's own standalone glyph at badge
478
- // size (see archimateDiagram/_archimateBuildNode), so one
479
- // vector glyph serves both. Drawn unfilled with a dark
480
- // outline so it reads on top of the node's own fill.
481
- // The icon is drawn in the outline colour; with no outline ('none') it stays black, as in Archi.
482
- const markColor = node.strokeColor === 'none' ? '#000000' : node.strokeColor;
483
- if (node.iconShape) {
484
- const badge = Math.max(10, Math.round(Math.min(nodeSize.w, nodeSize.h) * 0.3));
485
- const corner = NodeGeometry.nodeIconCorner(node, iconCornerByShape, iconCorner);
486
- const cp = NodeGeometry.cornerPoint({ x: 0, y: 0 }, nodeSize, corner);
487
- const inset = badge / 2 + 3;
488
- const bx = cp.x + (corner.includes('right') ? -inset : inset);
489
- const by = cp.y + (corner.includes('bottom') ? -inset : inset);
490
- const { el: badgeEl } = NodeGeometry.nodeShapeEl(
491
- this.renderer, node.iconShape, badge / 2, 'none', { w: badge, h: badge * 0.78 }, undefined,
492
- markColor || '#000000', 1,
493
- );
494
- badgeEl.setAttribute('transform', `translate(${bx},${by})`);
495
- badgeEl.setAttribute('pointer-events', 'none');
496
- g.appendChild(badgeEl);
497
- }
498
-
499
- const iconPosition = node.iconPosition || defaultIconPosition;
500
- // A custom icon (an emoji/glyph string, or an image/SVG file via
501
- // iconUrl) — centred on the shape (replacing the plain initials,
502
- // 'center', the default) or a small corner badge ('corner', see
503
- // _nodeIconCorner) alongside whatever else is in the box.
504
- // suppressInner still gates 'center' mode only — a 'person'
505
- // shape (which draws its own head/body glyph) stays uncluttered
506
- // there, but a corner badge sits well clear of that glyph.
507
- if (iconPosition === 'corner' && (node.icon || node.iconUrl)) {
508
- // Sized off the box's SMALLER dimension, not nodeR (which
509
- // is max(w,h)/2 — for a wide-but-short ArchiMate-style
510
- // box that would badly oversize the corner badge).
511
- const badgeSize = Math.max(12, Math.round(Math.min(nodeSize.w, nodeSize.h) * 0.3));
512
- const corner = NodeGeometry.nodeIconCorner(node, iconCornerByShape, iconCorner);
513
- const cp = NodeGeometry.cornerPoint({ x: 0, y: 0 }, nodeSize, corner);
514
- const inset = badgeSize / 2 + 2; // keep the badge inside the box, not straddling its edge
515
- const bx = cp.x + (corner.includes('right') ? -inset : inset);
516
- const by = cp.y + (corner.includes('bottom') ? -inset : inset);
517
- if (node.iconUrl) {
518
- g.appendChild(this.renderer.image(node.iconUrl, bx - badgeSize / 2, by - badgeSize / 2, badgeSize, badgeSize, { 'pointer-events': 'none' }));
519
- } else {
520
- // Badge colour follows the node's own outline colour —
521
- // white reads on plain graphDiagram's saturated node
522
- // fills, but vanishes on an ArchiMate box's pastel one
523
- // (which sets strokeColor: '#000').
524
- g.appendChild(this.renderer.text(node.icon, bx, by, { fill: markColor || '#fff', textAnchor: 'middle', verticalAlign: 'middle', fontSize: `${badgeSize}px`, 'pointer-events': 'none' }));
525
- }
526
- // A node with a real glyph badge (iconShape, drawn above)
527
- // must not ALSO get the centred initials fallback below —
528
- // that's what put a stray "RE"/"CA" in the middle of every
529
- // ArchiMate box once emoji badges gave way to glyphs.
530
- } else if (!suppressInner && !node.iconShape && (!node.viewObject || node.iconUrl)) {
531
- // (A view-only box shows its own text: no initials, but its picture if it has one.)
532
- if (node.iconUrl) {
533
- const iconSize = nodeR * 1.3;
534
- g.appendChild(this.renderer.image(node.iconUrl, -iconSize / 2, -iconSize / 2, iconSize, iconSize, { 'pointer-events': 'none' }));
535
- } else if (GraphDiagram.showsInitials(node, defaultLabelPosition)) {
536
- const centerText = node.icon || resolvedLabel.slice(0, 2).toUpperCase() || (node.id ?? '').slice(0, 2).toUpperCase();
537
- const fontSize = node.icon ? Math.max(12, Math.round(nodeR * 1.1)) : 10;
538
- g.appendChild(this.renderer.text(centerText, 0, 0, { fill: '#fff', textAnchor: 'middle', verticalAlign: 'middle', fontSize: `${fontSize}px`, fontWeight: node.icon ? 'normal' : 'bold', 'pointer-events': 'none' }));
539
- }
540
- }
541
-
542
- // 'below' (default) keeps the original single-line caption-
543
- // under-the-shape convention; 'inside' (ArchiMate-style
544
- // boxes) left-aligns the label near the box's own top-left
545
- // instead, WORD-WRAPPED (_wrapLabelLines) to as many lines as
546
- // fit inside the real box (nodeSize, not the icon-sizing
547
- // nodeR above — a wide-but-short rect must not push it far
548
- // below) rather than a single line overflowing the edges.
549
- // Image objects draw their own visual, so they skip labels.
550
- if (node.shape !== 'image') {
551
- const labelPosition = node.labelPosition || (node.shape === 'note' ? 'inside' : defaultLabelPosition);
552
- const inside = labelPosition === 'inside';
553
- const textAlign = node.textAlign || defaultTextAlign || (inside ? 'left' : 'center');
554
- const verticalAlign = node.verticalAlign || defaultVerticalAlign || 'top';
555
- const anchorByAlign = { left: 'start', center: 'middle', right: 'end' };
556
- const textAnchor = anchorByAlign[textAlign] || 'start';
557
- if (inside) {
558
- // The node's own font when it has one (an Archi import: points, as Archi sizes them).
559
- const FONT_SIZE = GraphDiagram.fontPixels(node.fontSize) ?? 10;
560
- const PAD = 6, LINE_HEIGHT = Math.round(FONT_SIZE * 1.2);
561
- const font = {
562
- fill: node.textColor || '#222', fontSize: `${FONT_SIZE}px`,
563
- ...(node.fontFamily && { fontFamily: node.fontFamily }),
564
- ...(node.fontWeight && { fontWeight: node.fontWeight }),
565
- ...(node.fontStyle && { fontStyle: node.fontStyle }),
566
- };
567
- // A badge in a top corner takes its width off that side of the text, as in Archi.
568
- const hasBadge = node.iconShape || (iconPosition === 'corner' && (node.icon || node.iconUrl));
569
- const badgeCorner = hasBadge ? NodeGeometry.nodeIconCorner(node, iconCornerByShape, iconCorner) : '';
570
- const badgeWidth = hasBadge && badgeCorner.includes('top') ? Math.max(12, Math.round(Math.min(nodeSize.w, nodeSize.h) * 0.3)) + 4 : 0;
571
- const insetLeft = badgeCorner.includes('left') ? badgeWidth : 0;
572
- const insetRight = badgeCorner.includes('right') ? badgeWidth : 0;
573
- const lines = LabelHelper.wrapLabelLines(resolvedLabel, nodeSize.w - PAD * 2 - insetLeft - insetRight, FONT_SIZE, nodeSize.h - PAD * 2, LINE_HEIGHT,
574
- this.renderer.measureText ? (s) => this.renderer.measureText(s, font) : null);
575
- const { x, baseline: y0 } = TextStyle.block({ textAlign, textPosition: verticalAlign },
576
- { left: -nodeSize.w / 2 + insetLeft, right: nodeSize.w / 2 - insetRight, top: -nodeSize.h / 2, bottom: nodeSize.h / 2 },
577
- lines.length, { pixels: FONT_SIZE, lineHeight: LINE_HEIGHT, pad: PAD });
578
- lines.forEach((line, i) => {
579
- g.appendChild(this.renderer.text(line, x, y0 + i * LINE_HEIGHT, { ...font, textAnchor, 'pointer-events': 'none' }));
580
- });
581
- } else {
582
- // nodeSize.h/2 (the shape's own real bottom edge), not
583
- // nodeR (max(w,h)/2 — for a wide-but-short shape, e.g. any
584
- // archimateDiagram distinct notation shape at its usual
585
- // 120x55-ish box footprint, nodeR badly overshoots the
586
- // shape's actual visual height, sitting the label so far
587
- // down it lands on top of the NEXT row in a dense grid
588
- // instead of just under this node). For a shape that
589
- // ignores non-square `size` entirely (person/diamond/the
590
- // plain default circle — always rendered from R alone),
591
- // nodeSize.h still equals 2*R when no explicit
592
- // width/height override is set, so this is a no-op there.
593
- let x;
594
- if (textAlign === 'left') x = -nodeSize.w / 2;
595
- else if (textAlign === 'right') x = nodeSize.w / 2;
596
- else x = 0;
597
- g.appendChild(this.renderer.text(resolvedLabel, x, nodeSize.h / 2 + 9, { fill: '#333', textAnchor, fontSize: '9px', 'pointer-events': 'none' }));
598
- }
599
- }
600
-
601
- // Hierarchical-key mode only: a +/− badge on any node with
602
- // children toggles its subtree's visibility (_graphCollapsedNodes,
603
- // read back into hiddenByCollapse above on the next render).
604
- // stopPropagation keeps this click from also bubbling to the
605
- // node's own onNodeClick listener below.
606
- if (childrenMap?.get(node.id)?.length) {
607
- const collapsed = this._graphCollapsedNodes.has(node.id);
608
- const badgeY = nodeR + 20; // below the label (at nodeR+9) so it doesn't overlap the node circle or its text
609
- const badge = this.renderer.circle(0, badgeY, 6, '#ffffff', { color: '#888', width: 1.2 });
610
- const badgeGlyph = this.renderer.text(collapsed ? '+' : '−', 0, badgeY, {
611
- textAnchor: 'middle', verticalAlign: 'middle', fontSize: '9px', fontWeight: 'bold', fill: '#555', 'pointer-events': 'none',
612
- });
613
- badge.style.cursor = 'pointer';
614
- badge.addEventListener('click', (event) => {
615
- event.stopPropagation();
616
- if (collapsed) this._graphCollapsedNodes.delete(node.id);
617
- else this._graphCollapsedNodes.add(node.id);
618
- this._rerenderGraph();
619
- });
620
- g.appendChild(badge);
621
- g.appendChild(badgeGlyph);
622
- }
623
- }
624
-
625
- if (onNodeClick) {
626
- g.addEventListener('click', () => onNodeClick(node));
627
- }
628
- return g;
629
- });
630
- if (edgesOnTop && svgRoot?.childNodes) {
631
- // Re-append the lines (and their labels and bridges), in order, above every box.
632
- for (const el of Array.from(svgRoot.childNodes).slice(edgesFrom, edgesTo)) svgRoot.appendChild(el);
633
- }
634
- for (const draw of selectionHandles) draw();
635
-
636
- if (showLegend && groups.size > 0) {
637
- const legendX = W - 120;
638
- let legendY = 40;
639
- const legendItemHeight = 20;
640
- const legendMarkerRadius = 6;
641
-
642
- const legendGroup = this.renderer.group({ class: 'legend' });
643
-
644
- groups.forEach((color, groupName) => {
645
- const isHidden = this._hiddenGraphGroups.has(groupName);
646
- const legendItemAttrs = { legendItem: true, graphLegend: true, groupName };
647
- const itemColor = isHidden ? '#ccc' : color;
648
- const textColor = isHidden ? '#ccc' : '#333';
649
-
650
- const marker = this.renderer.circle(legendX, legendY, legendMarkerRadius, itemColor, { color: 'white', width: 1.5 }, legendItemAttrs);
651
- const label = this.renderer.text(groupName, legendX + legendMarkerRadius + 8, legendY, {
652
- textAnchor: 'start',
653
- verticalAlign: 'middle',
654
- fontSize: '12px',
655
- fill: textColor,
656
- ...legendItemAttrs
657
- });
658
- marker.addEventListener('click', () => this._onLegendClick(groupName));
659
- label.addEventListener('click', () => this._onLegendClick(groupName));
660
- legendY += legendItemHeight;
661
- });
662
- }
663
-
664
- // A click anywhere but on the selected line's handles deselects it: empty canvas, a shape, the
665
- // grid, a group. Listened in the capture phase (a shape stops its own click from bubbling); a
666
- // click on a line selects that one (its own listener). The handles are removed in place rather
667
- // than by redrawing, which would replace a shape under a double-click.
668
- if (this.renderer.svg) {
669
- this.renderer.svg.removeEventListener('click', this._handleCanvasClick, true);
670
- this._handleCanvasClick = (e) => this.deselectEdgeOnClick(e.target);
671
- this.renderer.svg.addEventListener('click', this._handleCanvasClick, true);
672
- }
673
-
674
- // The engine keeps the selection (several nodes), so it outlives a redraw.
675
- this._showNodeSelection(nodeEls);
676
-
677
- this._graph = {
678
- nodes, edges: visibleEdges, pos, nodeEls, edgeEls, nodeRadius: R, groups,
679
- contextMenuItems, edgeContextMenuItems, canvasContextMenuItems, editable,
680
- sizeDomain, sizeOptions,
681
- resizableDefault: resizable, resizeCorner, resizeCornerByShape,
682
- };
683
- }
684
-
685
- static inferHierarchyEdges(nodes, explicitEdges = []) {
686
- const HIER_KEY_RE = /^\d+(?:[./-]\d+)+$/;
687
- if (!nodes.some(n => HIER_KEY_RE.test(String(n.id)))) return null;
688
- const isHierId = (id) => /^\d+(?:[./-]\d+)*$/.test(String(id));
689
- const nodeIds = new Set(nodes.map(n => n.id));
690
- const edges = [];
691
- const seen = new Set();
692
- nodes.forEach(n => {
693
- const m = String(n.id).match(/^(.+)[./-]\d+$/);
694
- const parentId = m ? m[1] : null;
695
- if (parentId && nodeIds.has(parentId)) {
696
- edges.push({ source: parentId, target: n.id });
697
- seen.add(`${parentId}\u0000${n.id}`);
698
- }
699
- });
700
- explicitEdges.forEach(e => {
701
- if (isHierId(e.source) && isHierId(e.target)) return; // superseded by the inferred parent/child above
702
- const key = `${e.source}\u0000${e.target}`;
703
- if (seen.has(key)) return;
704
- seen.add(key);
705
- edges.push(e);
706
- });
707
- return edges;
708
- }
709
- _inferHierarchyEdges(nodes, explicitEdges = []) {
710
- return GraphDiagram.inferHierarchyEdges(nodes, explicitEdges);
711
- }
712
-
713
- static hierarchyChildrenMap(edges) {
714
- const map = new Map();
715
- edges.forEach(e => {
716
- if (!map.has(e.source)) map.set(e.source, []);
717
- map.get(e.source).push(e.target);
718
- });
719
- return map;
720
- }
721
- _hierarchyChildrenMap(edges) {
722
- return GraphDiagram.hierarchyChildrenMap(edges);
723
- }
724
-
725
- static collapsedDescendantIds(collapsedIds, childrenMap) {
726
- const hidden = new Set();
727
- const visit = (id) => {
728
- for (const child of (childrenMap.get(id) || [])) {
729
- if (!hidden.has(child)) { hidden.add(child); visit(child); }
730
- }
731
- };
732
- collapsedIds.forEach(visit);
733
- return hidden;
734
- }
735
- _collapsedDescendantIds(collapsedIds, childrenMap) {
736
- return GraphDiagram.collapsedDescendantIds(collapsedIds, childrenMap);
737
- }
738
-
739
- static computeTreeLayout(nodes, edges, W, H, PAD) {
740
- const childrenMap = GraphDiagram.hierarchyChildrenMap(edges);
741
- const hasParent = new Set(edges.map(e => e.target));
742
- const roots = nodes.filter(n => !hasParent.has(n.id)).map(n => n.id);
743
-
744
- const depth = new Map();
745
- const leafOrder = [];
746
- const visited = new Set();
747
- const visit = (id, d) => {
748
- if (visited.has(id)) return;
749
- visited.add(id);
750
- depth.set(id, d);
751
- const kids = childrenMap.get(id) || [];
752
- if (kids.length === 0) leafOrder.push(id);
753
- else kids.forEach(k => visit(k, d + 1));
754
- };
755
- roots.forEach(id => visit(id, 0));
756
- nodes.forEach(n => { if (!visited.has(n.id)) visit(n.id, 0); }); // any node an edge cycle left unreached becomes its own root
757
-
758
- const slot = new Map();
759
- leafOrder.forEach((id, i) => slot.set(id, i));
760
- const computeSlot = (id, seen) => {
761
- if (slot.has(id)) return slot.get(id);
762
- if (seen.has(id)) return 0; // guards a malformed/cyclic edge set
763
- seen.add(id);
764
- const kids = childrenMap.get(id) || [];
765
- const s = kids.reduce((sum, k) => sum + computeSlot(k, seen), 0) / kids.length;
766
- slot.set(id, s);
767
- return s;
768
- };
769
- nodes.forEach(n => computeSlot(n.id, new Set()));
770
-
771
- const maxDepth = Math.max(0, ...Array.from(depth.values()));
772
- const numLeaves = Math.max(1, leafOrder.length);
773
- const pos = new Map();
774
- nodes.forEach(n => {
775
- const d = depth.get(n.id) ?? 0;
776
- const s = slot.get(n.id) ?? 0;
777
- const x = numLeaves === 1 ? W / 2 : PAD + (s / (numLeaves - 1)) * (W - 2 * PAD);
778
- const y = maxDepth === 0 ? H / 2 : PAD + (d / maxDepth) * (H - 2 * PAD);
779
- pos.set(n.id, { x, y, vx: 0, vy: 0 });
780
- });
781
- return pos;
782
- }
783
- _computeTreeLayout(nodes, edges, W, H, PAD) {
784
- return GraphDiagram.computeTreeLayout(nodes, edges, W, H, PAD);
785
- }
786
-
787
- static nodeMatchesKeyword(node, keyword, { nodeStructuralKeys, tableNodeStructuralKeys }) {
788
- const structuralKeys = node.shape === 'table' ? tableNodeStructuralKeys : nodeStructuralKeys;
789
- const haystack = [LabelHelper.resolveNodeLabel(node), ...LabelHelper.nodeExtraDataLines(node, structuralKeys)].join(' ').toLowerCase();
790
- return haystack.includes(String(keyword).toLowerCase());
791
- }
792
- _nodeMatchesKeyword(node, keyword) {
793
- return GraphDiagram.nodeMatchesKeyword(node, keyword, {
794
- nodeStructuralKeys: this.structuralKeys('node'),
795
- tableNodeStructuralKeys: this.structuralKeys('table'),
796
- });
797
- }
798
-
799
- static hiddenByKeywordFilter(nodes, keyword, childrenMap = null, { nodeStructuralKeys, tableNodeStructuralKeys }) {
800
- const hidden = new Set();
801
- if (!keyword) return hidden;
802
- const matchedIds = nodes.filter(n => GraphDiagram.nodeMatchesKeyword(n, keyword, { nodeStructuralKeys, tableNodeStructuralKeys })).map(n => n.id);
803
- let keepVisible;
804
- if (childrenMap) {
805
- const parentOf = new Map();
806
- childrenMap.forEach((kids, parent) => kids.forEach(kid => parentOf.set(kid, parent)));
807
- keepVisible = new Set();
808
- matchedIds.forEach(id => {
809
- let cur = id;
810
- while (cur != null && !keepVisible.has(cur)) {
811
- keepVisible.add(cur);
812
- cur = parentOf.get(cur);
813
- }
814
- });
815
- } else {
816
- keepVisible = new Set(matchedIds);
817
- }
818
- nodes.forEach(n => { if (!keepVisible.has(n.id)) hidden.add(n.id); });
819
- return hidden;
820
- }
821
- _hiddenByKeywordFilter(nodes, keyword, childrenMap = null) {
822
- return GraphDiagram.hiddenByKeywordFilter(nodes, keyword, childrenMap, {
823
- nodeStructuralKeys: this.structuralKeys('node'),
824
- tableNodeStructuralKeys: this.structuralKeys('table'),
825
- });
826
- }
827
-
828
- static edgeTooltipText(edge, { edgeStructuralKeys }) {
829
- const lines = LabelHelper.nodeExtraDataLines(edge, edgeStructuralKeys);
830
- if (!lines.length) return undefined;
831
- return [`Edge ${edge.source} → ${edge.target}`, ...lines].join('\n');
832
- }
833
- _edgeTooltipText(edge) {
834
- return GraphDiagram.edgeTooltipText(edge, {
835
- edgeStructuralKeys: this.structuralKeys('edge'),
836
- });
837
- }
838
-
839
- static generateNodeId(label, existingIds = []) {
840
- const base = (label || 'node').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-+|-+$)/g, '') || 'node';
841
- const existing = new Set(existingIds);
842
- if (!existing.has(base)) return base;
843
- let i = 2;
844
- while (existing.has(`${base}-${i}`)) i++;
845
- return `${base}-${i}`;
846
- }
847
- _generateNodeId(label) {
848
- const existingIds = (this._chartState?.nodes ?? []).map(n => n.id);
849
- return GraphDiagram.generateNodeId(label, existingIds);
850
- }
851
- _insertEdgeWaypoint(edge, point) {
852
- const sourcePos = this._graph?.pos?.get(edge.source), targetPos = this._graph?.pos?.get(edge.target);
853
- if (!sourcePos || !targetPos) return;
854
- const oldWaypoints = edge.waypoints ? edge.waypoints.slice() : [];
855
- const waypoints = EdgeGeometry.edgeWaypointsWithInsertion(edge, sourcePos, targetPos, point);
856
- // Find where the new point landed in the recomputed array.
857
- let insertedIndex = -1;
858
- for (let i = 0; i < waypoints.length; i++) {
859
- if (i >= oldWaypoints.length || waypoints[i] !== oldWaypoints[i]) {
860
- insertedIndex = i;
861
- break;
862
- }
863
- }
864
- if (insertedIndex < 0) insertedIndex = waypoints.length - 1;
865
- // Keep edge selected after adding waypoint so endpoints remain visible
866
- this._selectedEdge = edge;
867
- this.history.execute(new AddWaypointCommand(edge, waypoints[insertedIndex], { diagram: this, index: insertedIndex }));
868
- }
869
- /** The nodes selected together (a selection box, Ctrl-click): their ids. */
870
- get selectedNodeIds() {
871
- return this.engine?._selectedNodeIds ?? new Set();
872
- }
873
-
874
- /** Select these nodes (or add them, with `extend`), and show it. */
875
- selectNodes(ids, { extend = false } = {}) {
876
- if (!this.engine) return;
877
- const next = extend ? new Set([...this.selectedNodeIds, ...ids]) : new Set(ids);
878
- this.engine._selectedNodeIds = next;
879
- this._showNodeSelection(this._graph?.nodeEls ?? []);
880
- }
881
-
882
- /** Mark the selected nodes' elements (an outline glow), and unmark the rest. @private */
883
- _showNodeSelection(nodeEls) {
884
- const selected = this.selectedNodeIds;
885
- if (!selected.size && !this._showingSelection) return; // nothing to mark or unmark
886
- this._showingSelection = selected.size > 0;
887
- for (const el of nodeEls) {
888
- if (typeof el?.getAttribute !== 'function') continue;
889
- const on = selected.has(el.getAttribute('nodeId'));
890
- el.classList?.toggle('multi-selected', on);
891
- el.style.filter = on ? 'drop-shadow(0 0 3px #0066cc) drop-shadow(0 0 1px #0066cc)' : '';
892
- }
893
- }
894
-
895
- /**
896
- * After a click on `target`: unless it is one of the selected line's
897
- * handles, the line is no longer selected and its handles go.
898
- * @returns {boolean} whether a line was deselected
899
- */
900
- deselectEdgeOnClick(target) {
901
- if (!this._selectedEdge || target?.closest?.(`[${EDGE_HANDLE_ATTR}]`)) return false;
902
- this._selectedEdge = null;
903
- this.renderer.svg?.querySelectorAll(`[${EDGE_HANDLE_ATTR}]`).forEach(n => n.remove());
904
- return true;
905
- }
906
-
907
- _updateGraphLayout() {
908
- if (!this._graph) return;
909
-
910
- const { nodes, edges, pos, nodeEls, edgeEls, nodeRadius: R, sizeDomain, sizeOptions } = this._graph;
911
- const defaultEdgeRouting = this._chartState?.options?.edgeRouting || 'straight';
912
-
913
- // Update node positions
914
- nodeEls.forEach(g => {
915
- const nodeId = g.getAttribute('nodeId');
916
- const p = pos.get(nodeId);
917
- g.setAttribute('transform', `translate(${p.x},${p.y})`);
918
- });
919
-
920
- const routingObstacles = nodes.map(n => {
921
- const p = pos.get(n.id);
922
- if (!p) return null;
923
- const size = NodeGeometry.nodeSize(n, R, sizeDomain, sizeOptions);
924
- return { id: n.id, x: p.x, y: p.y, r: Math.max(size.w, size.h) / 2 };
925
- }).filter(Boolean);
926
-
927
- // Update edge positions — routes through the same _computeEdgePath the
928
- // initial draw in graphDiagram() uses, so a live drag frame doesn't
929
- // visibly snap between two different geometry calculations.
930
- edges.forEach((edge, i) => {
931
- const edgeEl = edgeEls[i];
932
- if (!edgeEl) return;
933
- const sourceNode = pos.get(edge.source);
934
- const targetNode = pos.get(edge.target);
935
- if (!sourceNode || !targetNode) return;
936
- const sourceInfo = nodes.find(n => n.id === edge.source);
937
- const targetInfo = nodes.find(n => n.id === edge.target);
938
- const sourceSize = this.engine._nodeSize(sourceInfo, R, sizeDomain, sizeOptions);
939
- const targetSize = this.engine._nodeSize(targetInfo, R, sizeDomain, sizeOptions);
940
- const edgeObstacles = routingObstacles.filter(o => o.id !== edge.source && o.id !== edge.target);
941
- const { d } = EdgeGeometry.computeEdgePath(
942
- sourceInfo, sourceNode, sourceSize, targetInfo, targetNode, targetSize, edge, defaultEdgeRouting, edgeObstacles
943
- );
944
- edgeEl.setAttribute('d', d);
945
- });
946
- }
947
-
948
- /** Find the edge whose rendered path passes closest to (x,y), within
949
- * `tolerance` SVG units. Returns the edge object or null. Used to make
950
- * thin or angled lines easier to select/hover when the renderer's own
951
- * pixel-exact hitTest misses the actual stroke. */
952
- findNearestEdge(x, y, tolerance = 14) {
953
- if (!this._graph) return null;
954
- const { nodes, edges, pos, nodeRadius: R, sizeDomain, sizeOptions } = this._graph;
955
- const defaultEdgeRouting = this._chartState?.options?.edgeRouting || 'straight';
956
- const tol2 = tolerance * tolerance;
957
-
958
- const routingObstacles = nodes.map(n => {
959
- const p = pos.get(n.id);
960
- if (!p) return null;
961
- const size = NodeGeometry.nodeSize(n, R, sizeDomain, sizeOptions);
962
- return { id: n.id, x: p.x, y: p.y, r: Math.max(size.w, size.h) / 2 };
963
- }).filter(Boolean);
964
-
965
- let bestEdge = null;
966
- let bestDist2 = Infinity;
967
-
968
- for (const edge of edges) {
969
- const sourcePos = pos.get(edge.source);
970
- const targetPos = pos.get(edge.target);
971
- if (!sourcePos || !targetPos) continue;
972
- const sourceInfo = nodes.find(n => n.id === edge.source);
973
- const targetInfo = nodes.find(n => n.id === edge.target);
974
- const sourceSize = this.engine._nodeSize(sourceInfo, R, sizeDomain, sizeOptions);
975
- const targetSize = this.engine._nodeSize(targetInfo, R, sizeDomain, sizeOptions);
976
- const edgeObstacles = routingObstacles.filter(o => o.id !== edge.source && o.id !== edge.target);
977
- const { d } = EdgeGeometry.computeEdgePath(
978
- sourceInfo, sourcePos, sourceSize, targetInfo, targetPos, targetSize, edge, defaultEdgeRouting, edgeObstacles
979
- );
980
- const dist2 = EdgeGeometry.pathDistance2(d, x, y);
981
- if (dist2 < tol2 && dist2 < bestDist2) {
982
- bestDist2 = dist2;
983
- bestEdge = edge;
984
- }
985
- }
986
- return bestEdge;
987
- }
988
-
989
- _getContextMenu() {
990
- if (!this._contextMenu && this.engine?.container) {
991
- this._contextMenu = new ContextMenu(this.engine.container);
992
- }
993
- return this._contextMenu;
994
- }
995
-
996
- _showNodeContextMenu(event, nodeId) {
997
- const node = this._graph.nodes.find(n => n.id === nodeId);
998
- if (!node) return;
999
-
1000
- const userItems = this._graph.contextMenuItems;
1001
- let menuItems;
1002
- if (userItems) {
1003
- menuItems = userItems;
1004
- } else {
1005
- menuItems = [
1006
- {
1007
- label: (node) => this._graphFixedPos.has(node.id) ? 'Unpin Node' : 'Pin Node',
1008
- action: (node) => {
1009
- this._graphFixedPos.has(node.id)
1010
- ? this._graphFixedPos.delete(node.id)
1011
- : this._graphFixedPos.add(node.id);
1012
- }
1013
- },
1014
- ...(this._graph.editable ? [{
1015
- label: 'Rename Node',
1016
- action: (node) => {
1017
- const next = window.prompt('Rename node:', node.label ?? '');
1018
- if (next == null) return;
1019
- const trimmed = next.trim();
1020
- if (trimmed === (node.label ?? '')) return;
1021
- this.history.execute(new RenameNodeCommand(
1022
- node.id, node.label ?? '', trimmed,
1023
- { diagram: this, onRename: this._chartState?.options?.onNodeRename }
1024
- ));
1025
- }
1026
- }] : []),
1027
- {
1028
- label: 'Show Details in Console',
1029
- action: (node) => console.log('Node Details:', node)
1030
- },
1031
- {
1032
- label: 'Hide Node',
1033
- action: (node) => {
1034
- this._hiddenGraphNodes.add(node.id);
1035
- this._rerenderGraph();
1036
- }
1037
- },
1038
- {
1039
- label: 'Delete Node',
1040
- action: (node) => {
1041
- const dangling = this._chartState.edges.filter(e => e.source === node.id || e.target === node.id);
1042
- this.history.execute(new DeleteNodeCommand(node, dangling, { diagram: this }));
1043
- }
1044
- }
1045
- ];
1046
-
1047
- if (this._hiddenGraphNodes.size > 0) {
1048
- menuItems.push({ separator: true });
1049
- menuItems.push({
1050
- label: 'Show All Hidden Nodes',
1051
- action: () => {
1052
- this._hiddenGraphNodes.clear();
1053
- this._rerenderGraph();
1054
- }
1055
- });
1056
- }
1057
- }
1058
-
1059
- this._getContextMenu()?.show(event, menuItems, node, (clientX, clientY) => SvgCoordinate.clientToSvgPoint(this.renderer?.svg, clientX, clientY), this.engine);
1060
- }
1061
- _showEdgeContextMenu(event, edge) {
1062
- const menuItems = this._graph.edgeContextMenuItems || [
1063
- {
1064
- label: 'Rename Edge',
1065
- action: (edge) => {
1066
- const next = window.prompt('Rename edge:', edge.label ?? '');
1067
- if (next == null) return;
1068
- const trimmed = next.trim();
1069
- if (trimmed === (edge.label ?? '')) return;
1070
- this.history.execute(new RenameEdgeCommand(
1071
- edge, edge.label ?? '', trimmed,
1072
- { diagram: this, onRename: this._chartState?.options?.onEdgeRename }
1073
- ));
1074
- }
1075
- },
1076
- {
1077
- label: 'Invert Direction',
1078
- action: (edge) => {
1079
- this.history.execute(new InvertEdgeCommand(
1080
- edge,
1081
- { diagram: this, onInvert: this._chartState?.options?.onEdgeInvert }
1082
- ));
1083
- }
1084
- },
1085
- // Where adding a bend point moved to once double-clicking the
1086
- // line itself was repurposed for onEdgeDoubleClick (see
1087
- // graphDiagram's own doc comment) — `point` is the right-click
1088
- // location _buildAndShowContextMenu now passes to every action.
1089
- { label: 'Add Waypoint Here', action: (edge, engine, point) => this._insertEdgeWaypoint(edge, point) },
1090
- {
1091
- label: 'Delete Edge',
1092
- action: (edge) => {
1093
- this.history.execute(new DeleteEdgeCommand(
1094
- edge,
1095
- { diagram: this, onDelete: this._chartState?.options?.onEdgeDelete }
1096
- ));
1097
- }
1098
- }
1099
- ];
1100
- this._getContextMenu()?.show(event, menuItems, edge, (clientX, clientY) => SvgCoordinate.clientToSvgPoint(this.renderer?.svg, clientX, clientY), this.engine);
1101
- }
1102
- _showCanvasContextMenu(event) {
1103
- const point = SvgCoordinate.clientToSvgPoint(this.renderer?.svg, event.clientX, event.clientY);
1104
- const menuItems = this._graph.canvasContextMenuItems || [
1105
- {
1106
- label: 'Add Node',
1107
- action: (point) => {
1108
- const label = window.prompt('New node label:', '');
1109
- if (label == null) return;
1110
- const trimmed = label.trim();
1111
- if (!trimmed) return;
1112
- const gridSize = this._chartState?.options?.gridSize ?? 0;
1113
- const id = this._generateNodeId(trimmed);
1114
- const newNode = {
1115
- id,
1116
- label: trimmed,
1117
- x: GraphDiagram.snapToGrid(point.x, gridSize),
1118
- y: GraphDiagram.snapToGrid(point.y, gridSize),
1119
- };
1120
- this.history.execute(new AddNodeCommand(
1121
- newNode,
1122
- { diagram: this, onAdd: this._chartState?.options?.onNodeAdd }
1123
- ));
1124
- }
1125
- },
1126
- {
1127
- label: 'Add Folder',
1128
- action: (point) => {
1129
- const name = window.prompt('New folder name:', 'New Folder');
1130
- if (name == null) return;
1131
- const trimmed = name.trim();
1132
- if (!trimmed) return;
1133
- const gridSize = this._chartState?.options?.gridSize ?? 0;
1134
- const folderId = `folder-${Math.random().toString(36).slice(2, 9)}`;
1135
- const newFolder = {
1136
- id: folderId,
1137
- name: trimmed,
1138
- x: GraphDiagram.snapToGrid(point.x, gridSize),
1139
- y: GraphDiagram.snapToGrid(point.y, gridSize),
1140
- width: 200,
1141
- height: 150,
1142
- collapsed: false,
1143
- };
1144
- // Add to folders in data
1145
- if (!this.data.folders) this.data.folders = [];
1146
- this.data.folders.push(newFolder);
1147
- this._rerenderGraph();
1148
- }
1149
- }
1150
- ];
1151
- this._getContextMenu()?.show(event, menuItems, point, (clientX, clientY) => SvgCoordinate.clientToSvgPoint(this.renderer?.svg, clientX, clientY), this.engine);
1152
- }
1153
- _onLegendClick(groupName) {
1154
- if (this._hiddenGraphGroups.has(groupName)) {
1155
- this._hiddenGraphGroups.delete(groupName);
1156
- } else {
1157
- this._hiddenGraphGroups.add(groupName);
1158
- }
1159
- this._rerenderGraph();
1160
- }
1161
-
1162
- /**
1163
- * Handle clicks on diagram chrome (e.g. the group legend).
1164
- * @param {MouseEvent} event
1165
- * @param {object} hitShape
1166
- */
1167
- onClick(event, hitShape) {
1168
- if (hitShape && hitShape.graphLegend) {
1169
- this._onLegendClick(hitShape.groupName);
1170
- }
1171
- }
1172
- filterGraphNodes(keyword) {
1173
- this._graphFilterKeyword = keyword ? String(keyword).trim() : '';
1174
- this._rerenderGraph();
1175
- }
1176
-
1177
- /** Snap a coordinate to the nearest grid multiple. `gridSize <= 0` means no
1178
- * snapping. Exposed as a static helper so drag/add-node code outside this
1179
- * class can reuse the same rounding rule. */
1180
- static snapToGrid(value, gridSize) {
1181
- if (!gridSize || gridSize <= 0) return value;
1182
- return Math.round(value / gridSize) * gridSize;
1183
- }
1184
-
1185
- /** A single path string covering a W×H area with a grid of `gridSize`
1186
- * spacing — rendered as one cheap background path. */
1187
- static _gridPath(W, H, gridSize) {
1188
- const parts = [];
1189
- for (let x = gridSize; x < W; x += gridSize) parts.push(`M${x},0 L${x},${H}`);
1190
- for (let y = gridSize; y < H; y += gridSize) parts.push(`M0,${y} L${W},${y}`);
1191
- return parts.join(' ');
1192
- }
1193
-
1194
- _rerenderGraph() {
1195
- // graphDiagram() reseeds every node's position from `n.x ?? Math.random()`
1196
- // on each call — nothing else ever writes the live/simulated/dragged
1197
- // position back onto the node object itself. Without carrying it
1198
- // forward here, a node being actively dragged would snap back to its
1199
- // pre-drag spot on the very next re-render (this is called on every
1200
- // drag mousemove), and any node without an explicit x/y would jump to
1201
- // a brand-new random position on every hide/show, delete, or legend
1202
- // toggle. Copying this._graph.pos back onto the node objects before
1203
- // re-invoking graphDiagram makes it a true "redraw with current state"
1204
- // instead of "reroll positions and redraw".
1205
- if (this._graph?.pos) {
1206
- for (const node of this._chartState.nodes) {
1207
- const p = this._graph.pos.get(node.id);
1208
- if (p) { node.x = p.x; node.y = p.y; }
1209
- }
1210
- }
1211
- this.data.nodes = this._chartState.nodes;
1212
- this.data.edges = this._chartState.edges;
1213
- this.options = this._chartState.options;
1214
- this.render();
1215
- }
1216
-
1217
- /** Render folder containers (before nodes so they appear behind) */
1218
- _renderFolders(svg, folders = []) {
1219
- if (!folders.length || !svg) return;
1220
- for (const folder of folders) {
1221
- this._renderFolderContainer(svg, folder);
1222
- }
1223
- }
1224
-
1225
- /** Render a single folder container with border, header, and collapse toggle */
1226
- _renderFolderContainer(svg, folder) {
1227
- // A group needs a place; 0 is one.
1228
- if (!Number.isFinite(folder.x) || !Number.isFinite(folder.y)) return;
1229
- const HEADER_HEIGHT = 24;
1230
- const BORDER_COLOR = this._dragOverFolderId === folder.id ? '#44aa44' : '#999';
1231
- const FILL_COLOR = this._dragOverFolderId === folder.id ? 'rgba(68, 170, 68, 0.15)' : 'rgba(150, 150, 200, 0.08)';
1232
- const isCollapsed = this._collapsedFolders.has(folder.id);
1233
-
1234
- // Folder background rectangle
1235
- const bgRect = this.renderer.rect(
1236
- folder.x, folder.y, folder.width || 150, folder.height || 100,
1237
- FILL_COLOR, { color: BORDER_COLOR, width: 1.5 }
1238
- );
1239
- svg.appendChild(bgRect);
1240
-
1241
- // Folder header bar
1242
- const headerRect = this.renderer.rect(
1243
- folder.x, folder.y, folder.width || 150, HEADER_HEIGHT,
1244
- 'rgba(150, 150, 200, 0.3)', { color: BORDER_COLOR, width: 1.5 }
1245
- );
1246
- headerRect.setAttribute('folderId', folder.id);
1247
- headerRect.setAttribute('class', 'folder-header');
1248
- headerRect.style.cursor = 'pointer';
1249
- svg.appendChild(headerRect);
1250
-
1251
- // Collapse/expand indicator
1252
- const indicator = isCollapsed ? '▶' : '▼';
1253
- const indicatorText = this.renderer.text(indicator, folder.x + 4, folder.y + 16,
1254
- { fontSize: '12px', fill: '#333', cursor: 'pointer' }
1255
- );
1256
- indicatorText.setAttribute('folderId', folder.id);
1257
- indicatorText.setAttribute('class', 'folder-toggle');
1258
- svg.appendChild(indicatorText);
1259
-
1260
- // Folder name label
1261
- const label = this.renderer.text(
1262
- LabelHelper.resolveNodeLabel({ label: folder.name }) || 'Folder',
1263
- folder.x + 22, folder.y + 16,
1264
- { fontSize: '12px', fontWeight: 'bold', fill: '#333', cursor: 'pointer' }
1265
- );
1266
- label.setAttribute('folderId', folder.id);
1267
- label.setAttribute('class', 'folder-label');
1268
- svg.appendChild(label);
1269
-
1270
- // Add click handlers
1271
- headerRect.addEventListener('click', (e) => this._onFolderHeaderClick(e, folder.id));
1272
- headerRect.addEventListener('dblclick', (e) => this._onFolderRename(e, folder));
1273
- indicatorText.addEventListener('click', (e) => this._onFolderHeaderClick(e, folder.id));
1274
- label.addEventListener('click', (e) => this._onFolderHeaderClick(e, folder.id));
1275
- label.addEventListener('dblclick', (e) => this._onFolderRename(e, folder));
1276
- }
1277
-
1278
- /** Handle folder header click — toggle collapse state */
1279
- _onFolderHeaderClick(event, folderId) {
1280
- event.stopPropagation();
1281
- if (this._collapsedFolders.has(folderId)) {
1282
- this._collapsedFolders.delete(folderId);
1283
- } else {
1284
- this._collapsedFolders.add(folderId);
1285
- }
1286
- this._rerenderGraph();
1287
- }
1288
-
1289
- /** Handle folder rename (double-click) */
1290
- _onFolderRename(event, folder) {
1291
- event.stopPropagation();
1292
- const newName = window.prompt('Rename folder:', folder.name || 'Folder');
1293
- if (newName == null) return;
1294
- const trimmed = newName.trim();
1295
- if (!trimmed || trimmed === folder.name) return;
1296
-
1297
- // Update folder name
1298
- folder.name = trimmed;
1299
- this._rerenderGraph();
1300
- }
1301
-
1302
- /** Return IDs of all nodes within a given folder (recursively including nested folders) */
1303
- _getNodesInFolder(folderId) {
1304
- const nodeIds = new Set();
1305
- const elementFolderMap = this._chartState?.options?.elementFolderMap;
1306
- if (!elementFolderMap) return nodeIds;
1307
-
1308
- const folders = this._chartState?.folders || [];
1309
-
1310
- // Find all elements in this folder directly
1311
- for (const [elementId, folderIdForElement] of elementFolderMap.entries()) {
1312
- if (folderIdForElement === folderId) {
1313
- nodeIds.add(elementId);
1314
- }
1315
- }
1316
-
1317
- // Recursively add elements in nested folders
1318
- for (const folder of folders) {
1319
- if (folder.parentId === folderId) {
1320
- for (const nodeId of this._getNodesInFolder(folder.id)) {
1321
- nodeIds.add(nodeId);
1322
- }
1323
- }
1324
- }
1325
-
1326
- return nodeIds;
1327
- }
1328
-
1329
- /** Return IDs of all nodes hidden due to collapsed folders */
1330
- _getHiddenByCollapsedFolders(folders) {
1331
- if (!folders.length && !this._collapsedFolders.size) return new Set();
1332
- const hidden = new Set();
1333
- for (const folderId of this._collapsedFolders) {
1334
- const nodesInFolder = this._getNodesInFolder(folderId);
1335
- for (const nodeId of nodesInFolder) {
1336
- hidden.add(nodeId);
1337
- }
1338
- }
1339
- return hidden;
1340
- }
1341
-
1342
- }
1343
-
1344
- /**
1345
- * A font size in pixels: a number, "12px" or "9pt" (1pt = 4/3 px). Undefined when there is none.
1346
- * @param {number|string} [size]
1347
- * @returns {number|undefined}
1348
- */
1349
- /** A font size in pixels (TextStyle.pixels: points are 4/3 of a pixel). */
1350
- GraphDiagram.fontPixels = (size) => TextStyle.pixels(size);
1
+ import { TextStyle } from '../TextStyle.js';
2
+ import { Diagram } from '../Diagram.js';
3
+ import { CommandHistory } from '../CommandHistory.js';
4
+ import { GraphMetrics } from './GraphMetrics.js';
5
+ import { LabelHelper } from './LabelHelper.js';
6
+ import { NodeGeometry } from './NodeGeometry.js';
7
+ import { SvgCoordinate } from './SvgCoordinate.js';
8
+ import { EdgeGeometry } from './EdgeGeometry.js';
9
+ import { ContextMenu } from './ContextMenu.js';
10
+ import { GridOverlay } from './GridOverlay.js';
11
+ import { LINE_STYLE_DASH } from '../edgeStyle/EdgeStyleSchema.js';
12
+ import {
13
+ AddNodeCommand, DeleteNodeCommand,
14
+ AddEdgeCommand, DeleteEdgeCommand, InvertEdgeCommand,
15
+ RenameNodeCommand, RenameEdgeCommand,
16
+ AddWaypointCommand,
17
+ } from './commands/index.js';
18
+
19
+ /**
20
+ * GraphDiagram — renders an interactive 2D node/edge graph diagram.
21
+ */
22
+
23
+ /** Marks a selected line's end and bend-point handles (removed when the line is deselected). */
24
+ const EDGE_HANDLE_ATTR = 'data-edge-handle';
25
+
26
+ export class GraphDiagram extends Diagram {
27
+ /**
28
+ * Pure. Whether a node without a picture shows centred text: its icon text,
29
+ * or (standing in for a name drawn below the shape) its initials. A node
30
+ * whose name is written inside it needs no initials.
31
+ */
32
+ static showsInitials(node, defaultLabelPosition = 'below') {
33
+ if (node.icon) return true;
34
+ return (node.labelPosition || (node.shape === 'note' ? 'inside' : defaultLabelPosition)) !== 'inside';
35
+ }
36
+
37
+ static diagramType = 'graph';
38
+
39
+ constructor(renderer, nodes, edges, options = {}, engine = null, folders = []) {
40
+ super(renderer, { nodes, edges, folders }, options);
41
+ this.engine = engine;
42
+ this._chartState = { type: 'graph', nodes, edges, folders, options };
43
+ this._graph = null;
44
+ this._graphCollapsedNodes = new Set();
45
+ this._graphFilterKeyword = '';
46
+ this._graphFixedPos = new Set();
47
+ this._hiddenGraphNodes = new Set();
48
+ this._hiddenGraphGroups = new Set();
49
+ this._collapsedFolders = new Set(); // Track which folders are collapsed
50
+ // A host that re-renders the diagram hands in its own history so undo
51
+ // outlives this instance; that host then owns the undo/redo keys too.
52
+ this.hostOwnsHistory = !!options.history;
53
+ this.history = options.history ?? new CommandHistory({
54
+ maxSize: options.historySize ?? 200,
55
+ onChange: (canUndo, canRedo) => {
56
+ options?.onHistoryChange?.(canUndo, canRedo);
57
+ },
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Fields that are not caller "extra data" for tooltips, search and the
63
+ * properties dialog: the engine's own, plus the host's `internalKeys`
64
+ * (bookkeeping a host adds to the items it hands in).
65
+ * @param {'node'|'table'|'edge'} kind
66
+ * @returns {Set<string>}
67
+ */
68
+ structuralKeys(kind) {
69
+ const E = this.engine?.constructor ?? {};
70
+ const own = { node: E.NODE_STRUCTURAL_KEYS, table: E.TABLE_NODE_STRUCTURAL_KEYS, edge: E.EDGE_STRUCTURAL_KEYS }[kind];
71
+ return new Set([...(own ?? []), ...(this.options?.internalKeys ?? [])]);
72
+ }
73
+
74
+ _render() {
75
+ const { nodes, edges } = this.data;
76
+ const options = this.options;
77
+ this._chartState = { type: 'graph', nodes, edges, options };
78
+ if (!this.renderer || !this.renderer.supportsSceneGraph) {
79
+ console.warn('Advanced graph diagram is only supported in SVG renderer.');
80
+ return;
81
+ }
82
+ this.renderer.clear();
83
+
84
+ const { nodeRadius = 14, onNodeClick, onEdgeClick, legend: showLegend = false, colors = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452'], edgeColor: defaultEdgeColor = '#bdc3c7', contextMenuItems, edgeContextMenuItems, canvasContextMenuItems, editable = false } = options;
85
+ const { compactness = 1.0 } = options;
86
+ // Default auto-routing for edges that don't set their own `edge.routing`
87
+ // — 'straight' (default), 'curved', 'orthogonal', or 'polyline' (basic
88
+ // node-avoidance). See _computeEdgePath. Editable diagrams also let
89
+ // the user drag a manual bend point onto any edge (double-click to
90
+ // add/remove, drag to reposition — see _addDragListeners), which
91
+ // always wins over whatever routing style is set.
92
+ const { edgeRouting: defaultEdgeRouting = 'straight' } = options;
93
+ // Resize handle (see _addResizeListeners): off unless `resizable` is
94
+ // true (a node can still opt out/in individually via its own
95
+ // `node.resizable`), corner defaults to 'top-right', overridable per
96
+ // shape (`resizeCornerByShape: {shape: corner}`) or per node
97
+ // (`node.resizeCorner`) — see _nodeResizeCorner.
98
+ const { resizable = false, resizeCorner = 'top-right', resizeCornerByShape } = options;
99
+ // Where a node's icon (icon/iconUrl, see the nodes param doc above)
100
+ // sits: 'center' (default — replaces the initials) or 'corner' (a
101
+ // small badge, positioned like the resize handle via the same
102
+ // node > per-shape > diagram-wide precedence — see _nodeIconCorner).
103
+ // A node's own `node.iconPosition` overrides this diagram default.
104
+ const { iconPosition: defaultIconPosition = 'center', iconCorner = 'top-right', iconCornerByShape } = options;
105
+ // 'below' (default, the original caption-under-the-shape convention)
106
+ // or 'inside' (label left-aligned inside the box — see the render
107
+ // loop below). A node's own `node.labelPosition` overrides this.
108
+ const { labelPosition: defaultLabelPosition = 'below' } = options;
109
+ // Horizontal alignment of the label: 'left' | 'center' | 'right'.
110
+ // Default keeps the existing behaviour: centred for captions below the
111
+ // shape, left-aligned for wrapped labels inside a box. Per-node values
112
+ // override the diagram default.
113
+ const { textAlign: defaultTextAlign = null } = options;
114
+ // Lines over boxes (Archi's connection layer) instead of under them.
115
+ const { edgesOnTop = false } = options;
116
+ // Vertical alignment, only meaningful for `labelPosition: 'inside'`:
117
+ // 'top' | 'middle' | 'bottom'. Default 'top' preserves existing layout.
118
+ const { verticalAlign: defaultVerticalAlign = null } = options;
119
+ // `sizeBy: 'degree'` sizes nodes by connection count instead of the
120
+ // caller pre-populating `node.size` — see _computeNodeDegrees.
121
+ const { minRadius = nodeRadius, maxRadius = nodeRadius * 2.5, sizeScale = 'linear', sizeBy } = options;
122
+ // Same pattern, for edge thickness — `edge.weight` drives stroke width.
123
+ const { minEdgeWidth = 1.4, maxEdgeWidth = minEdgeWidth * 4, edgeWidthScale = 'linear' } = options;
124
+ // Grid: `gridSize` > 0 snaps user-driven positions (drag, add node) to
125
+ // this many SVG units; `showGrid` draws the grid behind the diagram.
126
+ const { gridSize = 0, showGrid = false } = options;
127
+
128
+ // Initialize GridOverlay for responsive grid rendering
129
+ if (showGrid && gridSize > 0) {
130
+ if (!this._gridOverlay) {
131
+ this._gridOverlay = new GridOverlay(this.renderer.container, { gridSize, color: '#e5e5e5', width: 0.5 });
132
+ } else {
133
+ this._gridOverlay.setGridSize(gridSize);
134
+ }
135
+ } else if (this._gridOverlay) {
136
+ this._gridOverlay.destroy();
137
+ this._gridOverlay = null;
138
+ }
139
+
140
+ const W = this.renderer.width, H = this.renderer.height;
141
+ const R = nodeRadius;
142
+ const PAD = R + 6;
143
+ // Degree is computed from the caller's own `edges`, not whatever
144
+ // `effectiveEdges` layout inference below may substitute — "number of
145
+ // connections" means the graph the caller described, not an internal
146
+ // tree-layout artifact.
147
+ const degreeMap = sizeBy === 'degree' ? GraphMetrics.computeNodeDegrees(nodes, edges) : null;
148
+ const sizeDomain = GraphMetrics.computeSizeDomain(nodes, { sizeBy, degreeMap });
149
+ const sizeOptions = { minRadius, maxRadius, sizeScale, sizeBy, degreeMap };
150
+
151
+ // A hierarchical-key node set (see _inferHierarchyEdges) replaces the
152
+ // caller's edges with the inferred parent/child structure (plus any
153
+ // "quasi-leaf" edges it chose to keep) and switches layout from
154
+ // force-directed to a top-down tree — the two layouts don't blend.
155
+ const hierarchyEdges = this._inferHierarchyEdges(nodes, edges);
156
+ const useTreeLayout = hierarchyEdges !== null;
157
+ const effectiveEdges = useTreeLayout ? hierarchyEdges : edges;
158
+ const childrenMap = useTreeLayout ? this._hierarchyChildrenMap(effectiveEdges) : null;
159
+ // Over effectiveEdges, not the caller's raw edges — this is the domain
160
+ // for what actually gets drawn below, and in tree-layout mode that's a
161
+ // synthesized edge set the caller's `.weight` values don't apply to.
162
+ const edgeWidthDomain = GraphMetrics.computeEdgeWidthDomain(effectiveEdges);
163
+ const edgeWidthOptions = { minEdgeWidth, maxEdgeWidth, widthScale: edgeWidthScale };
164
+ // Collapsed subtrees (toggled via each node's +/− badge, see nodeEls
165
+ // below) hide their descendants — not the collapsed node itself,
166
+ // whose badge must stay visible/clickable to re-expand it.
167
+ const hiddenByCollapse = useTreeLayout
168
+ ? this._collapsedDescendantIds(this._graphCollapsedNodes, childrenMap)
169
+ : new Set();
170
+ // Live keyword filter (see filterGraphNodes) — recomputed every render
171
+ // so typing in a search box just calls filterGraphNodes and re-renders.
172
+ const filterKeyword = (this._graphFilterKeyword || '').trim();
173
+ const hiddenByFilter = this._hiddenByKeywordFilter(nodes, filterKeyword, childrenMap);
174
+
175
+ // Initialize positions, respecting any fixed positions from the input
176
+ this._graphFixedPos = new Set(nodes.filter(n => n.x != null || n.y != null).map(n => n.id));
177
+ let pos;
178
+ if (useTreeLayout) {
179
+ pos = this._computeTreeLayout(nodes, effectiveEdges, W, H, PAD);
180
+ nodes.forEach(n => { // an explicit x/y still overrides the computed tree slot
181
+ if (this._graphFixedPos.has(n.id)) {
182
+ const p = pos.get(n.id);
183
+ if (n.x != null) p.x = n.x;
184
+ if (n.y != null) p.y = n.y;
185
+ }
186
+ });
187
+ } else {
188
+ pos = new Map(nodes.map(n => [n.id, {
189
+ x: n.x ?? (Math.random() * (W - 2 * PAD) + PAD),
190
+ y: n.y ?? (Math.random() * (H - 2 * PAD) + PAD),
191
+ vx: 0, vy: 0 }]));
192
+ const K_FR = Math.sqrt((W * H) / Math.max(nodes.length, 1)) * 0.9 * compactness;
193
+ const frStep = (temp) => {
194
+ nodes.forEach(n => { if (!this._graphFixedPos.has(n.id)) { const p = pos.get(n.id); p.vx = 0; p.vy = 0; } });
195
+ for (let i = 0; i < nodes.length; i++) {
196
+ for (let j = i + 1; j < nodes.length; j++) {
197
+ const a = pos.get(nodes[i].id), b = pos.get(nodes[j].id);
198
+ const dx = b.x - a.x, dy = b.y - a.y;
199
+ const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
200
+ const f = K_FR * K_FR / dist;
201
+ a.vx -= dx / dist * f; a.vy -= dy / dist * f;
202
+ b.vx += dx / dist * f; b.vy += dy / dist * f;
203
+ }
204
+ }
205
+ effectiveEdges.forEach(e => {
206
+ const a = pos.get(e.source), b = pos.get(e.target);
207
+ if (!a || !b) return;
208
+ const dx = b.x - a.x, dy = b.y - a.y;
209
+ const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
210
+ const f = (dist * dist) / (K_FR * 1.5);
211
+ a.vx += dx / dist * f; a.vy += dy / dist * f;
212
+ b.vx -= dx / dist * f; b.vy -= dy / dist * f;
213
+ });
214
+ nodes.forEach(n => {
215
+ if (this._graphFixedPos.has(n.id)) return;
216
+ const p = pos.get(n.id);
217
+ p.vx += (W / 2 - p.x) * 0.006;
218
+ p.vy += (H / 2 - p.y) * 0.006;
219
+ const mag = Math.sqrt(p.vx * p.vx + p.vy * p.vy) || 1;
220
+ const dt = Math.min(mag, temp) / mag;
221
+ p.x = Math.max(PAD, Math.min(W - PAD, p.x + p.vx * dt));
222
+ p.y = Math.max(PAD, Math.min(H - PAD, p.y + p.vy * dt));
223
+ });
224
+ };
225
+
226
+ if (!this._graph) { // Only run full simulation on initial draw
227
+ // Run simulation synchronously before drawing
228
+ for (let iter = 0; iter < 180; iter++) {
229
+ frStep(K_FR * (1 - iter / 180) * 0.25);
230
+ }
231
+ }
232
+ }
233
+
234
+ // Other nodes as circular obstacles for 'polyline' routing (see
235
+ // _polylineEdgePath) — computed once per render, source/target of
236
+ // each edge excluded per-edge below rather than filtered out here.
237
+ const routingObstacles = nodes.map(n => {
238
+ const p = pos.get(n.id);
239
+ if (!p) return null;
240
+ const size = NodeGeometry.nodeSize(n, R, sizeDomain, sizeOptions);
241
+ return { id: n.id, x: p.x, y: p.y, r: Math.max(size.w, size.h) / 2 };
242
+ }).filter(Boolean);
243
+
244
+ // The selected line's handles are drawn after the nodes: an end handle
245
+ // sits on a node's rim, and a node drawn over it would hide half of it.
246
+ const selectionHandles = [];
247
+ const svgRoot = this.renderer.svg;
248
+ const edgesFrom = svgRoot?.childNodes?.length ?? 0;
249
+ const edgeEls = effectiveEdges.map(edge => {
250
+ const sourceNode = pos.get(edge.source);
251
+ const targetNode = pos.get(edge.target);
252
+ if (!sourceNode || !targetNode) return null;
253
+ const sourceInfo = nodes.find(n => n.id === edge.source);
254
+ const targetInfo = nodes.find(n => n.id === edge.target);
255
+ const sourceSize = this.engine._nodeSize(sourceInfo, R, sizeDomain, sizeOptions);
256
+ const targetSize = this.engine._nodeSize(targetInfo, R, sizeDomain, sizeOptions);
257
+ const edgeObstacles = routingObstacles.filter(o => o.id !== edge.source && o.id !== edge.target);
258
+
259
+ const { x1, y1, x2, y2, d } = EdgeGeometry.computeEdgePath(
260
+ sourceInfo, sourceNode, sourceSize, targetInfo, targetNode, targetSize, edge, defaultEdgeRouting, edgeObstacles
261
+ );
262
+
263
+ const edgeColor = edge.color || defaultEdgeColor;
264
+ const arrowhead = edge.arrowhead || 'arrow'; // default to 'arrow'
265
+ const markerId = arrowhead === 'none' ? '' : this.renderer.markerUrl(arrowhead);
266
+ const edgeWidth = GraphMetrics.effectiveEdgeWidth(edge, 1.4, edgeWidthDomain, edgeWidthOptions);
267
+ const path = this.renderer.path(d, 'none', { color: edgeColor, width: edgeWidth }, { isEdge: true, edge, tooltip: this._edgeTooltipText(edge) });
268
+ if (edge.id) path.setAttribute('edgeId', edge.id);
269
+ if (edge.opacity != null && edge.opacity !== 1) {
270
+ path.setAttribute('opacity', String(edge.opacity));
271
+ }
272
+ if (markerId) path.setAttribute('marker-end', markerId);
273
+ // marker-start — a second, independent arrowhead/glyph at the
274
+ // SOURCE end (e.g. ArchiMate's composition/aggregation diamond,
275
+ // or assignment's filled ball, both drawn at the "whole"/source
276
+ // end while marker-end carries a different glyph or none at all).
277
+ // Existing edges that only ever set `arrowhead` are unaffected —
278
+ // this is purely additive.
279
+ if (edge.arrowheadStart && edge.arrowheadStart !== 'none') path.setAttribute('marker-start', this.renderer.markerUrl(edge.arrowheadStart));
280
+ // `lineStyle` ('solid'|'dashed'|'dotted') is the general form of
281
+ // the older boolean `edge.dashed` (kept working, still meaning
282
+ // 'dashed', for callers who never adopted lineStyle) — 'dotted'
283
+ // is the extra case neither `dashed` nor the old fixed pattern
284
+ // could express (ArchiMate's realization/access relationships).
285
+ const dashArray = LINE_STYLE_DASH[edge.lineStyle ?? (edge.dashed ? 'dashed' : 'solid')] ?? null;
286
+ if (dashArray) path.setAttribute('stroke-dasharray', dashArray);
287
+ // onEdgeClick was accepted as an option but never actually wired
288
+ // up to anything — direct DOM listener, same pattern node click
289
+ // already uses (bubbles fine regardless of hitTest).
290
+ if (onEdgeClick) path.style.cursor = 'pointer';
291
+ if (onEdgeClick) path.addEventListener('click', () => onEdgeClick(edge));
292
+
293
+ // Track edge selection for waypoint visibility (only show waypoints on selected edge)
294
+ path.addEventListener('click', (e) => {
295
+ e.stopPropagation(); // Prevent canvas deselection
296
+ this._selectedEdge = edge;
297
+ this._rerenderGraph(); // Redraw to update waypoint visibility
298
+ });
299
+
300
+ // A collapsed ancestor or the keyword filter hides some nodes
301
+ // (see nodeEls below) — hide their connecting edges too, or
302
+ // they'd dangle toward an invisible node instead of disappearing
303
+ // with it.
304
+ const edgeHidden = hiddenByCollapse.has(edge.source) || hiddenByCollapse.has(edge.target)
305
+ || hiddenByFilter.has(edge.source) || hiddenByFilter.has(edge.target);
306
+ if (edgeHidden) path.setAttribute('visibility', 'hidden');
307
+
308
+ if (edge.label) {
309
+ const midX = (x1 + x2) / 2;
310
+ const midY = (y1 + y2) / 2;
311
+ const defaultTooltip = `${sourceInfo.label} → ${targetInfo.label}: ${edge.label}`;
312
+
313
+ const labelEl = this.renderer.text(edge.label, midX, midY, { textAnchor: 'middle', fontSize: '9px', fill: '#636e72', paintOrder: 'stroke', stroke: '#fafbfc', 'stroke-width': '2px', 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter', tooltip: edge.tooltip || defaultTooltip });
314
+ if (edgeHidden) labelEl.setAttribute('visibility', 'hidden');
315
+ }
316
+
317
+ // Independent end labels — a UML association's multiplicity
318
+ // ('0..1', '1..*', ...) shown near EACH end, distinct from the
319
+ // one midpoint `edge.label` above (e.g. the relationship's own
320
+ // name) — see dataModelDiagram's 'uml' notation, though this is
321
+ // a plain graphDiagram field, not data-model-specific. Placed
322
+ // 18% of the way in from each anchor, not right at it, so the
323
+ // text doesn't sit on top of an arrowhead/cardinality glyph.
324
+ if (edge.sourceLabel) {
325
+ const lx = x1 + (x2 - x1) * 0.18, ly = y1 + (y2 - y1) * 0.18;
326
+ const labelEl = this.renderer.text(edge.sourceLabel, lx, ly, { textAnchor: 'middle', fontSize: '9px', fill: '#636e72', paintOrder: 'stroke', stroke: '#fafbfc', 'stroke-width': '2px', 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter' });
327
+ if (edgeHidden) labelEl.setAttribute('visibility', 'hidden');
328
+ }
329
+ if (edge.targetLabel) {
330
+ const lx = x2 + (x1 - x2) * 0.18, ly = y2 + (y1 - y2) * 0.18;
331
+ const labelEl = this.renderer.text(edge.targetLabel, lx, ly, { textAnchor: 'middle', fontSize: '9px', fill: '#636e72', paintOrder: 'stroke', stroke: '#fafbfc', 'stroke-width': '2px', 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter' });
332
+ if (edgeHidden) labelEl.setAttribute('visibility', 'hidden');
333
+ }
334
+
335
+ // Manual bend points (see _computeEdgePath's waypoints branch and
336
+ // the drag/double-click handlers in _addDragListeners) only need a
337
+ // visible/draggable handle in editable mode — a non-editable
338
+ // diagram still routes through them, it just can't gain new ones.
339
+ // Only show waypoints when this edge is selected (reduces clutter).
340
+ if (editable && this._selectedEdge === edge) selectionHandles.push(() => {
341
+ // Endpoint handles (source and target) — red circles, draggable to reposition on node rim
342
+ const endpointHandleColor = '#ff6b6b';
343
+ {
344
+ const sourceHandle = this.renderer.circle(x1, y1, 5, '#ffffff', { color: endpointHandleColor, width: 2 }, { draggable: true, isEndpoint: true, edge, endpointType: 'source' });
345
+ sourceHandle.style.cursor = 'grab';
346
+ sourceHandle.setAttribute(EDGE_HANDLE_ATTR, '');
347
+ if (edgeHidden) sourceHandle.setAttribute('visibility', 'hidden');
348
+
349
+ const targetHandle = this.renderer.circle(x2, y2, 5, '#ffffff', { color: endpointHandleColor, width: 2 }, { draggable: true, isEndpoint: true, edge, endpointType: 'target' });
350
+ targetHandle.style.cursor = 'grab';
351
+ targetHandle.setAttribute(EDGE_HANDLE_ATTR, '');
352
+ if (edgeHidden) targetHandle.setAttribute('visibility', 'hidden');
353
+ }
354
+
355
+ // Waypoint handles (blue circles for manual bends)
356
+ if (edge.waypoints?.length) {
357
+ edge.waypoints.forEach((wp, waypointIndex) => {
358
+ const handle = this.renderer.circle(wp.x, wp.y, 4, '#ffffff', { color: '#4f8ef7', width: 2 }, { draggable: true, isWaypoint: true, edge, waypointIndex });
359
+ handle.style.cursor = 'move';
360
+ handle.setAttribute(EDGE_HANDLE_ATTR, '');
361
+ if (edgeHidden) handle.setAttribute('visibility', 'hidden');
362
+ });
363
+ }
364
+ });
365
+ return path;
366
+ }).filter(Boolean);
367
+
368
+ // Draw edge-crossing bridges (small arcs where edges cross)
369
+ const edgePaths = edgeEls.map(el => el ? el.getAttribute('d') : null).filter(Boolean);
370
+ if (edgePaths.length > 1) {
371
+ // Detect all pairwise crossings
372
+ const allCrossings = [];
373
+ for (let i = 0; i < edgePaths.length; i++) {
374
+ for (let j = i + 1; j < edgePaths.length; j++) {
375
+ const crossings = EdgeGeometry.findEdgeCrossings(edgePaths[i], [edgePaths[j]]);
376
+ allCrossings.push(...crossings);
377
+ }
378
+ }
379
+
380
+ // Draw bridges at each crossing (small semicircle arcs)
381
+ for (const crossing of allCrossings) {
382
+ const bridgeRadius = 6;
383
+ const dx = Math.cos(crossing.angle);
384
+ const dy = Math.sin(crossing.angle);
385
+ const px = crossing.x - dy * bridgeRadius;
386
+ const py = crossing.y + dx * bridgeRadius;
387
+ const qx = crossing.x + dy * bridgeRadius;
388
+ const qy = crossing.y - dx * bridgeRadius;
389
+
390
+ const bridgePath = `M${px},${py} A${bridgeRadius},${bridgeRadius} 0 0,1 ${qx},${qy}`;
391
+ this.renderer.path(bridgePath, 'none', {
392
+ color: '#ffffff',
393
+ width: 8,
394
+ opacity: 0.8
395
+ });
396
+ // Draw a thin line on top for visibility
397
+ this.renderer.path(bridgePath, 'none', {
398
+ color: '#bdc3c7',
399
+ width: 2,
400
+ opacity: 1
401
+ });
402
+ }
403
+ }
404
+
405
+ const groups = new Map();
406
+ nodes.forEach(node => {
407
+ if (node.group && !groups.has(node.group)) {
408
+ groups.set(node.group, colors[groups.size % colors.length]);
409
+ }
410
+ });
411
+
412
+ // Get nodes hidden by collapsed folders
413
+ const { folders = [] } = this.data;
414
+ const hiddenByCollapsedFolders = this._getHiddenByCollapsedFolders(folders);
415
+
416
+ // Filter out nodes from hidden groups, collapsed subtrees, keyword filter, and collapsed folders
417
+ const visibleNodes = nodes.filter(node =>
418
+ (!node.group || !this._hiddenGraphGroups.has(node.group)) && !this._hiddenGraphNodes.has(node.id)
419
+ && !hiddenByCollapse.has(node.id) && !hiddenByFilter.has(node.id)
420
+ && !hiddenByCollapsedFolders.has(node.id)
421
+ );
422
+ const visibleNodeIds = new Set(visibleNodes.map(n => n.id));
423
+ const visibleEdges = effectiveEdges.filter(edge => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target));
424
+
425
+ const edgesTo = svgRoot?.childNodes?.length ?? 0;
426
+ // Render folders (visual containers) before nodes so they appear behind
427
+ this._renderFolders(this.renderer.svg, folders);
428
+
429
+ const nodeEls = nodes.map(node => {
430
+ const isHidden = this._hiddenGraphGroups.has(node.group) || this._hiddenGraphNodes.has(node.id)
431
+ || hiddenByCollapse.has(node.id) || hiddenByFilter.has(node.id)
432
+ || hiddenByCollapsedFolders.has(node.id);
433
+ const p = pos.get(node.id);
434
+
435
+ // Table nodes previously got just the bare label as a tooltip —
436
+ // real detail (column/row counts) already exists on the node
437
+ // object at this point but wasn't being surfaced on hover. Either
438
+ // way, any extra caller-supplied field not in the structural set
439
+ // (e.g. {owner:'Alice'}) is appended as its own "key: value" line.
440
+ const resolvedLabel = LabelHelper.resolveNodeLabel(node);
441
+ const tooltip = node.shape === 'table'
442
+ ? [
443
+ `${resolvedLabel} — ${node.columns?.length ?? 0} column${node.columns?.length === 1 ? '' : 's'}` +
444
+ (Number.isFinite(node.rowCount) ? ` · ${node.rowCount.toLocaleString()} row${node.rowCount === 1 ? '' : 's'}` : ''),
445
+ ...LabelHelper.nodeExtraDataLines(node, this.structuralKeys('table')),
446
+ ].join('\n')
447
+ : [`Node ${resolvedLabel}`, ...LabelHelper.nodeExtraDataLines(node, this.structuralKeys('node'))].join('\n');
448
+ const g = this.renderer.group({ transform: `translate(${p.x},${p.y})`, draggable: true, nodeId: node.id, tooltip });
449
+ if (node.opacity != null && node.opacity !== 1) {
450
+ g.setAttribute('opacity', String(node.opacity));
451
+ }
452
+
453
+ if (isHidden) {
454
+ g.setAttribute('visibility', 'hidden');
455
+ }
456
+
457
+ if (node.shape === 'table') {
458
+ NodeGeometry.buildTableNodeContent(this.renderer, g, node);
459
+ } else {
460
+ const nodeColor = node.color || (node.group ? groups.get(node.group) : (options.nodeColor || '#2ecc71'));
461
+ // A 'rect' node's own width/height (like a table node's, see
462
+ // _nodeSize) override the plain square-of-radius box every
463
+ // other shape still uses — needed for ArchiMate-style wider-
464
+ // than-tall boxes (archimateDiagram). nodeR (still used below
465
+ // for icon sizing/badge placement on non-rect shapes) is the
466
+ // radius that SAME square would have had, so nothing changes
467
+ // for a 'rect' node that never sets width/height.
468
+ const nodeSize = NodeGeometry.nodeSize(node, R, sizeDomain, sizeOptions);
469
+ const nodeR = Math.max(nodeSize.w, nodeSize.h) / 2;
470
+ const { el: shapeEl, suppressInner } = NodeGeometry.nodeShapeEl(this.renderer, node.shape, nodeR, nodeColor, nodeSize, node.cornerStyle, node.strokeColor, node.strokeWidth, node.iconUrl, node.icon);
471
+ if (node.strokeDasharray) shapeEl.setAttribute?.('stroke-dasharray', node.strokeDasharray);
472
+ const fillOp = node.fillOpacity ?? (node.alpha !== undefined && node.alpha !== null && node.alpha !== '' ? Number(node.alpha) / 255 : undefined);
473
+ if (fillOp !== undefined && fillOp !== 1 && shapeEl.setAttribute) shapeEl.setAttribute('fill-opacity', String(fillOp));
474
+ const strokeOp = node.strokeOpacity ?? (node.lineAlpha !== undefined && node.lineAlpha !== null && node.lineAlpha !== '' ? Number(node.lineAlpha) / 255 : undefined);
475
+ if (strokeOp !== undefined && strokeOp !== 1 && shapeEl.setAttribute) shapeEl.setAttribute('stroke-opacity', String(strokeOp));
476
+ if (node.gradient && node.gradient !== '0' && node.gradient !== 0 && nodeColor !== 'none' && typeof this.renderer.linearGradient === 'function') {
477
+ const dir = String(node.gradient);
478
+ const coords = dir === '2' ? { x1: '100%', y1: '0%', x2: '0%', y2: '0%' }
479
+ : dir === '3' ? { x1: '0%', y1: '100%', x2: '0%', y2: '0%' }
480
+ : dir === '4' ? { x1: '0%', y1: '0%', x2: '100%', y2: '0%' }
481
+ : { x1: '0%', y1: '0%', x2: '0%', y2: '100%' };
482
+ const gradId = `grad-${node.id || Math.random().toString(36).slice(2)}`;
483
+ const stops = [
484
+ { offset: '0%', color: '#ffffff', opacity: 0.45 },
485
+ { offset: '100%', color: nodeColor, opacity: fillOp ?? 1 },
486
+ ];
487
+ const fillUrl = this.renderer.linearGradient(gradId, stops, coords);
488
+ if (fillUrl && shapeEl.setAttribute) shapeEl.setAttribute('fill', fillUrl);
489
+ }
490
+ g.appendChild(shapeEl);
491
+
492
+ // `iconShape` — a shape NAME (any _nodeShapeEl one) drawn
493
+ // small in the corner as line art, instead of an emoji/image
494
+ // badge. This is how the real ArchiMate notation works: the
495
+ // corner badge is the element's own standalone glyph at badge
496
+ // size (see archimateDiagram/_archimateBuildNode), so one
497
+ // vector glyph serves both. Drawn unfilled with a dark
498
+ // outline so it reads on top of the node's own fill.
499
+ // The icon is drawn in the outline colour; with no outline ('none') it stays black, as in Archi.
500
+ const markColor = node.strokeColor === 'none' ? '#000000' : node.strokeColor;
501
+ if (node.iconShape) {
502
+ const badge = Math.max(10, Math.round(Math.min(nodeSize.w, nodeSize.h) * 0.3));
503
+ const corner = NodeGeometry.nodeIconCorner(node, iconCornerByShape, iconCorner);
504
+ const cp = NodeGeometry.cornerPoint({ x: 0, y: 0 }, nodeSize, corner);
505
+ const inset = badge / 2 + 3;
506
+ const bx = cp.x + (corner.includes('right') ? -inset : inset);
507
+ const by = cp.y + (corner.includes('bottom') ? -inset : inset);
508
+ const { el: badgeEl } = NodeGeometry.nodeShapeEl(
509
+ this.renderer, node.iconShape, badge / 2, 'none', { w: badge, h: badge * 0.78 }, undefined,
510
+ markColor || '#000000', 1,
511
+ );
512
+ badgeEl.setAttribute('transform', `translate(${bx},${by})`);
513
+ badgeEl.setAttribute('pointer-events', 'none');
514
+ g.appendChild(badgeEl);
515
+ }
516
+
517
+ const iconPosition = node.iconPosition || defaultIconPosition;
518
+ // A custom icon (an emoji/glyph string, or an image/SVG file via
519
+ // iconUrl) — centred on the shape (replacing the plain initials,
520
+ // 'center', the default) or a small corner badge ('corner', see
521
+ // _nodeIconCorner) alongside whatever else is in the box.
522
+ // suppressInner still gates 'center' mode only — a 'person'
523
+ // shape (which draws its own head/body glyph) stays uncluttered
524
+ // there, but a corner badge sits well clear of that glyph.
525
+ if (iconPosition === 'corner' && (node.icon || node.iconUrl)) {
526
+ // Sized off the box's SMALLER dimension, not nodeR (which
527
+ // is max(w,h)/2 — for a wide-but-short ArchiMate-style
528
+ // box that would badly oversize the corner badge).
529
+ const badgeSize = Math.max(12, Math.round(Math.min(nodeSize.w, nodeSize.h) * 0.3));
530
+ const corner = NodeGeometry.nodeIconCorner(node, iconCornerByShape, iconCorner);
531
+ const cp = NodeGeometry.cornerPoint({ x: 0, y: 0 }, nodeSize, corner);
532
+ const inset = badgeSize / 2 + 2; // keep the badge inside the box, not straddling its edge
533
+ const bx = cp.x + (corner.includes('right') ? -inset : inset);
534
+ const by = cp.y + (corner.includes('bottom') ? -inset : inset);
535
+ if (node.iconUrl) {
536
+ g.appendChild(this.renderer.image(node.iconUrl, bx - badgeSize / 2, by - badgeSize / 2, badgeSize, badgeSize, { 'pointer-events': 'none' }));
537
+ } else {
538
+ // Badge colour follows the node's own outline colour —
539
+ // white reads on plain graphDiagram's saturated node
540
+ // fills, but vanishes on an ArchiMate box's pastel one
541
+ // (which sets strokeColor: '#000').
542
+ g.appendChild(this.renderer.text(node.icon, bx, by, { fill: markColor || '#fff', textAnchor: 'middle', verticalAlign: 'middle', fontSize: `${badgeSize}px`, 'pointer-events': 'none' }));
543
+ }
544
+ // A node with a real glyph badge (iconShape, drawn above)
545
+ // must not ALSO get the centred initials fallback below —
546
+ // that's what put a stray "RE"/"CA" in the middle of every
547
+ // ArchiMate box once emoji badges gave way to glyphs.
548
+ } else if (!suppressInner && !node.iconShape && (!node.viewObject || node.iconUrl)) {
549
+ // (A view-only box shows its own text: no initials, but its picture if it has one.)
550
+ if (node.iconUrl) {
551
+ const iconSize = nodeR * 1.3;
552
+ g.appendChild(this.renderer.image(node.iconUrl, -iconSize / 2, -iconSize / 2, iconSize, iconSize, { 'pointer-events': 'none' }));
553
+ } else if (GraphDiagram.showsInitials(node, defaultLabelPosition)) {
554
+ const centerText = node.icon || resolvedLabel.slice(0, 2).toUpperCase() || (node.id ?? '').slice(0, 2).toUpperCase();
555
+ const fontSize = node.icon ? Math.max(12, Math.round(nodeR * 1.1)) : 10;
556
+ g.appendChild(this.renderer.text(centerText, 0, 0, { fill: '#fff', textAnchor: 'middle', verticalAlign: 'middle', fontSize: `${fontSize}px`, fontWeight: node.icon ? 'normal' : 'bold', 'pointer-events': 'none' }));
557
+ }
558
+ }
559
+
560
+ // 'below' (default) keeps the original single-line caption-
561
+ // under-the-shape convention; 'inside' (ArchiMate-style
562
+ // boxes) left-aligns the label near the box's own top-left
563
+ // instead, WORD-WRAPPED (_wrapLabelLines) to as many lines as
564
+ // fit inside the real box (nodeSize, not the icon-sizing
565
+ // nodeR above — a wide-but-short rect must not push it far
566
+ // below) rather than a single line overflowing the edges.
567
+ // Image objects draw their own visual, so they skip labels.
568
+ if (node.shape !== 'image') {
569
+ const labelPosition = node.labelPosition || (node.shape === 'note' ? 'inside' : defaultLabelPosition);
570
+ const inside = labelPosition === 'inside';
571
+ const textAlign = node.textAlign || defaultTextAlign || (inside ? 'left' : 'center');
572
+ const verticalAlign = node.verticalAlign || defaultVerticalAlign || 'top';
573
+ const anchorByAlign = { left: 'start', center: 'middle', right: 'end' };
574
+ const textAnchor = anchorByAlign[textAlign] || 'start';
575
+ if (inside) {
576
+ // The node's own font when it has one (an Archi import: points, as Archi sizes them).
577
+ const FONT_SIZE = GraphDiagram.fontPixels(node.fontSize) ?? 10;
578
+ const PAD = 6, LINE_HEIGHT = Math.round(FONT_SIZE * 1.2);
579
+ const font = {
580
+ fill: node.textColor || '#222', fontSize: `${FONT_SIZE}px`,
581
+ ...(node.fontFamily && { fontFamily: node.fontFamily }),
582
+ ...(node.fontWeight && { fontWeight: node.fontWeight }),
583
+ ...(node.fontStyle && { fontStyle: node.fontStyle }),
584
+ };
585
+ // A badge in a top corner takes its width off that side of the text, as in Archi.
586
+ const hasBadge = node.iconShape || (iconPosition === 'corner' && (node.icon || node.iconUrl));
587
+ const badgeCorner = hasBadge ? NodeGeometry.nodeIconCorner(node, iconCornerByShape, iconCorner) : '';
588
+ const badgeWidth = hasBadge && badgeCorner.includes('top') ? Math.max(12, Math.round(Math.min(nodeSize.w, nodeSize.h) * 0.3)) + 4 : 0;
589
+ const insetLeft = badgeCorner.includes('left') ? badgeWidth : 0;
590
+ const insetRight = badgeCorner.includes('right') ? badgeWidth : 0;
591
+ const lines = LabelHelper.wrapLabelLines(resolvedLabel, nodeSize.w - PAD * 2 - insetLeft - insetRight, FONT_SIZE, nodeSize.h - PAD * 2, LINE_HEIGHT,
592
+ this.renderer.measureText ? (s) => this.renderer.measureText(s, font) : null);
593
+ const { x, baseline: y0 } = TextStyle.block({ textAlign, textPosition: verticalAlign },
594
+ { left: -nodeSize.w / 2 + insetLeft, right: nodeSize.w / 2 - insetRight, top: -nodeSize.h / 2, bottom: nodeSize.h / 2 },
595
+ lines.length, { pixels: FONT_SIZE, lineHeight: LINE_HEIGHT, pad: PAD });
596
+ lines.forEach((line, i) => {
597
+ g.appendChild(this.renderer.text(line, x, y0 + i * LINE_HEIGHT, { ...font, textAnchor, 'pointer-events': 'none' }));
598
+ });
599
+ } else {
600
+ // nodeSize.h/2 (the shape's own real bottom edge), not
601
+ // nodeR (max(w,h)/2 — for a wide-but-short shape, e.g. any
602
+ // archimateDiagram distinct notation shape at its usual
603
+ // 120x55-ish box footprint, nodeR badly overshoots the
604
+ // shape's actual visual height, sitting the label so far
605
+ // down it lands on top of the NEXT row in a dense grid
606
+ // instead of just under this node). For a shape that
607
+ // ignores non-square `size` entirely (person/diamond/the
608
+ // plain default circle — always rendered from R alone),
609
+ // nodeSize.h still equals 2*R when no explicit
610
+ // width/height override is set, so this is a no-op there.
611
+ let x;
612
+ if (textAlign === 'left') x = -nodeSize.w / 2;
613
+ else if (textAlign === 'right') x = nodeSize.w / 2;
614
+ else x = 0;
615
+ g.appendChild(this.renderer.text(resolvedLabel, x, nodeSize.h / 2 + 9, { fill: '#333', textAnchor, fontSize: '9px', 'pointer-events': 'none' }));
616
+ }
617
+ }
618
+
619
+ // Hierarchical-key mode only: a +/− badge on any node with
620
+ // children toggles its subtree's visibility (_graphCollapsedNodes,
621
+ // read back into hiddenByCollapse above on the next render).
622
+ // stopPropagation keeps this click from also bubbling to the
623
+ // node's own onNodeClick listener below.
624
+ if (childrenMap?.get(node.id)?.length) {
625
+ const collapsed = this._graphCollapsedNodes.has(node.id);
626
+ const badgeY = nodeR + 20; // below the label (at nodeR+9) so it doesn't overlap the node circle or its text
627
+ const badge = this.renderer.circle(0, badgeY, 6, '#ffffff', { color: '#888', width: 1.2 });
628
+ const badgeGlyph = this.renderer.text(collapsed ? '+' : '−', 0, badgeY, {
629
+ textAnchor: 'middle', verticalAlign: 'middle', fontSize: '9px', fontWeight: 'bold', fill: '#555', 'pointer-events': 'none',
630
+ });
631
+ badge.style.cursor = 'pointer';
632
+ badge.addEventListener('click', (event) => {
633
+ event.stopPropagation();
634
+ if (collapsed) this._graphCollapsedNodes.delete(node.id);
635
+ else this._graphCollapsedNodes.add(node.id);
636
+ this._rerenderGraph();
637
+ });
638
+ g.appendChild(badge);
639
+ g.appendChild(badgeGlyph);
640
+ }
641
+ }
642
+
643
+ if (onNodeClick) {
644
+ g.addEventListener('click', () => onNodeClick(node));
645
+ }
646
+ return g;
647
+ });
648
+ if (edgesOnTop && svgRoot?.childNodes) {
649
+ // Re-append the lines (and their labels and bridges), in order, above every box.
650
+ for (const el of Array.from(svgRoot.childNodes).slice(edgesFrom, edgesTo)) svgRoot.appendChild(el);
651
+ }
652
+ for (const draw of selectionHandles) draw();
653
+
654
+ if (showLegend && groups.size > 0) {
655
+ const legendX = W - 120;
656
+ let legendY = 40;
657
+ const legendItemHeight = 20;
658
+ const legendMarkerRadius = 6;
659
+
660
+ const legendGroup = this.renderer.group({ class: 'legend' });
661
+
662
+ groups.forEach((color, groupName) => {
663
+ const isHidden = this._hiddenGraphGroups.has(groupName);
664
+ const legendItemAttrs = { legendItem: true, graphLegend: true, groupName };
665
+ const itemColor = isHidden ? '#ccc' : color;
666
+ const textColor = isHidden ? '#ccc' : '#333';
667
+
668
+ const marker = this.renderer.circle(legendX, legendY, legendMarkerRadius, itemColor, { color: 'white', width: 1.5 }, legendItemAttrs);
669
+ const label = this.renderer.text(groupName, legendX + legendMarkerRadius + 8, legendY, {
670
+ textAnchor: 'start',
671
+ verticalAlign: 'middle',
672
+ fontSize: '12px',
673
+ fill: textColor,
674
+ ...legendItemAttrs
675
+ });
676
+ marker.addEventListener('click', () => this._onLegendClick(groupName));
677
+ label.addEventListener('click', () => this._onLegendClick(groupName));
678
+ legendY += legendItemHeight;
679
+ });
680
+ }
681
+
682
+ // A click anywhere but on the selected line's handles deselects it: empty canvas, a shape, the
683
+ // grid, a group. Listened in the capture phase (a shape stops its own click from bubbling); a
684
+ // click on a line selects that one (its own listener). The handles are removed in place rather
685
+ // than by redrawing, which would replace a shape under a double-click.
686
+ if (this.renderer.svg) {
687
+ this.renderer.svg.removeEventListener('click', this._handleCanvasClick, true);
688
+ this._handleCanvasClick = (e) => this.deselectEdgeOnClick(e.target);
689
+ this.renderer.svg.addEventListener('click', this._handleCanvasClick, true);
690
+ }
691
+
692
+ // The engine keeps the selection (several nodes), so it outlives a redraw.
693
+ this._showNodeSelection(nodeEls);
694
+ this._wireGraphHover(nodes, effectiveEdges, nodeEls, edgeEls);
695
+
696
+ this._graph = {
697
+ nodes, edges: visibleEdges, pos, nodeEls, edgeEls, nodeRadius: R, groups,
698
+ contextMenuItems, edgeContextMenuItems, canvasContextMenuItems, editable,
699
+ sizeDomain, sizeOptions,
700
+ resizableDefault: resizable, resizeCorner, resizeCornerByShape,
701
+ };
702
+ }
703
+
704
+ /**
705
+ * Wire interactive hover elevation and 1-hop neighborhood glow on graph/ArchiMate/data-model
706
+ * nodes and edges without touching node position transforms or EmphasisRule opacity attributes.
707
+ */
708
+ _wireGraphHover(nodes, effectiveEdges, nodeEls, edgeEls) {
709
+ if (this.renderer?.supportsSceneGraph !== true || this.options?.hoverHighlight === false) return;
710
+ const nodeElById = new Map();
711
+ nodes.forEach((n, i) => {
712
+ if (nodeEls[i]) nodeElById.set(n.id, nodeEls[i]);
713
+ });
714
+
715
+ const clearHover = () => {
716
+ for (const g of nodeEls) {
717
+ if (g?.style) g.style.filter = '';
718
+ }
719
+ for (const p of edgeEls) {
720
+ if (p?.style) p.style.filter = '';
721
+ }
722
+ };
723
+
724
+ nodes.forEach((node, idx) => {
725
+ const g = nodeEls[idx];
726
+ if (typeof g?.addEventListener !== 'function') return;
727
+ if (g.style) g.style.transition = 'filter 0.18s ease';
728
+ g.addEventListener('mouseenter', () => {
729
+ if (g.style) g.style.filter = 'drop-shadow(0 5px 10px rgba(43, 95, 217, 0.34))';
730
+ effectiveEdges.forEach((edge, eIdx) => {
731
+ if (edge.source !== node.id && edge.target !== node.id) return;
732
+ const p = edgeEls[eIdx];
733
+ if (p?.style) p.style.filter = 'drop-shadow(0 0 4px rgba(43, 95, 217, 0.5))';
734
+ const neighborId = edge.source === node.id ? edge.target : edge.source;
735
+ const neighborEl = nodeElById.get(neighborId);
736
+ if (neighborEl?.style && neighborEl !== g) {
737
+ neighborEl.style.filter = 'drop-shadow(0 3px 7px rgba(43, 95, 217, 0.24))';
738
+ }
739
+ });
740
+ });
741
+ g.addEventListener('mouseleave', clearHover);
742
+ });
743
+
744
+ effectiveEdges.forEach((edge, eIdx) => {
745
+ const p = edgeEls[eIdx];
746
+ if (typeof p?.addEventListener !== 'function') return;
747
+ if (p.style) p.style.transition = 'filter 0.18s ease';
748
+ p.addEventListener('mouseenter', () => {
749
+ if (p.style) p.style.filter = 'drop-shadow(0 0 5px rgba(43, 95, 217, 0.55))';
750
+ const sEl = nodeElById.get(edge.source);
751
+ const tEl = nodeElById.get(edge.target);
752
+ if (sEl?.style) sEl.style.filter = 'drop-shadow(0 4px 8px rgba(43, 95, 217, 0.28))';
753
+ if (tEl?.style) tEl.style.filter = 'drop-shadow(0 4px 8px rgba(43, 95, 217, 0.28))';
754
+ });
755
+ p.addEventListener('mouseleave', clearHover);
756
+ });
757
+ }
758
+
759
+ static inferHierarchyEdges(nodes, explicitEdges = []) {
760
+ const HIER_KEY_RE = /^\d+(?:[./-]\d+)+$/;
761
+ if (!nodes.some(n => HIER_KEY_RE.test(String(n.id)))) return null;
762
+ const isHierId = (id) => /^\d+(?:[./-]\d+)*$/.test(String(id));
763
+ const nodeIds = new Set(nodes.map(n => n.id));
764
+ const edges = [];
765
+ const seen = new Set();
766
+ nodes.forEach(n => {
767
+ const m = String(n.id).match(/^(.+)[./-]\d+$/);
768
+ const parentId = m ? m[1] : null;
769
+ if (parentId && nodeIds.has(parentId)) {
770
+ edges.push({ source: parentId, target: n.id });
771
+ seen.add(`${parentId}\u0000${n.id}`);
772
+ }
773
+ });
774
+ explicitEdges.forEach(e => {
775
+ if (isHierId(e.source) && isHierId(e.target)) return; // superseded by the inferred parent/child above
776
+ const key = `${e.source}\u0000${e.target}`;
777
+ if (seen.has(key)) return;
778
+ seen.add(key);
779
+ edges.push(e);
780
+ });
781
+ return edges;
782
+ }
783
+ _inferHierarchyEdges(nodes, explicitEdges = []) {
784
+ return GraphDiagram.inferHierarchyEdges(nodes, explicitEdges);
785
+ }
786
+
787
+ static hierarchyChildrenMap(edges) {
788
+ const map = new Map();
789
+ edges.forEach(e => {
790
+ if (!map.has(e.source)) map.set(e.source, []);
791
+ map.get(e.source).push(e.target);
792
+ });
793
+ return map;
794
+ }
795
+ _hierarchyChildrenMap(edges) {
796
+ return GraphDiagram.hierarchyChildrenMap(edges);
797
+ }
798
+
799
+ static collapsedDescendantIds(collapsedIds, childrenMap) {
800
+ const hidden = new Set();
801
+ const visit = (id) => {
802
+ for (const child of (childrenMap.get(id) || [])) {
803
+ if (!hidden.has(child)) { hidden.add(child); visit(child); }
804
+ }
805
+ };
806
+ collapsedIds.forEach(visit);
807
+ return hidden;
808
+ }
809
+ _collapsedDescendantIds(collapsedIds, childrenMap) {
810
+ return GraphDiagram.collapsedDescendantIds(collapsedIds, childrenMap);
811
+ }
812
+
813
+ static computeTreeLayout(nodes, edges, W, H, PAD) {
814
+ const childrenMap = GraphDiagram.hierarchyChildrenMap(edges);
815
+ const hasParent = new Set(edges.map(e => e.target));
816
+ const roots = nodes.filter(n => !hasParent.has(n.id)).map(n => n.id);
817
+
818
+ const depth = new Map();
819
+ const leafOrder = [];
820
+ const visited = new Set();
821
+ const visit = (id, d) => {
822
+ if (visited.has(id)) return;
823
+ visited.add(id);
824
+ depth.set(id, d);
825
+ const kids = childrenMap.get(id) || [];
826
+ if (kids.length === 0) leafOrder.push(id);
827
+ else kids.forEach(k => visit(k, d + 1));
828
+ };
829
+ roots.forEach(id => visit(id, 0));
830
+ nodes.forEach(n => { if (!visited.has(n.id)) visit(n.id, 0); }); // any node an edge cycle left unreached becomes its own root
831
+
832
+ const slot = new Map();
833
+ leafOrder.forEach((id, i) => slot.set(id, i));
834
+ const computeSlot = (id, seen) => {
835
+ if (slot.has(id)) return slot.get(id);
836
+ if (seen.has(id)) return 0; // guards a malformed/cyclic edge set
837
+ seen.add(id);
838
+ const kids = childrenMap.get(id) || [];
839
+ const s = kids.reduce((sum, k) => sum + computeSlot(k, seen), 0) / kids.length;
840
+ slot.set(id, s);
841
+ return s;
842
+ };
843
+ nodes.forEach(n => computeSlot(n.id, new Set()));
844
+
845
+ const maxDepth = Math.max(0, ...Array.from(depth.values()));
846
+ const numLeaves = Math.max(1, leafOrder.length);
847
+ const pos = new Map();
848
+ nodes.forEach(n => {
849
+ const d = depth.get(n.id) ?? 0;
850
+ const s = slot.get(n.id) ?? 0;
851
+ const x = numLeaves === 1 ? W / 2 : PAD + (s / (numLeaves - 1)) * (W - 2 * PAD);
852
+ const y = maxDepth === 0 ? H / 2 : PAD + (d / maxDepth) * (H - 2 * PAD);
853
+ pos.set(n.id, { x, y, vx: 0, vy: 0 });
854
+ });
855
+ return pos;
856
+ }
857
+ _computeTreeLayout(nodes, edges, W, H, PAD) {
858
+ return GraphDiagram.computeTreeLayout(nodes, edges, W, H, PAD);
859
+ }
860
+
861
+ static nodeMatchesKeyword(node, keyword, { nodeStructuralKeys, tableNodeStructuralKeys }) {
862
+ const structuralKeys = node.shape === 'table' ? tableNodeStructuralKeys : nodeStructuralKeys;
863
+ const haystack = [LabelHelper.resolveNodeLabel(node), ...LabelHelper.nodeExtraDataLines(node, structuralKeys)].join(' ').toLowerCase();
864
+ return haystack.includes(String(keyword).toLowerCase());
865
+ }
866
+ _nodeMatchesKeyword(node, keyword) {
867
+ return GraphDiagram.nodeMatchesKeyword(node, keyword, {
868
+ nodeStructuralKeys: this.structuralKeys('node'),
869
+ tableNodeStructuralKeys: this.structuralKeys('table'),
870
+ });
871
+ }
872
+
873
+ static hiddenByKeywordFilter(nodes, keyword, childrenMap = null, { nodeStructuralKeys, tableNodeStructuralKeys }) {
874
+ const hidden = new Set();
875
+ if (!keyword) return hidden;
876
+ const matchedIds = nodes.filter(n => GraphDiagram.nodeMatchesKeyword(n, keyword, { nodeStructuralKeys, tableNodeStructuralKeys })).map(n => n.id);
877
+ let keepVisible;
878
+ if (childrenMap) {
879
+ const parentOf = new Map();
880
+ childrenMap.forEach((kids, parent) => kids.forEach(kid => parentOf.set(kid, parent)));
881
+ keepVisible = new Set();
882
+ matchedIds.forEach(id => {
883
+ let cur = id;
884
+ while (cur != null && !keepVisible.has(cur)) {
885
+ keepVisible.add(cur);
886
+ cur = parentOf.get(cur);
887
+ }
888
+ });
889
+ } else {
890
+ keepVisible = new Set(matchedIds);
891
+ }
892
+ nodes.forEach(n => { if (!keepVisible.has(n.id)) hidden.add(n.id); });
893
+ return hidden;
894
+ }
895
+ _hiddenByKeywordFilter(nodes, keyword, childrenMap = null) {
896
+ return GraphDiagram.hiddenByKeywordFilter(nodes, keyword, childrenMap, {
897
+ nodeStructuralKeys: this.structuralKeys('node'),
898
+ tableNodeStructuralKeys: this.structuralKeys('table'),
899
+ });
900
+ }
901
+
902
+ static edgeTooltipText(edge, { edgeStructuralKeys }) {
903
+ const lines = LabelHelper.nodeExtraDataLines(edge, edgeStructuralKeys);
904
+ if (!lines.length) return undefined;
905
+ return [`Edge ${edge.source} → ${edge.target}`, ...lines].join('\n');
906
+ }
907
+ _edgeTooltipText(edge) {
908
+ return GraphDiagram.edgeTooltipText(edge, {
909
+ edgeStructuralKeys: this.structuralKeys('edge'),
910
+ });
911
+ }
912
+
913
+ static generateNodeId(label, existingIds = []) {
914
+ const base = (label || 'node').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-+|-+$)/g, '') || 'node';
915
+ const existing = new Set(existingIds);
916
+ if (!existing.has(base)) return base;
917
+ let i = 2;
918
+ while (existing.has(`${base}-${i}`)) i++;
919
+ return `${base}-${i}`;
920
+ }
921
+ _generateNodeId(label) {
922
+ const existingIds = (this._chartState?.nodes ?? []).map(n => n.id);
923
+ return GraphDiagram.generateNodeId(label, existingIds);
924
+ }
925
+ _insertEdgeWaypoint(edge, point) {
926
+ const sourcePos = this._graph?.pos?.get(edge.source), targetPos = this._graph?.pos?.get(edge.target);
927
+ if (!sourcePos || !targetPos) return;
928
+ const oldWaypoints = edge.waypoints ? edge.waypoints.slice() : [];
929
+ const waypoints = EdgeGeometry.edgeWaypointsWithInsertion(edge, sourcePos, targetPos, point);
930
+ // Find where the new point landed in the recomputed array.
931
+ let insertedIndex = -1;
932
+ for (let i = 0; i < waypoints.length; i++) {
933
+ if (i >= oldWaypoints.length || waypoints[i] !== oldWaypoints[i]) {
934
+ insertedIndex = i;
935
+ break;
936
+ }
937
+ }
938
+ if (insertedIndex < 0) insertedIndex = waypoints.length - 1;
939
+ // Keep edge selected after adding waypoint so endpoints remain visible
940
+ this._selectedEdge = edge;
941
+ this.history.execute(new AddWaypointCommand(edge, waypoints[insertedIndex], { diagram: this, index: insertedIndex }));
942
+ }
943
+ /** The nodes selected together (a selection box, Ctrl-click): their ids. */
944
+ get selectedNodeIds() {
945
+ return this.engine?._selectedNodeIds ?? new Set();
946
+ }
947
+
948
+ /** Select these nodes (or add them, with `extend`), and show it. */
949
+ selectNodes(ids, { extend = false } = {}) {
950
+ if (!this.engine) return;
951
+ const next = extend ? new Set([...this.selectedNodeIds, ...ids]) : new Set(ids);
952
+ this.engine._selectedNodeIds = next;
953
+ this._showNodeSelection(this._graph?.nodeEls ?? []);
954
+ }
955
+
956
+ /** Mark the selected nodes' elements (an outline glow), and unmark the rest. @private */
957
+ _showNodeSelection(nodeEls) {
958
+ const selected = this.selectedNodeIds;
959
+ if (!selected.size && !this._showingSelection) return; // nothing to mark or unmark
960
+ this._showingSelection = selected.size > 0;
961
+ for (const el of nodeEls) {
962
+ if (typeof el?.getAttribute !== 'function') continue;
963
+ const on = selected.has(el.getAttribute('nodeId'));
964
+ el.classList?.toggle('multi-selected', on);
965
+ el.style.filter = on ? 'drop-shadow(0 0 3px #0066cc) drop-shadow(0 0 1px #0066cc)' : '';
966
+ }
967
+ }
968
+
969
+ /**
970
+ * After a click on `target`: unless it is one of the selected line's
971
+ * handles, the line is no longer selected and its handles go.
972
+ * @returns {boolean} whether a line was deselected
973
+ */
974
+ deselectEdgeOnClick(target) {
975
+ if (!this._selectedEdge || target?.closest?.(`[${EDGE_HANDLE_ATTR}]`)) return false;
976
+ this._selectedEdge = null;
977
+ this.renderer.svg?.querySelectorAll(`[${EDGE_HANDLE_ATTR}]`).forEach(n => n.remove());
978
+ return true;
979
+ }
980
+
981
+ _updateGraphLayout() {
982
+ if (!this._graph) return;
983
+
984
+ const { nodes, edges, pos, nodeEls, edgeEls, nodeRadius: R, sizeDomain, sizeOptions } = this._graph;
985
+ const defaultEdgeRouting = this._chartState?.options?.edgeRouting || 'straight';
986
+
987
+ // Update node positions
988
+ nodeEls.forEach(g => {
989
+ const nodeId = g.getAttribute('nodeId');
990
+ const p = pos.get(nodeId);
991
+ g.setAttribute('transform', `translate(${p.x},${p.y})`);
992
+ });
993
+
994
+ const routingObstacles = nodes.map(n => {
995
+ const p = pos.get(n.id);
996
+ if (!p) return null;
997
+ const size = NodeGeometry.nodeSize(n, R, sizeDomain, sizeOptions);
998
+ return { id: n.id, x: p.x, y: p.y, r: Math.max(size.w, size.h) / 2 };
999
+ }).filter(Boolean);
1000
+
1001
+ // Update edge positions — routes through the same _computeEdgePath the
1002
+ // initial draw in graphDiagram() uses, so a live drag frame doesn't
1003
+ // visibly snap between two different geometry calculations.
1004
+ edges.forEach((edge, i) => {
1005
+ const edgeEl = edgeEls[i];
1006
+ if (!edgeEl) return;
1007
+ const sourceNode = pos.get(edge.source);
1008
+ const targetNode = pos.get(edge.target);
1009
+ if (!sourceNode || !targetNode) return;
1010
+ const sourceInfo = nodes.find(n => n.id === edge.source);
1011
+ const targetInfo = nodes.find(n => n.id === edge.target);
1012
+ const sourceSize = this.engine._nodeSize(sourceInfo, R, sizeDomain, sizeOptions);
1013
+ const targetSize = this.engine._nodeSize(targetInfo, R, sizeDomain, sizeOptions);
1014
+ const edgeObstacles = routingObstacles.filter(o => o.id !== edge.source && o.id !== edge.target);
1015
+ const { d } = EdgeGeometry.computeEdgePath(
1016
+ sourceInfo, sourceNode, sourceSize, targetInfo, targetNode, targetSize, edge, defaultEdgeRouting, edgeObstacles
1017
+ );
1018
+ edgeEl.setAttribute('d', d);
1019
+ });
1020
+ }
1021
+
1022
+ /** Find the edge whose rendered path passes closest to (x,y), within
1023
+ * `tolerance` SVG units. Returns the edge object or null. Used to make
1024
+ * thin or angled lines easier to select/hover when the renderer's own
1025
+ * pixel-exact hitTest misses the actual stroke. */
1026
+ findNearestEdge(x, y, tolerance = 14) {
1027
+ if (!this._graph) return null;
1028
+ const { nodes, edges, pos, nodeRadius: R, sizeDomain, sizeOptions } = this._graph;
1029
+ const defaultEdgeRouting = this._chartState?.options?.edgeRouting || 'straight';
1030
+ const tol2 = tolerance * tolerance;
1031
+
1032
+ const routingObstacles = nodes.map(n => {
1033
+ const p = pos.get(n.id);
1034
+ if (!p) return null;
1035
+ const size = NodeGeometry.nodeSize(n, R, sizeDomain, sizeOptions);
1036
+ return { id: n.id, x: p.x, y: p.y, r: Math.max(size.w, size.h) / 2 };
1037
+ }).filter(Boolean);
1038
+
1039
+ let bestEdge = null;
1040
+ let bestDist2 = Infinity;
1041
+
1042
+ for (const edge of edges) {
1043
+ const sourcePos = pos.get(edge.source);
1044
+ const targetPos = pos.get(edge.target);
1045
+ if (!sourcePos || !targetPos) continue;
1046
+ const sourceInfo = nodes.find(n => n.id === edge.source);
1047
+ const targetInfo = nodes.find(n => n.id === edge.target);
1048
+ const sourceSize = this.engine._nodeSize(sourceInfo, R, sizeDomain, sizeOptions);
1049
+ const targetSize = this.engine._nodeSize(targetInfo, R, sizeDomain, sizeOptions);
1050
+ const edgeObstacles = routingObstacles.filter(o => o.id !== edge.source && o.id !== edge.target);
1051
+ const { d } = EdgeGeometry.computeEdgePath(
1052
+ sourceInfo, sourcePos, sourceSize, targetInfo, targetPos, targetSize, edge, defaultEdgeRouting, edgeObstacles
1053
+ );
1054
+ const dist2 = EdgeGeometry.pathDistance2(d, x, y);
1055
+ if (dist2 < tol2 && dist2 < bestDist2) {
1056
+ bestDist2 = dist2;
1057
+ bestEdge = edge;
1058
+ }
1059
+ }
1060
+ return bestEdge;
1061
+ }
1062
+
1063
+ _getContextMenu() {
1064
+ if (!this._contextMenu && this.engine?.container) {
1065
+ this._contextMenu = new ContextMenu(this.engine.container);
1066
+ }
1067
+ return this._contextMenu;
1068
+ }
1069
+
1070
+ _showNodeContextMenu(event, nodeId) {
1071
+ const node = this._graph.nodes.find(n => n.id === nodeId);
1072
+ if (!node) return;
1073
+
1074
+ const userItems = this._graph.contextMenuItems;
1075
+ let menuItems;
1076
+ if (userItems) {
1077
+ menuItems = userItems;
1078
+ } else {
1079
+ menuItems = [
1080
+ {
1081
+ label: (node) => this._graphFixedPos.has(node.id) ? 'Unpin Node' : 'Pin Node',
1082
+ action: (node) => {
1083
+ this._graphFixedPos.has(node.id)
1084
+ ? this._graphFixedPos.delete(node.id)
1085
+ : this._graphFixedPos.add(node.id);
1086
+ }
1087
+ },
1088
+ ...(this._graph.editable ? [{
1089
+ label: 'Rename Node',
1090
+ action: (node) => {
1091
+ const next = window.prompt('Rename node:', node.label ?? '');
1092
+ if (next == null) return;
1093
+ const trimmed = next.trim();
1094
+ if (trimmed === (node.label ?? '')) return;
1095
+ this.history.execute(new RenameNodeCommand(
1096
+ node.id, node.label ?? '', trimmed,
1097
+ { diagram: this, onRename: this._chartState?.options?.onNodeRename }
1098
+ ));
1099
+ }
1100
+ }] : []),
1101
+ {
1102
+ label: 'Show Details in Console',
1103
+ action: (node) => console.log('Node Details:', node)
1104
+ },
1105
+ {
1106
+ label: 'Hide Node',
1107
+ action: (node) => {
1108
+ this._hiddenGraphNodes.add(node.id);
1109
+ this._rerenderGraph();
1110
+ }
1111
+ },
1112
+ {
1113
+ label: 'Delete Node',
1114
+ action: (node) => {
1115
+ const dangling = this._chartState.edges.filter(e => e.source === node.id || e.target === node.id);
1116
+ this.history.execute(new DeleteNodeCommand(node, dangling, { diagram: this }));
1117
+ }
1118
+ }
1119
+ ];
1120
+
1121
+ if (this._hiddenGraphNodes.size > 0) {
1122
+ menuItems.push({ separator: true });
1123
+ menuItems.push({
1124
+ label: 'Show All Hidden Nodes',
1125
+ action: () => {
1126
+ this._hiddenGraphNodes.clear();
1127
+ this._rerenderGraph();
1128
+ }
1129
+ });
1130
+ }
1131
+ }
1132
+
1133
+ this._getContextMenu()?.show(event, menuItems, node, (clientX, clientY) => SvgCoordinate.clientToSvgPoint(this.renderer?.svg, clientX, clientY), this.engine);
1134
+ }
1135
+ _showEdgeContextMenu(event, edge) {
1136
+ const menuItems = this._graph.edgeContextMenuItems || [
1137
+ {
1138
+ label: 'Rename Edge',
1139
+ action: (edge) => {
1140
+ const next = window.prompt('Rename edge:', edge.label ?? '');
1141
+ if (next == null) return;
1142
+ const trimmed = next.trim();
1143
+ if (trimmed === (edge.label ?? '')) return;
1144
+ this.history.execute(new RenameEdgeCommand(
1145
+ edge, edge.label ?? '', trimmed,
1146
+ { diagram: this, onRename: this._chartState?.options?.onEdgeRename }
1147
+ ));
1148
+ }
1149
+ },
1150
+ {
1151
+ label: 'Invert Direction',
1152
+ action: (edge) => {
1153
+ this.history.execute(new InvertEdgeCommand(
1154
+ edge,
1155
+ { diagram: this, onInvert: this._chartState?.options?.onEdgeInvert }
1156
+ ));
1157
+ }
1158
+ },
1159
+ // Where adding a bend point moved to once double-clicking the
1160
+ // line itself was repurposed for onEdgeDoubleClick (see
1161
+ // graphDiagram's own doc comment) — `point` is the right-click
1162
+ // location _buildAndShowContextMenu now passes to every action.
1163
+ { label: 'Add Waypoint Here', action: (edge, engine, point) => this._insertEdgeWaypoint(edge, point) },
1164
+ {
1165
+ label: 'Delete Edge',
1166
+ action: (edge) => {
1167
+ this.history.execute(new DeleteEdgeCommand(
1168
+ edge,
1169
+ { diagram: this, onDelete: this._chartState?.options?.onEdgeDelete }
1170
+ ));
1171
+ }
1172
+ }
1173
+ ];
1174
+ this._getContextMenu()?.show(event, menuItems, edge, (clientX, clientY) => SvgCoordinate.clientToSvgPoint(this.renderer?.svg, clientX, clientY), this.engine);
1175
+ }
1176
+ _showCanvasContextMenu(event) {
1177
+ const point = SvgCoordinate.clientToSvgPoint(this.renderer?.svg, event.clientX, event.clientY);
1178
+ const menuItems = this._graph.canvasContextMenuItems || [
1179
+ {
1180
+ label: 'Add Node',
1181
+ action: (point) => {
1182
+ const label = window.prompt('New node label:', '');
1183
+ if (label == null) return;
1184
+ const trimmed = label.trim();
1185
+ if (!trimmed) return;
1186
+ const gridSize = this._chartState?.options?.gridSize ?? 0;
1187
+ const id = this._generateNodeId(trimmed);
1188
+ const newNode = {
1189
+ id,
1190
+ label: trimmed,
1191
+ x: GraphDiagram.snapToGrid(point.x, gridSize),
1192
+ y: GraphDiagram.snapToGrid(point.y, gridSize),
1193
+ };
1194
+ this.history.execute(new AddNodeCommand(
1195
+ newNode,
1196
+ { diagram: this, onAdd: this._chartState?.options?.onNodeAdd }
1197
+ ));
1198
+ }
1199
+ },
1200
+ {
1201
+ label: 'Add Folder',
1202
+ action: (point) => {
1203
+ const name = window.prompt('New folder name:', 'New Folder');
1204
+ if (name == null) return;
1205
+ const trimmed = name.trim();
1206
+ if (!trimmed) return;
1207
+ const gridSize = this._chartState?.options?.gridSize ?? 0;
1208
+ const folderId = `folder-${Math.random().toString(36).slice(2, 9)}`;
1209
+ const newFolder = {
1210
+ id: folderId,
1211
+ name: trimmed,
1212
+ x: GraphDiagram.snapToGrid(point.x, gridSize),
1213
+ y: GraphDiagram.snapToGrid(point.y, gridSize),
1214
+ width: 200,
1215
+ height: 150,
1216
+ collapsed: false,
1217
+ };
1218
+ // Add to folders in data
1219
+ if (!this.data.folders) this.data.folders = [];
1220
+ this.data.folders.push(newFolder);
1221
+ this._rerenderGraph();
1222
+ }
1223
+ }
1224
+ ];
1225
+ this._getContextMenu()?.show(event, menuItems, point, (clientX, clientY) => SvgCoordinate.clientToSvgPoint(this.renderer?.svg, clientX, clientY), this.engine);
1226
+ }
1227
+ _onLegendClick(groupName) {
1228
+ if (this._hiddenGraphGroups.has(groupName)) {
1229
+ this._hiddenGraphGroups.delete(groupName);
1230
+ } else {
1231
+ this._hiddenGraphGroups.add(groupName);
1232
+ }
1233
+ this._rerenderGraph();
1234
+ }
1235
+
1236
+ /**
1237
+ * Handle clicks on diagram chrome (e.g. the group legend).
1238
+ * @param {MouseEvent} event
1239
+ * @param {object} hitShape
1240
+ */
1241
+ onClick(event, hitShape) {
1242
+ if (hitShape && hitShape.graphLegend) {
1243
+ this._onLegendClick(hitShape.groupName);
1244
+ }
1245
+ }
1246
+ filterGraphNodes(keyword) {
1247
+ this._graphFilterKeyword = keyword ? String(keyword).trim() : '';
1248
+ this._rerenderGraph();
1249
+ }
1250
+
1251
+ /** Snap a coordinate to the nearest grid multiple. `gridSize <= 0` means no
1252
+ * snapping. Exposed as a static helper so drag/add-node code outside this
1253
+ * class can reuse the same rounding rule. */
1254
+ static snapToGrid(value, gridSize) {
1255
+ if (!gridSize || gridSize <= 0) return value;
1256
+ return Math.round(value / gridSize) * gridSize;
1257
+ }
1258
+
1259
+ /** A single path string covering a W×H area with a grid of `gridSize`
1260
+ * spacing — rendered as one cheap background path. */
1261
+ static _gridPath(W, H, gridSize) {
1262
+ const parts = [];
1263
+ for (let x = gridSize; x < W; x += gridSize) parts.push(`M${x},0 L${x},${H}`);
1264
+ for (let y = gridSize; y < H; y += gridSize) parts.push(`M0,${y} L${W},${y}`);
1265
+ return parts.join(' ');
1266
+ }
1267
+
1268
+ _rerenderGraph() {
1269
+ // graphDiagram() reseeds every node's position from `n.x ?? Math.random()`
1270
+ // on each call — nothing else ever writes the live/simulated/dragged
1271
+ // position back onto the node object itself. Without carrying it
1272
+ // forward here, a node being actively dragged would snap back to its
1273
+ // pre-drag spot on the very next re-render (this is called on every
1274
+ // drag mousemove), and any node without an explicit x/y would jump to
1275
+ // a brand-new random position on every hide/show, delete, or legend
1276
+ // toggle. Copying this._graph.pos back onto the node objects before
1277
+ // re-invoking graphDiagram makes it a true "redraw with current state"
1278
+ // instead of "reroll positions and redraw".
1279
+ if (this._graph?.pos) {
1280
+ for (const node of this._chartState.nodes) {
1281
+ const p = this._graph.pos.get(node.id);
1282
+ if (p) { node.x = p.x; node.y = p.y; }
1283
+ }
1284
+ }
1285
+ this.data.nodes = this._chartState.nodes;
1286
+ this.data.edges = this._chartState.edges;
1287
+ this.options = this._chartState.options;
1288
+ this.render();
1289
+ }
1290
+
1291
+ /** Render folder containers (before nodes so they appear behind) */
1292
+ _renderFolders(svg, folders = []) {
1293
+ if (!folders.length || !svg) return;
1294
+ for (const folder of folders) {
1295
+ this._renderFolderContainer(svg, folder);
1296
+ }
1297
+ }
1298
+
1299
+ /** Render a single folder container with border, header, and collapse toggle */
1300
+ _renderFolderContainer(svg, folder) {
1301
+ // A group needs a place; 0 is one.
1302
+ if (!Number.isFinite(folder.x) || !Number.isFinite(folder.y)) return;
1303
+ const HEADER_HEIGHT = 24;
1304
+ const BORDER_COLOR = this._dragOverFolderId === folder.id ? '#44aa44' : '#999';
1305
+ const FILL_COLOR = this._dragOverFolderId === folder.id ? 'rgba(68, 170, 68, 0.15)' : 'rgba(150, 150, 200, 0.08)';
1306
+ const isCollapsed = this._collapsedFolders.has(folder.id);
1307
+
1308
+ // Folder background rectangle
1309
+ const bgRect = this.renderer.rect(
1310
+ folder.x, folder.y, folder.width || 150, folder.height || 100,
1311
+ FILL_COLOR, { color: BORDER_COLOR, width: 1.5 }
1312
+ );
1313
+ svg.appendChild(bgRect);
1314
+
1315
+ // Folder header bar
1316
+ const headerRect = this.renderer.rect(
1317
+ folder.x, folder.y, folder.width || 150, HEADER_HEIGHT,
1318
+ 'rgba(150, 150, 200, 0.3)', { color: BORDER_COLOR, width: 1.5 }
1319
+ );
1320
+ headerRect.setAttribute('folderId', folder.id);
1321
+ headerRect.setAttribute('class', 'folder-header');
1322
+ headerRect.style.cursor = 'pointer';
1323
+ svg.appendChild(headerRect);
1324
+
1325
+ // Collapse/expand indicator
1326
+ const indicator = isCollapsed ? '▶' : '▼';
1327
+ const indicatorText = this.renderer.text(indicator, folder.x + 4, folder.y + 16,
1328
+ { fontSize: '12px', fill: '#333', cursor: 'pointer' }
1329
+ );
1330
+ indicatorText.setAttribute('folderId', folder.id);
1331
+ indicatorText.setAttribute('class', 'folder-toggle');
1332
+ svg.appendChild(indicatorText);
1333
+
1334
+ // Folder name label
1335
+ const label = this.renderer.text(
1336
+ LabelHelper.resolveNodeLabel({ label: folder.name }) || 'Folder',
1337
+ folder.x + 22, folder.y + 16,
1338
+ { fontSize: '12px', fontWeight: 'bold', fill: '#333', cursor: 'pointer' }
1339
+ );
1340
+ label.setAttribute('folderId', folder.id);
1341
+ label.setAttribute('class', 'folder-label');
1342
+ svg.appendChild(label);
1343
+
1344
+ // Add click handlers
1345
+ headerRect.addEventListener('click', (e) => this._onFolderHeaderClick(e, folder.id));
1346
+ headerRect.addEventListener('dblclick', (e) => this._onFolderRename(e, folder));
1347
+ indicatorText.addEventListener('click', (e) => this._onFolderHeaderClick(e, folder.id));
1348
+ label.addEventListener('click', (e) => this._onFolderHeaderClick(e, folder.id));
1349
+ label.addEventListener('dblclick', (e) => this._onFolderRename(e, folder));
1350
+ }
1351
+
1352
+ /** Handle folder header click — toggle collapse state */
1353
+ _onFolderHeaderClick(event, folderId) {
1354
+ event.stopPropagation();
1355
+ if (this._collapsedFolders.has(folderId)) {
1356
+ this._collapsedFolders.delete(folderId);
1357
+ } else {
1358
+ this._collapsedFolders.add(folderId);
1359
+ }
1360
+ this._rerenderGraph();
1361
+ }
1362
+
1363
+ /** Handle folder rename (double-click) */
1364
+ _onFolderRename(event, folder) {
1365
+ event.stopPropagation();
1366
+ const newName = window.prompt('Rename folder:', folder.name || 'Folder');
1367
+ if (newName == null) return;
1368
+ const trimmed = newName.trim();
1369
+ if (!trimmed || trimmed === folder.name) return;
1370
+
1371
+ // Update folder name
1372
+ folder.name = trimmed;
1373
+ this._rerenderGraph();
1374
+ }
1375
+
1376
+ /** Return IDs of all nodes within a given folder (recursively including nested folders) */
1377
+ _getNodesInFolder(folderId) {
1378
+ const nodeIds = new Set();
1379
+ const elementFolderMap = this._chartState?.options?.elementFolderMap;
1380
+ if (!elementFolderMap) return nodeIds;
1381
+
1382
+ const folders = this._chartState?.folders || [];
1383
+
1384
+ // Find all elements in this folder directly
1385
+ for (const [elementId, folderIdForElement] of elementFolderMap.entries()) {
1386
+ if (folderIdForElement === folderId) {
1387
+ nodeIds.add(elementId);
1388
+ }
1389
+ }
1390
+
1391
+ // Recursively add elements in nested folders
1392
+ for (const folder of folders) {
1393
+ if (folder.parentId === folderId) {
1394
+ for (const nodeId of this._getNodesInFolder(folder.id)) {
1395
+ nodeIds.add(nodeId);
1396
+ }
1397
+ }
1398
+ }
1399
+
1400
+ return nodeIds;
1401
+ }
1402
+
1403
+ /** Return IDs of all nodes hidden due to collapsed folders */
1404
+ _getHiddenByCollapsedFolders(folders) {
1405
+ if (!folders.length && !this._collapsedFolders.size) return new Set();
1406
+ const hidden = new Set();
1407
+ for (const folderId of this._collapsedFolders) {
1408
+ const nodesInFolder = this._getNodesInFolder(folderId);
1409
+ for (const nodeId of nodesInFolder) {
1410
+ hidden.add(nodeId);
1411
+ }
1412
+ }
1413
+ return hidden;
1414
+ }
1415
+
1416
+ }
1417
+
1418
+ /**
1419
+ * A font size in pixels: a number, "12px" or "9pt" (1pt = 4/3 px). Undefined when there is none.
1420
+ * @param {number|string} [size]
1421
+ * @returns {number|undefined}
1422
+ */
1423
+ /** A font size in pixels (TextStyle.pixels: points are 4/3 of a pixel). */
1424
+ GraphDiagram.fontPixels = (size) => TextStyle.pixels(size);