elkrb 1.0.2 → 2.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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.adoc +364 -0
  3. data/LICENSE +25 -0
  4. data/README.adoc +695 -557
  5. data/exe/elkrb +36 -2
  6. data/lib/elkrb/atomic_destination.rb +53 -0
  7. data/lib/elkrb/best_effort_write.rb +49 -0
  8. data/lib/elkrb/cli/layout_flag_options.rb +54 -0
  9. data/lib/elkrb/cli.rb +155 -82
  10. data/lib/elkrb/command_resolver.rb +186 -0
  11. data/lib/elkrb/commands/batch_command.rb +28 -5
  12. data/lib/elkrb/commands/convert_command.rb +22 -52
  13. data/lib/elkrb/commands/diagram_command.rb +234 -102
  14. data/lib/elkrb/commands/options_command.rb +87 -0
  15. data/lib/elkrb/commands/render_command.rb +6 -1
  16. data/lib/elkrb/commands/validate_command.rb +146 -91
  17. data/lib/elkrb/errors.rb +20 -1
  18. data/lib/elkrb/format_sniffer.rb +362 -0
  19. data/lib/elkrb/geometry/bezier.rb +14 -4
  20. data/lib/elkrb/geometry/point.rb +5 -6
  21. data/lib/elkrb/geometry/rectangle.rb +2 -2
  22. data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
  23. data/lib/elkrb/graph/edge.rb +89 -3
  24. data/lib/elkrb/graph/graph.rb +43 -15
  25. data/lib/elkrb/graph/label.rb +16 -2
  26. data/lib/elkrb/graph/node.rb +13 -2
  27. data/lib/elkrb/graph/node_constraints.rb +139 -8
  28. data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
  29. data/lib/elkrb/graph/port.rb +29 -21
  30. data/lib/elkrb/graph/read_only_mapping.rb +21 -0
  31. data/lib/elkrb/graphviz_wrapper.rb +152 -44
  32. data/lib/elkrb/layout/algorithm_registry.rb +65 -8
  33. data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
  34. data/lib/elkrb/layout/algorithms/box.rb +47 -20
  35. data/lib/elkrb/layout/algorithms/disco.rb +150 -61
  36. data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
  37. data/lib/elkrb/layout/algorithms/force.rb +145 -86
  38. data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
  39. data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
  40. data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
  41. data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
  42. data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
  43. data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
  44. data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
  45. data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
  46. data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
  47. data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
  48. data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
  49. data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
  50. data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
  51. data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
  52. data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
  53. data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
  54. data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
  55. data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
  56. data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
  57. data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
  58. data/lib/elkrb/layout/algorithms/layered.rb +548 -13
  59. data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
  60. data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
  61. data/lib/elkrb/layout/algorithms/radial.rb +188 -29
  62. data/lib/elkrb/layout/algorithms/random.rb +131 -17
  63. data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
  64. data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
  65. data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
  66. data/lib/elkrb/layout/algorithms/stress.rb +391 -84
  67. data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
  68. data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
  69. data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
  70. data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
  71. data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
  72. data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
  73. data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
  74. data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
  75. data/lib/elkrb/layout/edge_router.rb +533 -337
  76. data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
  77. data/lib/elkrb/layout/java_random.rb +59 -0
  78. data/lib/elkrb/layout/label_placer.rb +165 -157
  79. data/lib/elkrb/layout/layout_engine.rb +46 -23
  80. data/lib/elkrb/layout/node_index.rb +149 -0
  81. data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
  82. data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
  83. data/lib/elkrb/layout/polyomino/grid.rb +106 -0
  84. data/lib/elkrb/layout/polyomino/packer.rb +95 -0
  85. data/lib/elkrb/layout/polyomino/piece.rb +52 -0
  86. data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
  87. data/lib/elkrb/layout/spore/body.rb +65 -0
  88. data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
  89. data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
  90. data/lib/elkrb/layout/spore/gap.rb +76 -0
  91. data/lib/elkrb/layout/spore/geometry.rb +104 -0
  92. data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
  93. data/lib/elkrb/layout/spore/placement.rb +57 -0
  94. data/lib/elkrb/layout/spore/rays.rb +119 -0
  95. data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
  96. data/lib/elkrb/layout/spore/triangulation.rb +159 -0
  97. data/lib/elkrb/layout_flags.rb +69 -0
  98. data/lib/elkrb/options/decimal.rb +50 -0
  99. data/lib/elkrb/options/element_options.rb +43 -0
  100. data/lib/elkrb/options/elk_padding.rb +20 -14
  101. data/lib/elkrb/options/k_vector.rb +6 -7
  102. data/lib/elkrb/options/k_vector_chain.rb +12 -20
  103. data/lib/elkrb/options/option_map.rb +85 -0
  104. data/lib/elkrb/options/registry.rb +335 -0
  105. data/lib/elkrb/options/resolver.rb +151 -0
  106. data/lib/elkrb/options/spellings.rb +62 -0
  107. data/lib/elkrb/options/unhonoured_report.rb +194 -0
  108. data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
  109. data/lib/elkrb/parsers/elkt/parser.rb +424 -0
  110. data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
  111. data/lib/elkrb/parsers/elkt/token.rb +40 -0
  112. data/lib/elkrb/parsers/elkt_parser.rb +32 -230
  113. data/lib/elkrb/serializers/dot_serializer.rb +342 -54
  114. data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
  115. data/lib/elkrb/version.rb +1 -1
  116. data/lib/elkrb.rb +60 -61
  117. metadata +71 -49
  118. data/.rspec +0 -3
  119. data/.rubocop.yml +0 -11
  120. data/Gemfile +0 -13
  121. data/Rakefile +0 -64
  122. data/benchmarks/README.md +0 -172
  123. data/benchmarks/elkjs_benchmark.js +0 -140
  124. data/benchmarks/elkrb_benchmark.rb +0 -145
  125. data/benchmarks/fixtures/graphs.json +0 -10777
  126. data/benchmarks/generate_report.rb +0 -241
  127. data/benchmarks/generate_test_graphs.rb +0 -154
  128. data/benchmarks/results/elkrb_results.json +0 -280
  129. data/benchmarks/results/elkrb_summary.json +0 -285
  130. data/elkrb.gemspec +0 -39
  131. data/examples/dot_export_demo.rb +0 -133
  132. data/examples/hierarchical_graph.rb +0 -19
  133. data/examples/layout_constraints_demo.rb +0 -272
  134. data/examples/port_constraints_demo.rb +0 -291
  135. data/examples/self_loop_demo.rb +0 -391
  136. data/examples/simple_graph.rb +0 -50
  137. data/examples/spline_routing_demo.rb +0 -235
  138. data/lib/elkrb/graph/layout_options.rb +0 -247
  139. data/sig/elkrb/constraints.rbs +0 -114
  140. data/sig/elkrb/geometry.rbs +0 -61
  141. data/sig/elkrb/graph.rbs +0 -112
  142. data/sig/elkrb/layout.rbs +0 -107
  143. data/sig/elkrb/options.rbs +0 -81
  144. data/sig/elkrb.rbs +0 -32
@@ -2,165 +2,298 @@
2
2
 
3
3
  require_relative "../geometry/point"
4
4
  require_relative "../geometry/bezier"
5
+ require_relative "../geometry/rectangle"
5
6
  require_relative "../graph/edge"
7
+ require_relative "node_index"
6
8
 
7
9
  module Elkrb
8
10
  module Layout
9
- # Provides edge routing functionality for layout algorithms
11
+ # Provides edge routing functionality for layout algorithms.
12
+ #
13
+ # The including class sets @resolver (an Options::Resolver), as
14
+ # BaseAlgorithm does; every option read goes through it.
10
15
  module EdgeRouter
11
16
  # Route edges in a graph using specified routing style
12
17
  # @param graph [Graph::Graph] The graph to route edges for
13
- # @param node_map [Hash] Map of node IDs to node objects
18
+ # @param node_map [NodeIndex] Resolves node/port ids to owning
19
+ # nodes for this level
14
20
  # @param routing_style [String] Routing style (ORTHOGONAL, POLYLINE,
15
21
  # 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)
22
+ # A plain `{id => node}` Hash was the documented node_map before
23
+ # NodeIndex existed, and both public entry points still take one.
24
+ # Only #node is ever asked of a node_map, so that is all this wraps.
25
+ class HashNodeMap
26
+ def initialize(nodes)
27
+ @nodes = nodes
28
+ end
29
+
30
+ # A caller-supplied Hash is keyed by node id, but an edge endpoint may
31
+ # name a PORT. Fall back to the node owning that port, the way the
32
+ # pre-NodeIndex router did — otherwise a ported edge silently ends up
33
+ # with no sections at all.
34
+ def node(id)
35
+ @nodes[id] || owner_of_port(id)
36
+ end
37
+
38
+ private
39
+
40
+ def owner_of_port(id)
41
+ @nodes.each_value.find { |node| owns_port?(node, id) }
42
+ end
43
+
44
+ def owns_port?(node, id)
45
+ return false unless node.respond_to?(:ports)
46
+
47
+ node.ports&.any? { |port| port.id == id } || false
48
+ end
49
+ end
50
+ private_constant :HashNodeMap
51
+
52
+ def coerce_node_map(node_map)
53
+ node_map.is_a?(::Hash) ? HashNodeMap.new(node_map) : node_map
54
+ end
19
55
 
56
+ def route_edges(graph, node_map = nil, routing_style = nil)
57
+ node_map = coerce_node_map(node_map || NodeIndex.build(graph))
20
58
  graph.edges&.each do |edge|
21
- if self_loop?(edge)
22
- route_self_loop(edge, node_map, graph, routing_style)
59
+ style = routing_style || get_edge_routing_style(graph, edge)
60
+ if self_loop?(edge, node_map)
61
+ route_self_loop(edge, node_map, graph, style)
23
62
  else
24
- route_edge_with_style(edge, node_map, graph, routing_style)
63
+ route_edge_with_style(edge, node_map, graph, style)
25
64
  end
26
65
  end
27
66
  end
28
67
 
29
68
  # Route a single edge
30
69
  # @param edge [Graph::Edge] The edge to route
31
- # @param node_map [Hash] Map of node IDs to node objects
70
+ # @param node_map [NodeIndex] Resolves node/port ids to owning
71
+ # nodes for this level
32
72
  # @param graph [Graph::Graph] The containing graph
33
- def route_edge(edge, node_map, _graph)
73
+ def route_edge(edge, node_map, graph, routing_style = nil)
34
74
  return unless edge.sources&.any? && edge.targets&.any?
35
75
 
76
+ node_map = coerce_node_map(node_map)
77
+
36
78
  # Get source and target nodes
37
79
  source_id = edge.sources.first
38
80
  target_id = edge.targets.first
39
81
 
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)
82
+ source_node = node_map.node(source_id)
83
+ target_node = node_map.node(target_id)
47
84
 
48
85
  return unless source_node && target_node
49
86
 
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
87
+ section = reset_section(edge, graph)
88
+ routing_style ||= get_edge_routing_style(graph, edge)
59
89
 
60
90
  # Calculate routing points based on port-awareness
61
91
  if edge_uses_ports?(edge, source_node, target_node)
62
- route_with_ports(section, edge, source_node, target_node)
92
+ route_with_ports(section, edge, source_node, target_node,
93
+ routing_style, graph)
63
94
  else
64
- route_node_to_node(section, source_node, target_node, edge)
95
+ route_node_to_node(section, source_node, target_node, routing_style)
65
96
  end
66
97
  end
67
98
 
68
99
  private
69
100
 
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
101
+ def route_cross_level_edges(graph)
102
+ hierarchy = @resolver.get("elk.hierarchyHandling", graph)
103
+ return unless hierarchy == "INCLUDE_CHILDREN"
104
+
105
+ deep_index = NodeIndex.build_deep(graph)
106
+ level_index = NodeIndex.build(graph)
107
+ graph.edges&.each do |edge|
108
+ route_cross_level_edge(edge, graph, level_index, deep_index)
75
109
  end
76
- map
77
110
  end
78
111
 
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
112
+ def route_cross_level_edge(edge, graph, level_index, deep_index)
113
+ return unless edge.sources&.any? && edge.targets&.any?
114
+
115
+ source = unique_deep_endpoint(deep_index, edge.sources.first)
116
+ target = unique_deep_endpoint(deep_index, edge.targets.first)
117
+ return unless source && target
118
+ return if self_loop?(edge, level_index)
119
+ return if level_index.node(edge.sources.first) &&
120
+ level_index.node(edge.targets.first)
121
+
122
+ start_point, end_point = absolute_endpoint_points(
123
+ edge, source, target
124
+ )
125
+ section = reset_section(edge, graph)
126
+ section.start_point = start_point
127
+ section.end_point = end_point
128
+ section.bend_points = []
129
+ route_cross_level_bends(section, edge, graph)
84
130
  end
85
131
 
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?
132
+ def unique_deep_endpoint(deep_index, id)
133
+ matches = deep_index[id] || []
134
+ if matches.length > 1
135
+ raise Elkrb::ValidationError, "ambiguous id across levels: #{id}"
136
+ end
91
137
 
92
- has_source_ports || has_target_ports
138
+ matches.first
93
139
  end
94
140
 
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
141
+ def absolute_endpoint_points(edge, source, target)
142
+ source_node, source_offset = source
143
+ target_node, target_offset = target
144
+ source_rect = offset_rectangle(source_node, source_offset)
145
+ target_rect = offset_rectangle(target_node, target_offset)
98
146
  source_port = find_port_by_id(edge.sources.first, source_node)
99
147
  target_port = find_port_by_id(edge.targets.first, target_node)
148
+ source_anchor = absolute_endpoint_anchor(source_node, source_offset,
149
+ source_port, source_rect)
150
+ target_anchor = absolute_endpoint_anchor(target_node, target_offset,
151
+ target_port, target_rect)
100
152
 
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,
153
+ start_point = if source_port
154
+ source_anchor
155
+ else
156
+ clip_to_border(source_rect, source_anchor,
157
+ target_anchor)
158
+ end
159
+ end_point = if target_port
160
+ target_anchor
161
+ else
162
+ clip_to_border(target_rect, target_anchor,
163
+ source_anchor)
164
+ end
165
+ [start_point, end_point]
166
+ end
167
+
168
+ def offset_rectangle(node, offset)
169
+ Geometry::Rectangle.new(
170
+ (node.x || 0.0) + offset.x,
171
+ (node.y || 0.0) + offset.y,
172
+ node.width || 0.0,
173
+ node.height || 0.0,
110
174
  )
175
+ end
176
+
177
+ def absolute_endpoint_anchor(node, offset, port, rectangle)
178
+ return rectangle.center unless port
179
+
180
+ point = get_port_position(port.id, node, nil)
181
+ Geometry::Point.new(x: offset.x + point.x, y: offset.y + point.y)
182
+ end
183
+
184
+ def route_cross_level_bends(section, edge, graph)
185
+ case get_edge_routing_style(graph, edge)
186
+ when "ORTHOGONAL"
187
+ add_orthogonal_bend_points(section, section.start_point,
188
+ section.end_point)
189
+ when "SPLINES"
190
+ add_spline_control_points(section, section.start_point,
191
+ section.end_point, edge)
192
+ end
193
+ end
194
+
195
+ # Check if edge should use port-based routing
196
+ def edge_uses_ports?(edge, source_node, target_node)
197
+ find_port_by_id(edge.sources.first, source_node) ||
198
+ find_port_by_id(edge.targets.first, target_node)
199
+ end
200
+
201
+ # Route edge using port positions
202
+ def route_with_ports(section, edge, source_node, target_node,
203
+ routing_style, graph)
204
+ start_point, end_point = endpoint_points(edge, source_node, target_node)
111
205
 
112
206
  section.start_point = start_point
113
207
  section.end_point = end_point
114
- section.bend_points ||= []
208
+ section.bend_points = []
115
209
 
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,
210
+ if routing_style == "ORTHOGONAL"
211
+ add_orthogonal_bend_points_between_nodes(
212
+ section, start_point, end_point, source_node, target_node, graph
124
213
  )
125
- elsif should_use_orthogonal_routing?(edge)
126
- add_orthogonal_bend_points(section, start_point, end_point)
127
214
  end
128
215
  end
129
216
 
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)
217
+ # Route edge from source border to target border
218
+ def route_node_to_node(section, source_node, target_node, routing_style)
219
+ source_rect = node_rectangle(source_node)
220
+ target_rect = node_rectangle(target_node)
221
+ source_center = source_rect.center
222
+ target_center = target_rect.center
223
+ start_point = clip_to_border(source_rect, source_center, target_center)
224
+ end_point = clip_to_border(target_rect, target_center, source_center)
134
225
 
135
226
  section.start_point = start_point
136
227
  section.end_point = end_point
137
- section.bend_points ||= []
228
+ section.bend_points = []
138
229
 
139
- # Add orthogonal routing if configured
140
- if edge && should_use_orthogonal_routing?(edge)
230
+ if routing_style == "ORTHOGONAL"
141
231
  add_orthogonal_bend_points(section, start_point, end_point)
142
232
  end
143
233
  end
144
234
 
235
+ def node_rectangle(node)
236
+ Geometry::Rectangle.new(
237
+ node.x || 0.0,
238
+ node.y || 0.0,
239
+ node.width || 0.0,
240
+ node.height || 0.0,
241
+ )
242
+ end
243
+
244
+ # Intersect the ray from a rectangle's centre towards another point
245
+ # with the rectangle border.
246
+ def clip_to_border(rect, from, to)
247
+ dx = to.x - from.x
248
+ dy = to.y - from.y
249
+ return Geometry::Point.new(x: from.x, y: from.y) if dx.zero? && dy.zero?
250
+
251
+ scales = []
252
+ scales << ((rect.width / 2.0) / dx.abs) unless dx.zero?
253
+ scales << ((rect.height / 2.0) / dy.abs) unless dy.zero?
254
+ scale = scales.min
255
+
256
+ Geometry::Point.new(
257
+ x: from.x + (dx * scale),
258
+ y: from.y + (dy * scale),
259
+ )
260
+ end
261
+
262
+ def reset_section(edge, graph)
263
+ section = Graph::EdgeSection.new(
264
+ id: "#{edge.id}_s0",
265
+ incoming_shape: edge.sources.first,
266
+ outgoing_shape: edge.targets.first,
267
+ )
268
+ edge.sections = [section]
269
+ edge.container ||= graph.id
270
+ section
271
+ end
272
+
145
273
  # Find port by ID
146
274
  def find_port_by_id(port_id, node)
147
275
  node.ports&.find { |p| p.id == port_id }
148
276
  end
149
277
 
150
- # Get position of a port or node center
278
+ # Get the port's outer-border anchor, or the node center.
151
279
  def get_port_position(port_id, node, _direction)
152
- # Try to find port
153
280
  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)
281
+ return get_node_center(node) unless port
282
+
283
+ x = (node.x || 0.0) + (port.x || 0.0)
284
+ y = (node.y || 0.0) + (port.y || 0.0)
285
+ width = port.width || 0.0
286
+ height = port.height || 0.0
287
+ case port.side
288
+ when Graph::Port::NORTH
289
+ Geometry::Point.new(x: x + (width / 2.0), y: y)
290
+ when Graph::Port::SOUTH
291
+ Geometry::Point.new(x: x + (width / 2.0), y: y + height)
292
+ when Graph::Port::WEST
293
+ Geometry::Point.new(x: x, y: y + (height / 2.0))
294
+ when Graph::Port::EAST
295
+ Geometry::Point.new(x: x + width, y: y + (height / 2.0))
296
+ else Geometry::Point.new(x: x, y: y)
164
297
  end
165
298
  end
166
299
 
@@ -171,37 +304,27 @@ module Elkrb
171
304
  Geometry::Point.new(x: x, y: y)
172
305
  end
173
306
 
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
307
+ def endpoint_points(edge, source_node, target_node)
308
+ source_id = edge.sources.first
309
+ target_id = edge.targets.first
310
+ source_port = find_port_by_id(source_id, source_node)
311
+ target_port = find_port_by_id(target_id, target_node)
312
+ source_anchor = get_port_position(source_id, source_node, :outgoing)
313
+ target_anchor = get_port_position(target_id, target_node, :incoming)
184
314
 
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
315
+ start_point = if source_port
316
+ source_anchor
317
+ else
318
+ clip_to_border(node_rectangle(source_node),
319
+ source_anchor, target_anchor)
320
+ end
321
+ end_point = if target_port
322
+ target_anchor
323
+ else
324
+ clip_to_border(node_rectangle(target_node),
325
+ target_anchor, source_anchor)
326
+ end
327
+ [start_point, end_point]
205
328
  end
206
329
 
207
330
  # Add horizontal bend points (for horizontal connections)
@@ -218,35 +341,75 @@ module Elkrb
218
341
  section.add_bend_point(end_point.x, mid_y)
219
342
  end
220
343
 
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)
344
+ # Add orthogonal (right-angle) bend points
345
+ def add_orthogonal_bend_points(section, start_point, end_point)
346
+ return if start_point.x == end_point.x || start_point.y == end_point.y
347
+
348
+ dx = (end_point.x - start_point.x).abs
349
+ dy = (end_point.y - start_point.y).abs
350
+ if dx >= dy
351
+ add_horizontal_bend_points(section, start_point, end_point)
352
+ else
353
+ add_vertical_bend_points(section, start_point, end_point)
354
+ end
224
355
  end
225
356
 
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)
357
+ def add_orthogonal_bend_points_between_nodes(
358
+ section, start_point, end_point, source_node, target_node, graph
359
+ )
360
+ return if start_point.x == end_point.x || start_point.y == end_point.y
361
+
362
+ source = node_rectangle(source_node)
363
+ target = node_rectangle(target_node)
364
+
365
+ if horizontal_routing?(source, target, graph)
366
+ mid_x = horizontal_gap(source, target, start_point, end_point)
367
+ section.add_bend_point(mid_x, start_point.y)
368
+ section.add_bend_point(mid_x, end_point.y)
369
+ else
370
+ mid_y = vertical_gap(source, target, start_point, end_point)
371
+ section.add_bend_point(start_point.x, mid_y)
372
+ section.add_bend_point(end_point.x, mid_y)
373
+ end
229
374
  end
230
375
 
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
376
+ def horizontal_routing?(source, target, graph)
377
+ direction = @resolver.get("elk.direction", graph)
378
+ return true if %w[LEFT RIGHT].include?(direction)
379
+ return false if %w[DOWN UP].include?(direction)
235
380
 
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)
381
+ (target.center.x - source.center.x).abs >=
382
+ (target.center.y - source.center.y).abs
383
+ end
384
+
385
+ def horizontal_gap(source, target, start_point, end_point)
386
+ return (source.x + source.width + target.x) / 2.0 if
387
+ source.x + source.width <= target.x
388
+ return (target.x + target.width + source.x) / 2.0 if
389
+ target.x + target.width <= source.x
390
+
391
+ (start_point.x + end_point.x) / 2.0
239
392
  end
240
393
 
241
- # Get routing style from graph options
242
- def get_routing_style(graph)
243
- return "ORTHOGONAL" unless graph.layout_options
394
+ def vertical_gap(source, target, start_point, end_point)
395
+ return (source.y + source.height + target.y) / 2.0 if
396
+ source.y + source.height <= target.y
397
+ return (target.y + target.height + source.y) / 2.0 if
398
+ target.y + target.height <= source.y
244
399
 
245
- style = graph.layout_options["elk.edgeRouting"] ||
246
- graph.layout_options["edgeRouting"] ||
247
- graph.layout_options.edge_routing
400
+ (start_point.y + end_point.y) / 2.0
401
+ end
248
402
 
249
- style ? style.to_s.upcase : "ORTHOGONAL"
403
+ # Get edge routing style from graph options
404
+ #
405
+ # @param graph [Elkrb::Graph::Graph] The graph
406
+ # @return [String] Routing style (ORTHOGONAL, POLYLINE, SPLINES)
407
+ def get_edge_routing_style(graph, edge = nil)
408
+ elements = edge ? [edge, graph] : [graph]
409
+ style = @resolver.get("elk.edgeRouting", *elements)
410
+
411
+ style = style.to_s.upcase
412
+ style == "UNDEFINED" ? "ORTHOGONAL" : style
250
413
  end
251
414
 
252
415
  # Route edge with specified routing style
@@ -257,40 +420,28 @@ module Elkrb
257
420
  when "POLYLINE"
258
421
  route_polyline_edge(edge, node_map, graph)
259
422
  else
260
- route_edge(edge, node_map, graph)
423
+ route_edge(edge, node_map, graph, "ORTHOGONAL")
261
424
  end
262
425
  end
263
426
 
264
427
  # Route edge with polyline (straight segments) style
265
428
  def route_polyline_edge(edge, node_map, graph)
266
- # Polyline is just direct routing without bend points
267
- route_edge(edge, node_map, graph)
429
+ route_edge(edge, node_map, graph, "POLYLINE")
268
430
  end
269
431
 
270
432
  # Route edge with spline (curved) style
271
- def route_spline_edge(edge, node_map, _graph)
433
+ def route_spline_edge(edge, node_map, graph)
272
434
  return unless edge.sources&.any? && edge.targets&.any?
273
435
 
274
436
  source_id = edge.sources.first
275
437
  target_id = edge.targets.first
276
438
 
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)
439
+ source_node = node_map.node(source_id)
440
+ target_node = node_map.node(target_id)
282
441
 
283
442
  return unless source_node && target_node
284
443
 
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
444
+ section = reset_section(edge, graph)
294
445
 
295
446
  # Calculate spline routing
296
447
  if edge_uses_ports?(edge, source_node, target_node)
@@ -302,16 +453,7 @@ module Elkrb
302
453
 
303
454
  # Route spline edge using port positions
304
455
  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
- )
456
+ start_point, end_point = endpoint_points(edge, source_node, target_node)
315
457
 
316
458
  section.start_point = start_point
317
459
  section.end_point = end_point
@@ -322,8 +464,12 @@ module Elkrb
322
464
 
323
465
  # Route spline edge from node center to node center
324
466
  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)
467
+ source_rect = node_rectangle(source_node)
468
+ target_rect = node_rectangle(target_node)
469
+ source_center = source_rect.center
470
+ target_center = target_rect.center
471
+ start_point = clip_to_border(source_rect, source_center, target_center)
472
+ end_point = clip_to_border(target_rect, target_center, source_center)
327
473
 
328
474
  section.start_point = start_point
329
475
  section.end_point = end_point
@@ -352,17 +498,12 @@ module Elkrb
352
498
 
353
499
  # Get spline curvature from options
354
500
  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
501
+ @resolver.get("elk.spline.curvature", edge)
361
502
  end
362
503
 
363
504
  # Calculate Bezier control points for smooth curves
364
505
  def calculate_spline_controls(start_point, end_point, curvature, edge)
365
- # Determine routing direction from edge or graph options
506
+ # Determine routing direction from the edge's options
366
507
  direction = get_routing_direction(edge)
367
508
 
368
509
  case direction
@@ -390,45 +531,34 @@ module Elkrb
390
531
 
391
532
  # Get routing direction from edge options
392
533
  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
534
+ @resolver.get("elk.direction", edge, default: nil)
398
535
  end
399
536
 
400
- # Check if edge is a self-loop (source == target)
401
- def self_loop?(edge)
537
+ # Check if an edge's endpoints belong to the same node.
538
+ def self_loop?(edge, node_map = nil)
402
539
  sources = edge.sources || []
403
540
  targets = edge.targets || []
404
541
 
405
542
  return false if sources.empty? || targets.empty?
543
+ return sources.first == targets.first unless node_map
544
+
545
+ source_node = node_map.node(sources.first)
546
+ target_node = node_map.node(targets.first)
547
+ return sources.first == targets.first unless source_node || target_node
406
548
 
407
- sources.first == targets.first
549
+ source_node&.equal?(target_node)
408
550
  end
409
551
 
410
552
  # Route a self-loop edge
411
553
  def route_self_loop(edge, node_map, graph, routing_style)
412
554
  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)
555
+ node = node_map.node(node_id)
417
556
 
418
557
  return unless node
419
558
 
420
559
  # 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
560
+ loop_index = get_self_loop_index(edge, node, graph, node_map)
561
+ section = reset_section(edge, graph)
432
562
 
433
563
  # Check if edge uses ports
434
564
  if edge_uses_ports_for_self_loop?(edge, node)
@@ -440,39 +570,42 @@ module Elkrb
440
570
  when "SPLINES"
441
571
  route_spline_self_loop(section, edge, node, loop_index)
442
572
  when "POLYLINE"
443
- route_polyline_self_loop(section, edge, node, loop_index)
573
+ route_polyline_self_loop(section, edge, node, loop_index, graph)
444
574
  else
445
- route_orthogonal_self_loop(section, edge, node, loop_index)
575
+ route_orthogonal_self_loop(
576
+ section, edge, node, loop_index, graph
577
+ )
446
578
  end
447
579
  end
448
580
  end
449
581
 
450
582
  # Get self-loop index for multiple loops on same node
451
- def get_self_loop_index(edge, node, graph)
583
+ def get_self_loop_index(edge, node, graph,
584
+ node_map = NodeIndex.build(graph))
452
585
  return 0 unless graph.edges
453
586
 
454
- # Find all self-loops on this node
455
587
  self_loops = graph.edges.select do |e|
456
- self_loop?(e) && e.sources&.first == node.id
588
+ source_node = node_map.node(e.sources&.first)
589
+ self_loop?(e, node_map) && source_node.equal?(node)
457
590
  end
458
591
 
459
- # Return index of current edge
460
- self_loops.index(edge) || 0
592
+ self_loops.index { |candidate| candidate.equal?(edge) } || 0
461
593
  end
462
594
 
463
595
  # Route orthogonal self-loop (rectangular path)
464
- def route_orthogonal_self_loop(section, edge, node, loop_index)
596
+ def route_orthogonal_self_loop(section, edge, node, loop_index,
597
+ graph = nil)
465
598
  # Calculate offset based on loop index
466
- offset = calculate_loop_offset(loop_index)
599
+ offset = calculate_loop_offset(loop_index, edge, node)
467
600
 
468
601
  # Get self-loop side
469
- side = get_self_loop_side(edge, node)
470
-
471
602
  # Calculate dimensions
472
603
  width = ((node.width || 50.0) * 0.4) + offset
473
604
  height = ((node.height || 50.0) * 0.4) + offset
605
+ side = unobstructed_self_loop_side(
606
+ get_self_loop_side(edge, node), node, width, height, graph
607
+ )
474
608
 
475
- # Calculate start/end points based on side
476
609
  case side
477
610
  when "EAST"
478
611
  route_east_self_loop(section, node, width, height)
@@ -483,121 +616,143 @@ module Elkrb
483
616
  when "SOUTH"
484
617
  route_south_self_loop(section, node, width, height)
485
618
  else
486
- # Default: EAST
487
619
  route_east_self_loop(section, node, width, height)
488
620
  end
489
621
  end
490
622
 
623
+ def unobstructed_self_loop_side(preferred, node, width, height, graph)
624
+ return preferred unless graph
625
+
626
+ ([preferred] + %w[EAST WEST NORTH SOUTH]).uniq.find do |side|
627
+ self_loop_side_clear?(side, node, width, height, graph)
628
+ end || preferred
629
+ end
630
+
631
+ def self_loop_side_clear?(side, node, width, height, graph)
632
+ loop_box = self_loop_box(side, node, width, height)
633
+ graph.children.to_a.none? do |candidate|
634
+ next false if candidate.equal?(node)
635
+
636
+ rectangles_overlap?(loop_box, node_rectangle(candidate))
637
+ end
638
+ end
639
+
640
+ def self_loop_box(side, node, width, height)
641
+ x = node.x || 0.0
642
+ y = node.y || 0.0
643
+ node_width = node.width || 50.0
644
+ node_height = node.height || 50.0
645
+ case side
646
+ when "WEST"
647
+ Geometry::Rectangle.new(x - width, y + (node_height / 2.0) - 5.0,
648
+ width, 10.0)
649
+ when "NORTH"
650
+ Geometry::Rectangle.new(x + (node_width / 2.0) - 5.0, y - height,
651
+ 10.0, height)
652
+ when "SOUTH"
653
+ Geometry::Rectangle.new(
654
+ x + (node_width / 2.0) - 5.0, y + node_height, 10.0, height
655
+ )
656
+ else
657
+ Geometry::Rectangle.new(
658
+ x + node_width, y + (node_height / 2.0) - 5.0, width, 10.0
659
+ )
660
+ end
661
+ end
662
+
663
+ def rectangles_overlap?(first, second)
664
+ first.x < second.x + second.width &&
665
+ first.x + first.width > second.x &&
666
+ first.y < second.y + second.height &&
667
+ first.y + first.height > second.y
668
+ end
669
+
491
670
  # Route self-loop on EAST side
492
- def route_east_self_loop(section, node, width, height)
671
+ def route_east_self_loop(section, node, width, _height)
493
672
  node_x = node.x || 0.0
494
673
  node_y = node.y || 0.0
495
674
  node_width = node.width || 50.0
496
675
  node_height = node.height || 50.0
497
676
 
498
- # Start point (right middle of node)
499
677
  start_x = node_x + node_width
500
- start_y = node_y + (node_height / 2.0)
501
-
502
- # End point (slightly below start)
678
+ start_y = node_y + (node_height / 2.0) - 5.0
503
679
  end_x = start_x
504
680
  end_y = start_y + 10.0
505
681
 
506
682
  section.start_point = Geometry::Point.new(x: start_x, y: start_y)
507
683
  section.end_point = Geometry::Point.new(x: end_x, y: end_y)
508
684
 
509
- # Bend points forming rectangular loop
510
685
  section.bend_points = [
511
686
  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),
687
+ Geometry::Point.new(x: start_x + width, y: end_y),
515
688
  ]
516
689
  end
517
690
 
518
691
  # Route self-loop on WEST side
519
- def route_west_self_loop(section, node, width, height)
692
+ def route_west_self_loop(section, node, width, _height)
520
693
  node_x = node.x || 0.0
521
694
  node_y = node.y || 0.0
522
695
  node_height = node.height || 50.0
523
696
 
524
- # Start point (left middle of node)
525
697
  start_x = node_x
526
- start_y = node_y + (node_height / 2.0)
527
-
528
- # End point (slightly below start)
698
+ start_y = node_y + (node_height / 2.0) - 5.0
529
699
  end_x = start_x
530
700
  end_y = start_y + 10.0
531
701
 
532
702
  section.start_point = Geometry::Point.new(x: start_x, y: start_y)
533
703
  section.end_point = Geometry::Point.new(x: end_x, y: end_y)
534
704
 
535
- # Bend points forming rectangular loop
536
705
  section.bend_points = [
537
706
  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),
707
+ Geometry::Point.new(x: start_x - width, y: end_y),
541
708
  ]
542
709
  end
543
710
 
544
711
  # Route self-loop on NORTH side
545
- def route_north_self_loop(section, node, width, height)
712
+ def route_north_self_loop(section, node, _width, height)
546
713
  node_x = node.x || 0.0
547
714
  node_y = node.y || 0.0
548
715
  node_width = node.width || 50.0
549
716
 
550
- # Start point (top middle of node)
551
- start_x = node_x + (node_width / 2.0)
717
+ start_x = node_x + (node_width / 2.0) - 5.0
552
718
  start_y = node_y
553
-
554
- # End point (slightly to the right of start)
555
719
  end_x = start_x + 10.0
556
720
  end_y = start_y
557
721
 
558
722
  section.start_point = Geometry::Point.new(x: start_x, y: start_y)
559
723
  section.end_point = Geometry::Point.new(x: end_x, y: end_y)
560
724
 
561
- # Bend points forming rectangular loop
562
725
  section.bend_points = [
563
726
  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),
727
+ Geometry::Point.new(x: end_x, y: start_y - height),
567
728
  ]
568
729
  end
569
730
 
570
731
  # Route self-loop on SOUTH side
571
- def route_south_self_loop(section, node, width, height)
732
+ def route_south_self_loop(section, node, _width, height)
572
733
  node_x = node.x || 0.0
573
734
  node_y = node.y || 0.0
574
735
  node_width = node.width || 50.0
575
736
  node_height = node.height || 50.0
576
737
 
577
- # Start point (bottom middle of node)
578
- start_x = node_x + (node_width / 2.0)
738
+ start_x = node_x + (node_width / 2.0) - 5.0
579
739
  start_y = node_y + node_height
580
-
581
- # End point (slightly to the right of start)
582
740
  end_x = start_x + 10.0
583
741
  end_y = start_y
584
742
 
585
743
  section.start_point = Geometry::Point.new(x: start_x, y: start_y)
586
744
  section.end_point = Geometry::Point.new(x: end_x, y: end_y)
587
745
 
588
- # Bend points forming rectangular loop
589
746
  section.bend_points = [
590
747
  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),
748
+ Geometry::Point.new(x: end_x, y: start_y + height),
594
749
  ]
595
750
  end
596
751
 
597
752
  # Route spline self-loop (curved path)
598
753
  def route_spline_self_loop(section, edge, node, loop_index)
599
754
  # Calculate offset based on loop index
600
- offset = calculate_loop_offset(loop_index)
755
+ offset = calculate_loop_offset(loop_index, edge, node)
601
756
 
602
757
  # Get self-loop side
603
758
  side = get_self_loop_side(edge, node)
@@ -613,7 +768,7 @@ module Elkrb
613
768
  case side
614
769
  when "EAST"
615
770
  start_x = node_x + node_width
616
- start_y = node_y + (node_height / 2.0)
771
+ start_y = node_y + (node_height / 2.0) - 5.0
617
772
  end_x = start_x
618
773
  end_y = start_y + 10.0
619
774
 
@@ -628,7 +783,7 @@ module Elkrb
628
783
  )
629
784
  when "WEST"
630
785
  start_x = node_x
631
- start_y = node_y + (node_height / 2.0)
786
+ start_y = node_y + (node_height / 2.0) - 5.0
632
787
  end_x = start_x
633
788
  end_y = start_y + 10.0
634
789
 
@@ -642,7 +797,7 @@ module Elkrb
642
797
  y: start_y + radius,
643
798
  )
644
799
  when "NORTH"
645
- start_x = node_x + (node_width / 2.0)
800
+ start_x = node_x + (node_width / 2.0) - 5.0
646
801
  start_y = node_y
647
802
  end_x = start_x + 10.0
648
803
  end_y = start_y
@@ -657,7 +812,7 @@ module Elkrb
657
812
  y: start_y - radius,
658
813
  )
659
814
  when "SOUTH"
660
- start_x = node_x + (node_width / 2.0)
815
+ start_x = node_x + (node_width / 2.0) - 5.0
661
816
  start_y = node_y + node_height
662
817
  end_x = start_x + 10.0
663
818
  end_y = start_y
@@ -674,7 +829,7 @@ module Elkrb
674
829
  else
675
830
  # Default: EAST
676
831
  start_x = node_x + node_width
677
- start_y = node_y + (node_height / 2.0)
832
+ start_y = node_y + (node_height / 2.0) - 5.0
678
833
  end_x = start_x
679
834
  end_y = start_y + 10.0
680
835
 
@@ -694,35 +849,21 @@ module Elkrb
694
849
  end
695
850
 
696
851
  # Route polyline self-loop (simple path)
697
- def route_polyline_self_loop(section, edge, node, loop_index)
852
+ def route_polyline_self_loop(section, edge, node, loop_index,
853
+ graph = nil)
698
854
  # For polyline, use orthogonal routing
699
- route_orthogonal_self_loop(section, edge, node, loop_index)
855
+ route_orthogonal_self_loop(section, edge, node, loop_index, graph)
700
856
  end
701
857
 
702
858
  # Calculate offset for multiple self-loops on same node
703
- def calculate_loop_offset(loop_index)
704
- base_offset = 20.0
859
+ def calculate_loop_offset(loop_index, edge, node)
860
+ base_offset = @resolver.get("elk.selfLoopOffset", edge, node)
705
861
  base_offset * (loop_index + 1)
706
862
  end
707
863
 
708
864
  # Get self-loop side from options or default
709
865
  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"
866
+ @resolver.get("elk.selfLoopSide", edge, node)
726
867
  end
727
868
 
728
869
  # Check if self-loop edge uses ports
@@ -750,27 +891,12 @@ module Elkrb
750
891
  source_port = find_port_by_id(source_id, node)
751
892
  target_port = find_port_by_id(target_id, node)
752
893
 
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
894
+ offset = calculate_loop_offset(loop_index, edge, node)
773
895
  if source_port && target_port
896
+ start_point = get_port_absolute_position(source_port, node)
897
+ end_point = get_port_absolute_position(target_port, node)
898
+ section.start_point = start_point
899
+ section.end_point = end_point
774
900
  route_port_to_port_self_loop(
775
901
  section,
776
902
  start_point,
@@ -781,33 +907,52 @@ module Elkrb
781
907
  routing_style,
782
908
  )
783
909
  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)
910
+ port = source_port || target_port
911
+ port_point = get_port_absolute_position(port, node)
912
+ side = port.side || get_self_loop_side(edge, node)
913
+ node_x = node.x || 0.0
914
+ node_y = node.y || 0.0
915
+ node_width = node.width || 0.0
916
+ node_height = node.height || 0.0
917
+ node_point = case side
918
+ when "WEST", "EAST"
919
+ candidate = [port_point.y + 10.0,
920
+ node_y + node_height].min
921
+ Geometry::Point.new(
922
+ x: side == "EAST" ? node_x + node_width : node_x,
923
+ y: candidate,
924
+ )
925
+ else
926
+ candidate = [port_point.x + 10.0,
927
+ node_x + node_width].min
928
+ Geometry::Point.new(
929
+ x: candidate,
930
+ y: side == "SOUTH" ? node_y + node_height : node_y,
931
+ )
932
+ end
933
+ start_point = source_port ? port_point : node_point
934
+ end_point = target_port ? port_point : node_point
935
+ section.start_point = start_point
936
+ section.end_point = end_point
937
+ if routing_style == "SPLINES"
938
+ route_spline_port_self_loop(section, start_point, end_point,
939
+ side, side, offset)
788
940
  else
789
- route_orthogonal_self_loop(section, edge, node, loop_index)
941
+ route_orthogonal_port_self_loop(section, start_point, end_point,
942
+ side, side, offset)
790
943
  end
791
944
  end
792
945
  end
793
946
 
794
947
  # Get absolute position of a port
795
948
  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
- )
949
+ get_port_position(port.id, node, nil)
800
950
  end
801
951
 
802
952
  # Route self-loop from port to port
803
953
  def route_port_to_port_self_loop(section, start_point, end_point,
804
954
  source_port, target_port, offset,
805
955
  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
956
  source_side = source_port.side || "EAST"
812
957
  target_side = target_port.side || "EAST"
813
958
 
@@ -837,43 +982,84 @@ module Elkrb
837
982
  # Route orthogonal self-loop between ports
838
983
  def route_orthogonal_port_self_loop(section, start_point, end_point,
839
984
  source_side, target_side, offset)
840
- section.bend_points = []
841
-
842
- # Create bend points based on port sides
985
+ extension = offset + 30.0
843
986
  case [source_side, target_side]
844
987
  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)
988
+ direction = source_side == "EAST" ? 1 : -1
989
+ outer_x = start_point.x + (direction * extension)
990
+ section.bend_points = [
991
+ Geometry::Point.new(x: outer_x, y: start_point.y),
992
+ Geometry::Point.new(x: outer_x, y: end_point.y),
993
+ ]
994
+ if start_point.x == end_point.x && start_point.y == end_point.y
995
+ section.bend_points[1].y += 10.0
996
+ section.bend_points << Geometry::Point.new(
997
+ x: end_point.x, y: end_point.y + 10.0,
998
+ )
857
999
  end
858
1000
  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)
1001
+ direction = source_side == "SOUTH" ? 1 : -1
1002
+ outer_y = start_point.y + (direction * extension)
1003
+ section.bend_points = [
1004
+ Geometry::Point.new(x: start_point.x, y: outer_y),
1005
+ Geometry::Point.new(x: end_point.x, y: outer_y),
1006
+ ]
1007
+ if start_point.x == end_point.x && start_point.y == end_point.y
1008
+ section.bend_points[1].x += 10.0
1009
+ section.bend_points << Geometry::Point.new(
1010
+ x: end_point.x + 10.0, y: end_point.y,
1011
+ )
871
1012
  end
872
1013
  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)
1014
+ deltas = {
1015
+ "EAST" => [extension, 0.0],
1016
+ "WEST" => [-extension, 0.0],
1017
+ "SOUTH" => [0.0, extension],
1018
+ "NORTH" => [0.0, -extension],
1019
+ }
1020
+ source_delta = deltas.fetch(source_side, [0.0, 0.0])
1021
+ target_delta = deltas.fetch(target_side, [0.0, 0.0])
1022
+ source_outer = Geometry::Point.new(
1023
+ x: start_point.x + source_delta[0],
1024
+ y: start_point.y + source_delta[1],
1025
+ )
1026
+ target_outer = Geometry::Point.new(
1027
+ x: end_point.x + target_delta[0],
1028
+ y: end_point.y + target_delta[1],
1029
+ )
1030
+ opposite = [["EAST", "WEST"], ["WEST", "EAST"],
1031
+ ["NORTH", "SOUTH"], ["SOUTH", "NORTH"]]
1032
+ .include?([source_side, target_side])
1033
+ vertical_sides = %w[EAST WEST].include?(source_side)
1034
+ section.bend_points = if opposite && vertical_sides
1035
+ corridor = [source_outer.y,
1036
+ target_outer.y].min - extension
1037
+ [source_outer,
1038
+ Geometry::Point.new(x: source_outer.x,
1039
+ y: corridor),
1040
+ Geometry::Point.new(x: target_outer.x,
1041
+ y: corridor),
1042
+ target_outer]
1043
+ elsif opposite
1044
+ corridor = [source_outer.x,
1045
+ target_outer.x].max + extension
1046
+ [source_outer,
1047
+ Geometry::Point.new(x: corridor,
1048
+ y: source_outer.y),
1049
+ Geometry::Point.new(x: corridor,
1050
+ y: target_outer.y),
1051
+ target_outer]
1052
+ elsif vertical_sides
1053
+ [source_outer,
1054
+ Geometry::Point.new(x: source_outer.x,
1055
+ y: target_outer.y),
1056
+ target_outer]
1057
+ else
1058
+ [source_outer,
1059
+ Geometry::Point.new(x: target_outer.x,
1060
+ y: source_outer.y),
1061
+ target_outer]
1062
+ end
877
1063
  end
878
1064
  end
879
1065
 
@@ -928,6 +1114,16 @@ module Elkrb
928
1114
  control2 = Geometry::Point.new(x: mid_x, y: end_point.y)
929
1115
  end
930
1116
 
1117
+ if start_point.x == end_point.x && start_point.y == end_point.y
1118
+ if %w[EAST WEST].include?(source_side)
1119
+ control1.y -= 5.0
1120
+ control2.y += 5.0
1121
+ else
1122
+ control1.x -= 5.0
1123
+ control2.x += 5.0
1124
+ end
1125
+ end
1126
+
931
1127
  section.bend_points = [control1, control2]
932
1128
  end
933
1129
  end