elkrb 1.0.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 (89) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +11 -0
  4. data/Gemfile +13 -0
  5. data/README.adoc +1028 -0
  6. data/Rakefile +64 -0
  7. data/benchmarks/README.md +172 -0
  8. data/benchmarks/elkjs_benchmark.js +140 -0
  9. data/benchmarks/elkrb_benchmark.rb +145 -0
  10. data/benchmarks/fixtures/graphs.json +10777 -0
  11. data/benchmarks/generate_report.rb +241 -0
  12. data/benchmarks/generate_test_graphs.rb +154 -0
  13. data/benchmarks/results/elkrb_results.json +280 -0
  14. data/benchmarks/results/elkrb_summary.json +285 -0
  15. data/elkrb.gemspec +39 -0
  16. data/examples/dot_export_demo.rb +133 -0
  17. data/examples/hierarchical_graph.rb +19 -0
  18. data/examples/layout_constraints_demo.rb +272 -0
  19. data/examples/port_constraints_demo.rb +291 -0
  20. data/examples/self_loop_demo.rb +391 -0
  21. data/examples/simple_graph.rb +50 -0
  22. data/examples/spline_routing_demo.rb +235 -0
  23. data/exe/elkrb +8 -0
  24. data/lib/elkrb/cli.rb +224 -0
  25. data/lib/elkrb/commands/batch_command.rb +66 -0
  26. data/lib/elkrb/commands/convert_command.rb +130 -0
  27. data/lib/elkrb/commands/diagram_command.rb +208 -0
  28. data/lib/elkrb/commands/render_command.rb +52 -0
  29. data/lib/elkrb/commands/validate_command.rb +241 -0
  30. data/lib/elkrb/errors.rb +30 -0
  31. data/lib/elkrb/geometry/bezier.rb +163 -0
  32. data/lib/elkrb/geometry/dimension.rb +32 -0
  33. data/lib/elkrb/geometry/point.rb +68 -0
  34. data/lib/elkrb/geometry/rectangle.rb +86 -0
  35. data/lib/elkrb/geometry/vector.rb +67 -0
  36. data/lib/elkrb/graph/edge.rb +95 -0
  37. data/lib/elkrb/graph/graph.rb +90 -0
  38. data/lib/elkrb/graph/label.rb +45 -0
  39. data/lib/elkrb/graph/layout_options.rb +247 -0
  40. data/lib/elkrb/graph/node.rb +79 -0
  41. data/lib/elkrb/graph/node_constraints.rb +107 -0
  42. data/lib/elkrb/graph/port.rb +104 -0
  43. data/lib/elkrb/graphviz_wrapper.rb +133 -0
  44. data/lib/elkrb/layout/algorithm_registry.rb +57 -0
  45. data/lib/elkrb/layout/algorithms/base_algorithm.rb +208 -0
  46. data/lib/elkrb/layout/algorithms/box.rb +47 -0
  47. data/lib/elkrb/layout/algorithms/disco.rb +206 -0
  48. data/lib/elkrb/layout/algorithms/fixed.rb +32 -0
  49. data/lib/elkrb/layout/algorithms/force.rb +165 -0
  50. data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +86 -0
  51. data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +96 -0
  52. data/lib/elkrb/layout/algorithms/layered/node_placer.rb +77 -0
  53. data/lib/elkrb/layout/algorithms/layered.rb +49 -0
  54. data/lib/elkrb/layout/algorithms/libavoid.rb +389 -0
  55. data/lib/elkrb/layout/algorithms/mrtree.rb +144 -0
  56. data/lib/elkrb/layout/algorithms/radial.rb +64 -0
  57. data/lib/elkrb/layout/algorithms/random.rb +43 -0
  58. data/lib/elkrb/layout/algorithms/rectpacking.rb +93 -0
  59. data/lib/elkrb/layout/algorithms/spore_compaction.rb +139 -0
  60. data/lib/elkrb/layout/algorithms/spore_overlap.rb +117 -0
  61. data/lib/elkrb/layout/algorithms/stress.rb +176 -0
  62. data/lib/elkrb/layout/algorithms/topdown_packing.rb +183 -0
  63. data/lib/elkrb/layout/algorithms/vertiflex.rb +174 -0
  64. data/lib/elkrb/layout/constraints/alignment_constraint.rb +150 -0
  65. data/lib/elkrb/layout/constraints/base_constraint.rb +72 -0
  66. data/lib/elkrb/layout/constraints/constraint_processor.rb +134 -0
  67. data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +87 -0
  68. data/lib/elkrb/layout/constraints/layer_constraint.rb +71 -0
  69. data/lib/elkrb/layout/constraints/relative_position_constraint.rb +110 -0
  70. data/lib/elkrb/layout/edge_router.rb +935 -0
  71. data/lib/elkrb/layout/hierarchical_processor.rb +299 -0
  72. data/lib/elkrb/layout/label_placer.rb +338 -0
  73. data/lib/elkrb/layout/layout_engine.rb +170 -0
  74. data/lib/elkrb/layout/port_constraint_processor.rb +173 -0
  75. data/lib/elkrb/options/elk_padding.rb +94 -0
  76. data/lib/elkrb/options/k_vector.rb +100 -0
  77. data/lib/elkrb/options/k_vector_chain.rb +135 -0
  78. data/lib/elkrb/parsers/elkt_parser.rb +248 -0
  79. data/lib/elkrb/serializers/dot_serializer.rb +339 -0
  80. data/lib/elkrb/serializers/elkt_serializer.rb +236 -0
  81. data/lib/elkrb/version.rb +5 -0
  82. data/lib/elkrb.rb +509 -0
  83. data/sig/elkrb/constraints.rbs +114 -0
  84. data/sig/elkrb/geometry.rbs +61 -0
  85. data/sig/elkrb/graph.rbs +112 -0
  86. data/sig/elkrb/layout.rbs +107 -0
  87. data/sig/elkrb/options.rbs +81 -0
  88. data/sig/elkrb.rbs +32 -0
  89. metadata +179 -0
@@ -0,0 +1,935 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../geometry/point"
4
+ require_relative "../geometry/bezier"
5
+ require_relative "../graph/edge"
6
+
7
+ module Elkrb
8
+ module Layout
9
+ # Provides edge routing functionality for layout algorithms
10
+ module EdgeRouter
11
+ # Route edges in a graph using specified routing style
12
+ # @param graph [Graph::Graph] The graph to route edges for
13
+ # @param node_map [Hash] Map of node IDs to node objects
14
+ # @param routing_style [String] Routing style (ORTHOGONAL, POLYLINE,
15
+ # SPLINES)
16
+ def route_edges(graph, node_map = nil, routing_style = nil)
17
+ node_map ||= build_node_map(graph)
18
+ routing_style ||= get_routing_style(graph)
19
+
20
+ graph.edges&.each do |edge|
21
+ if self_loop?(edge)
22
+ route_self_loop(edge, node_map, graph, routing_style)
23
+ else
24
+ route_edge_with_style(edge, node_map, graph, routing_style)
25
+ end
26
+ end
27
+ end
28
+
29
+ # Route a single edge
30
+ # @param edge [Graph::Edge] The edge to route
31
+ # @param node_map [Hash] Map of node IDs to node objects
32
+ # @param graph [Graph::Graph] The containing graph
33
+ def route_edge(edge, node_map, _graph)
34
+ return unless edge.sources&.any? && edge.targets&.any?
35
+
36
+ # Get source and target nodes
37
+ source_id = edge.sources.first
38
+ target_id = edge.targets.first
39
+
40
+ source_node = node_map[source_id]
41
+ target_node = node_map[target_id]
42
+
43
+ # If nodes not found, sources/targets might be port IDs
44
+ # Try to find nodes that contain these ports
45
+ source_node ||= find_node_with_port(node_map.values, source_id)
46
+ target_node ||= find_node_with_port(node_map.values, target_id)
47
+
48
+ return unless source_node && target_node
49
+
50
+ # Create edge section if not exists
51
+ edge.sections ||= []
52
+ if edge.sections.empty?
53
+ edge.sections << Graph::EdgeSection.new(
54
+ id: "#{edge.id}_section_0",
55
+ )
56
+ end
57
+
58
+ section = edge.sections.first
59
+
60
+ # Calculate routing points based on port-awareness
61
+ if edge_uses_ports?(edge, source_node, target_node)
62
+ route_with_ports(section, edge, source_node, target_node)
63
+ else
64
+ route_node_to_node(section, source_node, target_node, edge)
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ # Build a map of node IDs to node objects
71
+ def build_node_map(graph)
72
+ map = {}
73
+ graph.children&.each do |node|
74
+ map[node.id] = node
75
+ end
76
+ map
77
+ end
78
+
79
+ # Find node that contains a port with the given ID
80
+ def find_node_with_port(nodes, port_id)
81
+ nodes.find do |node|
82
+ node.ports&.any? { |port| port.id == port_id }
83
+ end
84
+ end
85
+
86
+ # Check if edge should use port-based routing
87
+ def edge_uses_ports?(_edge, source_node, target_node)
88
+ # Check if nodes have ports
89
+ has_source_ports = source_node.ports&.any?
90
+ has_target_ports = target_node.ports&.any?
91
+
92
+ has_source_ports || has_target_ports
93
+ end
94
+
95
+ # Route edge using port positions
96
+ def route_with_ports(section, edge, source_node, target_node)
97
+ # Get port positions or fallback to node center
98
+ source_port = find_port_by_id(edge.sources.first, source_node)
99
+ target_port = find_port_by_id(edge.targets.first, target_node)
100
+
101
+ start_point = get_port_position(
102
+ edge.sources.first,
103
+ source_node,
104
+ :outgoing,
105
+ )
106
+ end_point = get_port_position(
107
+ edge.targets.first,
108
+ target_node,
109
+ :incoming,
110
+ )
111
+
112
+ section.start_point = start_point
113
+ section.end_point = end_point
114
+ section.bend_points ||= []
115
+
116
+ # Add intelligent bend points based on port sides
117
+ if source_port && target_port
118
+ add_port_aware_bend_points(
119
+ section,
120
+ start_point,
121
+ end_point,
122
+ source_port,
123
+ target_port,
124
+ )
125
+ elsif should_use_orthogonal_routing?(edge)
126
+ add_orthogonal_bend_points(section, start_point, end_point)
127
+ end
128
+ end
129
+
130
+ # Route edge from node center to node center
131
+ def route_node_to_node(section, source_node, target_node, edge = nil)
132
+ start_point = get_node_center(source_node)
133
+ end_point = get_node_center(target_node)
134
+
135
+ section.start_point = start_point
136
+ section.end_point = end_point
137
+ section.bend_points ||= []
138
+
139
+ # Add orthogonal routing if configured
140
+ if edge && should_use_orthogonal_routing?(edge)
141
+ add_orthogonal_bend_points(section, start_point, end_point)
142
+ end
143
+ end
144
+
145
+ # Find port by ID
146
+ def find_port_by_id(port_id, node)
147
+ node.ports&.find { |p| p.id == port_id }
148
+ end
149
+
150
+ # Get position of a port or node center
151
+ def get_port_position(port_id, node, _direction)
152
+ # Try to find port
153
+ port = find_port_by_id(port_id, node)
154
+
155
+ if port
156
+ # Port position is relative to node position
157
+ Geometry::Point.new(
158
+ x: (node.x || 0.0) + (port.x || 0.0),
159
+ y: (node.y || 0.0) + (port.y || 0.0),
160
+ )
161
+ else
162
+ # Fallback to node center
163
+ get_node_center(node)
164
+ end
165
+ end
166
+
167
+ # Get center point of a node
168
+ def get_node_center(node)
169
+ x = (node.x || 0.0) + ((node.width || 0.0) / 2.0)
170
+ y = (node.y || 0.0) + ((node.height || 0.0) / 2.0)
171
+ Geometry::Point.new(x: x, y: y)
172
+ end
173
+
174
+ # Check if orthogonal routing should be used
175
+ def should_use_orthogonal_routing?(edge)
176
+ edge.layout_options&.[]("edge.routing") == "orthogonal"
177
+ end
178
+
179
+ # Add intelligent bend points based on port sides
180
+ def add_port_aware_bend_points(section, start_point, end_point,
181
+ source_port, target_port)
182
+ source_side = source_port.side
183
+ target_side = target_port.side
184
+
185
+ # Calculate bend points based on port side combinations
186
+ case [source_side, target_side]
187
+ when ["EAST", "WEST"], ["WEST", "EAST"]
188
+ # Horizontal connection: add midpoint
189
+ add_horizontal_bend_points(section, start_point, end_point)
190
+ when ["NORTH", "SOUTH"], ["SOUTH", "NORTH"]
191
+ # Vertical connection: add midpoint
192
+ add_vertical_bend_points(section, start_point, end_point)
193
+ when ["EAST", "NORTH"], ["EAST", "SOUTH"],
194
+ ["WEST", "NORTH"], ["WEST", "SOUTH"]
195
+ # Horizontal to vertical
196
+ add_horizontal_then_vertical(section, start_point, end_point)
197
+ when ["NORTH", "EAST"], ["NORTH", "WEST"],
198
+ ["SOUTH", "EAST"], ["SOUTH", "WEST"]
199
+ # Vertical to horizontal
200
+ add_vertical_then_horizontal(section, start_point, end_point)
201
+ else
202
+ # Default orthogonal routing
203
+ add_orthogonal_bend_points(section, start_point, end_point)
204
+ end
205
+ end
206
+
207
+ # Add horizontal bend points (for horizontal connections)
208
+ def add_horizontal_bend_points(section, start_point, end_point)
209
+ mid_x = (start_point.x + end_point.x) / 2.0
210
+ section.add_bend_point(mid_x, start_point.y)
211
+ section.add_bend_point(mid_x, end_point.y)
212
+ end
213
+
214
+ # Add vertical bend points (for vertical connections)
215
+ def add_vertical_bend_points(section, start_point, end_point)
216
+ mid_y = (start_point.y + end_point.y) / 2.0
217
+ section.add_bend_point(start_point.x, mid_y)
218
+ section.add_bend_point(end_point.x, mid_y)
219
+ end
220
+
221
+ # Route horizontal then vertical
222
+ def add_horizontal_then_vertical(section, start_point, end_point)
223
+ section.add_bend_point(end_point.x, start_point.y)
224
+ end
225
+
226
+ # Route vertical then horizontal
227
+ def add_vertical_then_horizontal(section, start_point, end_point)
228
+ section.add_bend_point(start_point.x, end_point.y)
229
+ end
230
+
231
+ # Add orthogonal (right-angle) bend points
232
+ def add_orthogonal_bend_points(section, start_point, end_point)
233
+ # Simple orthogonal routing: horizontal then vertical
234
+ mid_x = (start_point.x + end_point.x) / 2.0
235
+
236
+ # Add bend points for orthogonal path
237
+ section.add_bend_point(mid_x, start_point.y)
238
+ section.add_bend_point(mid_x, end_point.y)
239
+ end
240
+
241
+ # Get routing style from graph options
242
+ def get_routing_style(graph)
243
+ return "ORTHOGONAL" unless graph.layout_options
244
+
245
+ style = graph.layout_options["elk.edgeRouting"] ||
246
+ graph.layout_options["edgeRouting"] ||
247
+ graph.layout_options.edge_routing
248
+
249
+ style ? style.to_s.upcase : "ORTHOGONAL"
250
+ end
251
+
252
+ # Route edge with specified routing style
253
+ def route_edge_with_style(edge, node_map, graph, routing_style)
254
+ case routing_style
255
+ when "SPLINES"
256
+ route_spline_edge(edge, node_map, graph)
257
+ when "POLYLINE"
258
+ route_polyline_edge(edge, node_map, graph)
259
+ else
260
+ route_edge(edge, node_map, graph)
261
+ end
262
+ end
263
+
264
+ # Route edge with polyline (straight segments) style
265
+ def route_polyline_edge(edge, node_map, graph)
266
+ # Polyline is just direct routing without bend points
267
+ route_edge(edge, node_map, graph)
268
+ end
269
+
270
+ # Route edge with spline (curved) style
271
+ def route_spline_edge(edge, node_map, _graph)
272
+ return unless edge.sources&.any? && edge.targets&.any?
273
+
274
+ source_id = edge.sources.first
275
+ target_id = edge.targets.first
276
+
277
+ source_node = node_map[source_id]
278
+ target_node = node_map[target_id]
279
+
280
+ source_node ||= find_node_with_port(node_map.values, source_id)
281
+ target_node ||= find_node_with_port(node_map.values, target_id)
282
+
283
+ return unless source_node && target_node
284
+
285
+ # Create edge section if not exists
286
+ edge.sections ||= []
287
+ if edge.sections.empty?
288
+ edge.sections << Graph::EdgeSection.new(
289
+ id: "#{edge.id}_section_0",
290
+ )
291
+ end
292
+
293
+ section = edge.sections.first
294
+
295
+ # Calculate spline routing
296
+ if edge_uses_ports?(edge, source_node, target_node)
297
+ route_spline_with_ports(section, edge, source_node, target_node)
298
+ else
299
+ route_spline_node_to_node(section, edge, source_node, target_node)
300
+ end
301
+ end
302
+
303
+ # Route spline edge using port positions
304
+ def route_spline_with_ports(section, edge, source_node, target_node)
305
+ start_point = get_port_position(
306
+ edge.sources.first,
307
+ source_node,
308
+ :outgoing,
309
+ )
310
+ end_point = get_port_position(
311
+ edge.targets.first,
312
+ target_node,
313
+ :incoming,
314
+ )
315
+
316
+ section.start_point = start_point
317
+ section.end_point = end_point
318
+
319
+ # Calculate control points for spline
320
+ add_spline_control_points(section, start_point, end_point, edge)
321
+ end
322
+
323
+ # Route spline edge from node center to node center
324
+ def route_spline_node_to_node(section, edge, source_node, target_node)
325
+ start_point = get_node_center(source_node)
326
+ end_point = get_node_center(target_node)
327
+
328
+ section.start_point = start_point
329
+ section.end_point = end_point
330
+
331
+ # Calculate control points for spline
332
+ add_spline_control_points(section, start_point, end_point, edge)
333
+ end
334
+
335
+ # Add Bezier control points to create smooth spline
336
+ def add_spline_control_points(section, start_point, end_point, edge)
337
+ # Get curvature setting
338
+ curvature = get_spline_curvature(edge)
339
+
340
+ # Calculate control points
341
+ control_points = calculate_spline_controls(
342
+ start_point,
343
+ end_point,
344
+ curvature,
345
+ edge,
346
+ )
347
+
348
+ # Store control points as bend points
349
+ section.bend_points ||= []
350
+ section.bend_points = control_points
351
+ end
352
+
353
+ # Get spline curvature from options
354
+ def get_spline_curvature(edge)
355
+ return 0.5 unless edge.layout_options
356
+
357
+ curvature = edge.layout_options["elk.spline.curvature"] ||
358
+ edge.layout_options["spline.curvature"]
359
+
360
+ curvature ? curvature.to_f : 0.5
361
+ end
362
+
363
+ # Calculate Bezier control points for smooth curves
364
+ def calculate_spline_controls(start_point, end_point, curvature, edge)
365
+ # Determine routing direction from edge or graph options
366
+ direction = get_routing_direction(edge)
367
+
368
+ case direction
369
+ when "HORIZONTAL", "RIGHT", "LEFT"
370
+ Geometry::Bezier.horizontal_control_points(
371
+ start_point,
372
+ end_point,
373
+ curvature,
374
+ )
375
+ when "VERTICAL", "DOWN", "UP"
376
+ Geometry::Bezier.vertical_control_points(
377
+ start_point,
378
+ end_point,
379
+ curvature,
380
+ )
381
+ else
382
+ # Default: perpendicular control points
383
+ Geometry::Bezier.calculate_control_points(
384
+ start_point,
385
+ end_point,
386
+ curvature,
387
+ )
388
+ end
389
+ end
390
+
391
+ # Get routing direction from edge options
392
+ def get_routing_direction(edge)
393
+ return nil unless edge.layout_options
394
+
395
+ edge.layout_options["elk.direction"] ||
396
+ edge.layout_options["direction"] ||
397
+ nil
398
+ end
399
+
400
+ # Check if edge is a self-loop (source == target)
401
+ def self_loop?(edge)
402
+ sources = edge.sources || []
403
+ targets = edge.targets || []
404
+
405
+ return false if sources.empty? || targets.empty?
406
+
407
+ sources.first == targets.first
408
+ end
409
+
410
+ # Route a self-loop edge
411
+ def route_self_loop(edge, node_map, graph, routing_style)
412
+ node_id = edge.sources.first
413
+ node = node_map[node_id]
414
+
415
+ # If not found, might be a port ID
416
+ node ||= find_node_with_port(node_map.values, node_id)
417
+
418
+ return unless node
419
+
420
+ # Get self-loop index for multiple loops on same node
421
+ loop_index = get_self_loop_index(edge, node, graph)
422
+
423
+ # Create edge section if not exists
424
+ edge.sections ||= []
425
+ if edge.sections.empty?
426
+ edge.sections << Graph::EdgeSection.new(
427
+ id: "#{edge.id}_section_0",
428
+ )
429
+ end
430
+
431
+ section = edge.sections.first
432
+
433
+ # Check if edge uses ports
434
+ if edge_uses_ports_for_self_loop?(edge, node)
435
+ route_self_loop_with_ports(section, edge, node, loop_index,
436
+ routing_style)
437
+ else
438
+ # Route based on style
439
+ case routing_style
440
+ when "SPLINES"
441
+ route_spline_self_loop(section, edge, node, loop_index)
442
+ when "POLYLINE"
443
+ route_polyline_self_loop(section, edge, node, loop_index)
444
+ else
445
+ route_orthogonal_self_loop(section, edge, node, loop_index)
446
+ end
447
+ end
448
+ end
449
+
450
+ # Get self-loop index for multiple loops on same node
451
+ def get_self_loop_index(edge, node, graph)
452
+ return 0 unless graph.edges
453
+
454
+ # Find all self-loops on this node
455
+ self_loops = graph.edges.select do |e|
456
+ self_loop?(e) && e.sources&.first == node.id
457
+ end
458
+
459
+ # Return index of current edge
460
+ self_loops.index(edge) || 0
461
+ end
462
+
463
+ # Route orthogonal self-loop (rectangular path)
464
+ def route_orthogonal_self_loop(section, edge, node, loop_index)
465
+ # Calculate offset based on loop index
466
+ offset = calculate_loop_offset(loop_index)
467
+
468
+ # Get self-loop side
469
+ side = get_self_loop_side(edge, node)
470
+
471
+ # Calculate dimensions
472
+ width = ((node.width || 50.0) * 0.4) + offset
473
+ height = ((node.height || 50.0) * 0.4) + offset
474
+
475
+ # Calculate start/end points based on side
476
+ case side
477
+ when "EAST"
478
+ route_east_self_loop(section, node, width, height)
479
+ when "WEST"
480
+ route_west_self_loop(section, node, width, height)
481
+ when "NORTH"
482
+ route_north_self_loop(section, node, width, height)
483
+ when "SOUTH"
484
+ route_south_self_loop(section, node, width, height)
485
+ else
486
+ # Default: EAST
487
+ route_east_self_loop(section, node, width, height)
488
+ end
489
+ end
490
+
491
+ # Route self-loop on EAST side
492
+ def route_east_self_loop(section, node, width, height)
493
+ node_x = node.x || 0.0
494
+ node_y = node.y || 0.0
495
+ node_width = node.width || 50.0
496
+ node_height = node.height || 50.0
497
+
498
+ # Start point (right middle of node)
499
+ start_x = node_x + node_width
500
+ start_y = node_y + (node_height / 2.0)
501
+
502
+ # End point (slightly below start)
503
+ end_x = start_x
504
+ end_y = start_y + 10.0
505
+
506
+ section.start_point = Geometry::Point.new(x: start_x, y: start_y)
507
+ section.end_point = Geometry::Point.new(x: end_x, y: end_y)
508
+
509
+ # Bend points forming rectangular loop
510
+ section.bend_points = [
511
+ Geometry::Point.new(x: start_x + width, y: start_y),
512
+ Geometry::Point.new(x: start_x + width, y: start_y - height),
513
+ Geometry::Point.new(x: start_x + width, y: start_y + height),
514
+ Geometry::Point.new(x: end_x, y: end_y - 5.0),
515
+ ]
516
+ end
517
+
518
+ # Route self-loop on WEST side
519
+ def route_west_self_loop(section, node, width, height)
520
+ node_x = node.x || 0.0
521
+ node_y = node.y || 0.0
522
+ node_height = node.height || 50.0
523
+
524
+ # Start point (left middle of node)
525
+ start_x = node_x
526
+ start_y = node_y + (node_height / 2.0)
527
+
528
+ # End point (slightly below start)
529
+ end_x = start_x
530
+ end_y = start_y + 10.0
531
+
532
+ section.start_point = Geometry::Point.new(x: start_x, y: start_y)
533
+ section.end_point = Geometry::Point.new(x: end_x, y: end_y)
534
+
535
+ # Bend points forming rectangular loop
536
+ section.bend_points = [
537
+ Geometry::Point.new(x: start_x - width, y: start_y),
538
+ Geometry::Point.new(x: start_x - width, y: start_y - height),
539
+ Geometry::Point.new(x: start_x - width, y: start_y + height),
540
+ Geometry::Point.new(x: end_x, y: end_y - 5.0),
541
+ ]
542
+ end
543
+
544
+ # Route self-loop on NORTH side
545
+ def route_north_self_loop(section, node, width, height)
546
+ node_x = node.x || 0.0
547
+ node_y = node.y || 0.0
548
+ node_width = node.width || 50.0
549
+
550
+ # Start point (top middle of node)
551
+ start_x = node_x + (node_width / 2.0)
552
+ start_y = node_y
553
+
554
+ # End point (slightly to the right of start)
555
+ end_x = start_x + 10.0
556
+ end_y = start_y
557
+
558
+ section.start_point = Geometry::Point.new(x: start_x, y: start_y)
559
+ section.end_point = Geometry::Point.new(x: end_x, y: end_y)
560
+
561
+ # Bend points forming rectangular loop
562
+ section.bend_points = [
563
+ Geometry::Point.new(x: start_x, y: start_y - height),
564
+ Geometry::Point.new(x: start_x - width, y: start_y - height),
565
+ Geometry::Point.new(x: start_x + width, y: start_y - height),
566
+ Geometry::Point.new(x: end_x - 5.0, y: end_y),
567
+ ]
568
+ end
569
+
570
+ # Route self-loop on SOUTH side
571
+ def route_south_self_loop(section, node, width, height)
572
+ node_x = node.x || 0.0
573
+ node_y = node.y || 0.0
574
+ node_width = node.width || 50.0
575
+ node_height = node.height || 50.0
576
+
577
+ # Start point (bottom middle of node)
578
+ start_x = node_x + (node_width / 2.0)
579
+ start_y = node_y + node_height
580
+
581
+ # End point (slightly to the right of start)
582
+ end_x = start_x + 10.0
583
+ end_y = start_y
584
+
585
+ section.start_point = Geometry::Point.new(x: start_x, y: start_y)
586
+ section.end_point = Geometry::Point.new(x: end_x, y: end_y)
587
+
588
+ # Bend points forming rectangular loop
589
+ section.bend_points = [
590
+ Geometry::Point.new(x: start_x, y: start_y + height),
591
+ Geometry::Point.new(x: start_x - width, y: start_y + height),
592
+ Geometry::Point.new(x: start_x + width, y: start_y + height),
593
+ Geometry::Point.new(x: end_x - 5.0, y: end_y),
594
+ ]
595
+ end
596
+
597
+ # Route spline self-loop (curved path)
598
+ def route_spline_self_loop(section, edge, node, loop_index)
599
+ # Calculate offset based on loop index
600
+ offset = calculate_loop_offset(loop_index)
601
+
602
+ # Get self-loop side
603
+ side = get_self_loop_side(edge, node)
604
+
605
+ node_x = node.x || 0.0
606
+ node_y = node.y || 0.0
607
+ node_width = node.width || 50.0
608
+ node_height = node.height || 50.0
609
+
610
+ # Calculate radius based on offset
611
+ radius = ((node_width + node_height) / 4.0) + offset
612
+
613
+ case side
614
+ when "EAST"
615
+ start_x = node_x + node_width
616
+ start_y = node_y + (node_height / 2.0)
617
+ end_x = start_x
618
+ end_y = start_y + 10.0
619
+
620
+ # Control points for circular arc on right side
621
+ control1 = Geometry::Point.new(
622
+ x: start_x + radius,
623
+ y: start_y - radius,
624
+ )
625
+ control2 = Geometry::Point.new(
626
+ x: start_x + radius,
627
+ y: start_y + radius,
628
+ )
629
+ when "WEST"
630
+ start_x = node_x
631
+ start_y = node_y + (node_height / 2.0)
632
+ end_x = start_x
633
+ end_y = start_y + 10.0
634
+
635
+ # Control points for circular arc on left side
636
+ control1 = Geometry::Point.new(
637
+ x: start_x - radius,
638
+ y: start_y - radius,
639
+ )
640
+ control2 = Geometry::Point.new(
641
+ x: start_x - radius,
642
+ y: start_y + radius,
643
+ )
644
+ when "NORTH"
645
+ start_x = node_x + (node_width / 2.0)
646
+ start_y = node_y
647
+ end_x = start_x + 10.0
648
+ end_y = start_y
649
+
650
+ # Control points for circular arc on top
651
+ control1 = Geometry::Point.new(
652
+ x: start_x - radius,
653
+ y: start_y - radius,
654
+ )
655
+ control2 = Geometry::Point.new(
656
+ x: start_x + radius,
657
+ y: start_y - radius,
658
+ )
659
+ when "SOUTH"
660
+ start_x = node_x + (node_width / 2.0)
661
+ start_y = node_y + node_height
662
+ end_x = start_x + 10.0
663
+ end_y = start_y
664
+
665
+ # Control points for circular arc on bottom
666
+ control1 = Geometry::Point.new(
667
+ x: start_x - radius,
668
+ y: start_y + radius,
669
+ )
670
+ control2 = Geometry::Point.new(
671
+ x: start_x + radius,
672
+ y: start_y + radius,
673
+ )
674
+ else
675
+ # Default: EAST
676
+ start_x = node_x + node_width
677
+ start_y = node_y + (node_height / 2.0)
678
+ end_x = start_x
679
+ end_y = start_y + 10.0
680
+
681
+ control1 = Geometry::Point.new(
682
+ x: start_x + radius,
683
+ y: start_y - radius,
684
+ )
685
+ control2 = Geometry::Point.new(
686
+ x: start_x + radius,
687
+ y: start_y + radius,
688
+ )
689
+ end
690
+
691
+ section.start_point = Geometry::Point.new(x: start_x, y: start_y)
692
+ section.end_point = Geometry::Point.new(x: end_x, y: end_y)
693
+ section.bend_points = [control1, control2]
694
+ end
695
+
696
+ # Route polyline self-loop (simple path)
697
+ def route_polyline_self_loop(section, edge, node, loop_index)
698
+ # For polyline, use orthogonal routing
699
+ route_orthogonal_self_loop(section, edge, node, loop_index)
700
+ end
701
+
702
+ # Calculate offset for multiple self-loops on same node
703
+ def calculate_loop_offset(loop_index)
704
+ base_offset = 20.0
705
+ base_offset * (loop_index + 1)
706
+ end
707
+
708
+ # Get self-loop side from options or default
709
+ def get_self_loop_side(edge, node)
710
+ # Check edge layout options first
711
+ if edge.layout_options
712
+ side = edge.layout_options["elk.selfLoopSide"] ||
713
+ edge.layout_options["selfLoopSide"]
714
+ return side if side
715
+ end
716
+
717
+ # Check node layout options
718
+ if node.layout_options
719
+ side = node.layout_options["elk.selfLoopSide"] ||
720
+ node.layout_options["selfLoopSide"]
721
+ return side if side
722
+ end
723
+
724
+ # Default: EAST
725
+ "EAST"
726
+ end
727
+
728
+ # Check if self-loop edge uses ports
729
+ def edge_uses_ports_for_self_loop?(edge, node)
730
+ return false unless node.ports&.any?
731
+
732
+ source_id = edge.sources&.first
733
+ target_id = edge.targets&.first
734
+
735
+ return false unless source_id && target_id
736
+
737
+ # Check if source or target is a port ID
738
+ source_port = find_port_by_id(source_id, node)
739
+ target_port = find_port_by_id(target_id, node)
740
+
741
+ !!(source_port || target_port)
742
+ end
743
+
744
+ # Route self-loop with ports
745
+ def route_self_loop_with_ports(section, edge, node, loop_index,
746
+ routing_style)
747
+ source_id = edge.sources.first
748
+ target_id = edge.targets.first
749
+
750
+ source_port = find_port_by_id(source_id, node)
751
+ target_port = find_port_by_id(target_id, node)
752
+
753
+ # Get port positions
754
+ start_point = if source_port
755
+ get_port_absolute_position(source_port, node)
756
+ else
757
+ get_node_center(node)
758
+ end
759
+
760
+ end_point = if target_port
761
+ get_port_absolute_position(target_port, node)
762
+ else
763
+ get_node_center(node)
764
+ end
765
+
766
+ section.start_point = start_point
767
+ section.end_point = end_point
768
+
769
+ # Calculate offset
770
+ offset = calculate_loop_offset(loop_index)
771
+
772
+ # Route between ports with appropriate style
773
+ if source_port && target_port
774
+ route_port_to_port_self_loop(
775
+ section,
776
+ start_point,
777
+ end_point,
778
+ source_port,
779
+ target_port,
780
+ offset,
781
+ routing_style,
782
+ )
783
+ else
784
+ # Fallback to regular self-loop routing
785
+ case routing_style
786
+ when "SPLINES"
787
+ route_spline_self_loop(section, edge, node, loop_index)
788
+ else
789
+ route_orthogonal_self_loop(section, edge, node, loop_index)
790
+ end
791
+ end
792
+ end
793
+
794
+ # Get absolute position of a port
795
+ def get_port_absolute_position(port, node)
796
+ Geometry::Point.new(
797
+ x: (node.x || 0.0) + (port.x || 0.0),
798
+ y: (node.y || 0.0) + (port.y || 0.0),
799
+ )
800
+ end
801
+
802
+ # Route self-loop from port to port
803
+ def route_port_to_port_self_loop(section, start_point, end_point,
804
+ source_port, target_port, offset,
805
+ routing_style)
806
+ # Calculate midpoint for loop
807
+ (start_point.x + end_point.x) / 2.0
808
+ (start_point.y + end_point.y) / 2.0
809
+
810
+ # Determine loop direction based on port sides
811
+ source_side = source_port.side || "EAST"
812
+ target_side = target_port.side || "EAST"
813
+
814
+ if routing_style == "SPLINES"
815
+ # Create smooth curve between ports
816
+ route_spline_port_self_loop(
817
+ section,
818
+ start_point,
819
+ end_point,
820
+ source_side,
821
+ target_side,
822
+ offset,
823
+ )
824
+ else
825
+ # Create orthogonal path between ports
826
+ route_orthogonal_port_self_loop(
827
+ section,
828
+ start_point,
829
+ end_point,
830
+ source_side,
831
+ target_side,
832
+ offset,
833
+ )
834
+ end
835
+ end
836
+
837
+ # Route orthogonal self-loop between ports
838
+ def route_orthogonal_port_self_loop(section, start_point, end_point,
839
+ source_side, target_side, offset)
840
+ section.bend_points = []
841
+
842
+ # Create bend points based on port sides
843
+ case [source_side, target_side]
844
+ when ["EAST", "EAST"], ["WEST", "WEST"]
845
+ # Both on same vertical side - create horizontal loop
846
+ extension = offset + 30.0
847
+ mid_y = (start_point.y + end_point.y) / 2.0
848
+
849
+ if source_side == "EAST"
850
+ section.add_bend_point(start_point.x + extension, start_point.y)
851
+ section.add_bend_point(start_point.x + extension, mid_y)
852
+ section.add_bend_point(end_point.x + extension, end_point.y)
853
+ else
854
+ section.add_bend_point(start_point.x - extension, start_point.y)
855
+ section.add_bend_point(start_point.x - extension, mid_y)
856
+ section.add_bend_point(end_point.x - extension, end_point.y)
857
+ end
858
+ when ["NORTH", "NORTH"], ["SOUTH", "SOUTH"]
859
+ # Both on same horizontal side - create vertical loop
860
+ extension = offset + 30.0
861
+ mid_x = (start_point.x + end_point.x) / 2.0
862
+
863
+ if source_side == "NORTH"
864
+ section.add_bend_point(start_point.x, start_point.y - extension)
865
+ section.add_bend_point(mid_x, start_point.y - extension)
866
+ section.add_bend_point(end_point.x, end_point.y - extension)
867
+ else
868
+ section.add_bend_point(start_point.x, start_point.y + extension)
869
+ section.add_bend_point(mid_x, start_point.y + extension)
870
+ section.add_bend_point(end_point.x, end_point.y + extension)
871
+ end
872
+ else
873
+ # Different sides - create L-shaped path
874
+ mid_x = (start_point.x + end_point.x) / 2.0
875
+ section.add_bend_point(mid_x, start_point.y)
876
+ section.add_bend_point(mid_x, end_point.y)
877
+ end
878
+ end
879
+
880
+ # Route spline self-loop between ports
881
+ def route_spline_port_self_loop(section, start_point, end_point,
882
+ source_side, target_side, offset)
883
+ # Create Bezier curve control points
884
+ extension = offset + 30.0
885
+
886
+ case [source_side, target_side]
887
+ when ["EAST", "EAST"]
888
+ control1 = Geometry::Point.new(
889
+ x: start_point.x + extension,
890
+ y: start_point.y,
891
+ )
892
+ control2 = Geometry::Point.new(
893
+ x: end_point.x + extension,
894
+ y: end_point.y,
895
+ )
896
+ when ["WEST", "WEST"]
897
+ control1 = Geometry::Point.new(
898
+ x: start_point.x - extension,
899
+ y: start_point.y,
900
+ )
901
+ control2 = Geometry::Point.new(
902
+ x: end_point.x - extension,
903
+ y: end_point.y,
904
+ )
905
+ when ["NORTH", "NORTH"]
906
+ control1 = Geometry::Point.new(
907
+ x: start_point.x,
908
+ y: start_point.y - extension,
909
+ )
910
+ control2 = Geometry::Point.new(
911
+ x: end_point.x,
912
+ y: end_point.y - extension,
913
+ )
914
+ when ["SOUTH", "SOUTH"]
915
+ control1 = Geometry::Point.new(
916
+ x: start_point.x,
917
+ y: start_point.y + extension,
918
+ )
919
+ control2 = Geometry::Point.new(
920
+ x: end_point.x,
921
+ y: end_point.y + extension,
922
+ )
923
+ else
924
+ # Default perpendicular control points
925
+ mid_x = (start_point.x + end_point.x) / 2.0
926
+ (start_point.y + end_point.y) / 2.0
927
+ control1 = Geometry::Point.new(x: mid_x, y: start_point.y)
928
+ control2 = Geometry::Point.new(x: mid_x, y: end_point.y)
929
+ end
930
+
931
+ section.bend_points = [control1, control2]
932
+ end
933
+ end
934
+ end
935
+ end