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,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ # Module to add hierarchical graph layout support to algorithms.
6
+ # Handles recursive layout, parent-child constraints, and cross-hierarchy
7
+ # edges.
8
+ module HierarchicalProcessor
9
+ # Layout a graph and all its hierarchical children recursively.
10
+ #
11
+ # @param graph [Graph::Graph] The graph to layout
12
+ # @param options [Hash] Layout options
13
+ # @return [Graph::Graph] The laid out graph
14
+ def layout_hierarchical(graph, options = {})
15
+ return layout_flat(graph, options) unless graph.hierarchical?
16
+
17
+ # First, recursively layout all child nodes
18
+ layout_children_recursively(graph)
19
+
20
+ # Then layout the top-level graph
21
+ layout_flat(graph, options)
22
+
23
+ # Apply parent constraints
24
+ apply_parent_constraints(graph)
25
+
26
+ # Handle cross-hierarchy edges
27
+ handle_cross_hierarchy_edges(graph)
28
+
29
+ # Update parent bounds
30
+ update_parent_bounds(graph)
31
+
32
+ graph
33
+ end
34
+
35
+ private
36
+
37
+ # Layout all children of nodes in the graph recursively.
38
+ def layout_children_recursively(graph)
39
+ return unless graph.children
40
+
41
+ graph.children.each do |node|
42
+ next unless node.hierarchical?
43
+
44
+ # Create a temporary graph for the node's children
45
+ child_graph = create_child_graph(node)
46
+
47
+ # Recursively layout the child graph
48
+ layout_hierarchical(child_graph, extract_node_options(node))
49
+
50
+ # Apply the layout back to the node
51
+ apply_child_layout(node, child_graph)
52
+ end
53
+ end
54
+
55
+ # Layout a flat (non-hierarchical) graph using the base algorithm.
56
+ def layout_flat(graph, options = {})
57
+ # This should be implemented by the including algorithm class
58
+ raise NotImplementedError,
59
+ "#{self.class.name} must implement #layout_flat"
60
+ end
61
+
62
+ # Create a temporary graph from a node's children.
63
+ def create_child_graph(node)
64
+ Graph::Graph.new(
65
+ id: "#{node.id}_children",
66
+ children: node.children || [],
67
+ edges: node.edges || [],
68
+ layout_options: node.layout_options,
69
+ )
70
+ end
71
+
72
+ # Extract layout options from a node.
73
+ def extract_node_options(node)
74
+ return {} unless node.layout_options
75
+
76
+ node.layout_options.properties || {}
77
+ end
78
+
79
+ # Apply the child graph layout back to the parent node.
80
+ def apply_child_layout(node, child_graph)
81
+ # Copy positions from child_graph children to node children
82
+ return unless child_graph.children
83
+
84
+ child_graph.children.each_with_index do |child, index|
85
+ if node.children && node.children[index]
86
+ node.children[index].x = child.x
87
+ node.children[index].y = child.y
88
+ node.children[index].width = child.width
89
+ node.children[index].height = child.height
90
+ end
91
+ end
92
+ end
93
+
94
+ # Apply parent constraints to ensure children fit within parent bounds.
95
+ def apply_parent_constraints(graph)
96
+ return unless graph.children
97
+
98
+ graph.children.each do |node|
99
+ next unless node.hierarchical?
100
+
101
+ # Get padding from node options
102
+ padding = get_padding(node)
103
+
104
+ # Adjust child positions to account for padding
105
+ adjust_children_for_padding(node, padding)
106
+
107
+ # Recursively apply to nested children
108
+ if node.children
109
+ temp_graph = Graph::Graph.new(children: node.children)
110
+ apply_parent_constraints(temp_graph)
111
+ end
112
+ end
113
+ end
114
+
115
+ # Get padding for a node from its layout options.
116
+ def get_padding(node)
117
+ return default_padding unless node.layout_options
118
+
119
+ padding_option = node.layout_options.properties&.[]("padding") ||
120
+ node.layout_options.properties&.[]("elk.padding")
121
+
122
+ return default_padding unless padding_option
123
+
124
+ parse_padding(padding_option)
125
+ end
126
+
127
+ # Default padding values.
128
+ def default_padding
129
+ { top: 12.0, right: 12.0, bottom: 12.0, left: 12.0 }
130
+ end
131
+
132
+ # Parse padding from various formats.
133
+ def parse_padding(padding)
134
+ case padding
135
+ when Hash
136
+ {
137
+ top: padding[:top] || padding["top"] || 12.0,
138
+ right: padding[:right] || padding["right"] || 12.0,
139
+ bottom: padding[:bottom] || padding["bottom"] || 12.0,
140
+ left: padding[:left] || padding["left"] || 12.0,
141
+ }
142
+ when Numeric
143
+ { top: padding, right: padding, bottom: padding, left: padding }
144
+ else
145
+ default_padding
146
+ end
147
+ end
148
+
149
+ # Adjust children positions to account for parent padding.
150
+ def adjust_children_for_padding(node, padding)
151
+ return unless node.children
152
+
153
+ node.children.each do |child|
154
+ child.x = (child.x || 0.0) + padding[:left]
155
+ child.y = (child.y || 0.0) + padding[:top]
156
+ end
157
+ end
158
+
159
+ # Handle edges that cross hierarchy boundaries.
160
+ def handle_cross_hierarchy_edges(graph)
161
+ return unless graph.edges
162
+
163
+ graph.edges.each do |edge|
164
+ # Get source and target IDs from edge
165
+ source_id = edge.sources&.first
166
+ target_id = edge.targets&.first
167
+
168
+ next unless source_id && target_id
169
+
170
+ source_node = find_node_in_hierarchy(graph, source_id)
171
+ target_node = find_node_in_hierarchy(graph, target_id)
172
+
173
+ next unless source_node && target_node
174
+
175
+ # Check if edge crosses hierarchy levels
176
+ if crosses_hierarchy?(source_node, target_node, graph)
177
+ adjust_edge_for_hierarchy(edge, source_node, target_node)
178
+ end
179
+ end
180
+ end
181
+
182
+ # Find a node anywhere in the graph hierarchy.
183
+ def find_node_in_hierarchy(graph, node_id)
184
+ return nil unless node_id
185
+
186
+ # Handle both direct node IDs and port IDs
187
+ node_id_str = node_id.is_a?(String) ? node_id : node_id.to_s
188
+ graph.find_node(node_id_str)
189
+ end
190
+
191
+ # Check if an edge crosses hierarchy boundaries.
192
+ def crosses_hierarchy?(source_node, target_node, graph)
193
+ return false unless source_node && target_node
194
+
195
+ source_depth = node_depth(source_node, graph)
196
+ target_depth = node_depth(target_node, graph)
197
+
198
+ source_depth != target_depth
199
+ end
200
+
201
+ # Calculate the depth of a node in the hierarchy.
202
+ def node_depth(node, graph, depth = 0)
203
+ return depth if graph.children&.include?(node)
204
+
205
+ graph.children&.each do |child|
206
+ if child.hierarchical? && child.children
207
+ child_graph = Graph::Graph.new(children: child.children)
208
+ found_depth = node_depth(node, child_graph, depth + 1)
209
+ return found_depth if found_depth > depth
210
+ end
211
+ end
212
+
213
+ depth
214
+ end
215
+
216
+ # Adjust edge routing for cross-hierarchy edges.
217
+ def adjust_edge_for_hierarchy(edge, _source_node, _target_node)
218
+ # Add additional bend points to route around parent boundaries
219
+ # This is a simplified version - could be enhanced with proper routing
220
+ return unless edge.sections && !edge.sections.empty?
221
+
222
+ section = edge.sections.first
223
+
224
+ # Calculate midpoint
225
+ mid_x = (section.start_point.x + section.end_point.x) / 2.0
226
+ mid_y = (section.start_point.y + section.end_point.y) / 2.0
227
+
228
+ # Add a bend point at the midpoint
229
+ section.add_bend_point(mid_x, mid_y)
230
+ end
231
+
232
+ # Update parent node bounds to contain all children.
233
+ def update_parent_bounds(graph)
234
+ return unless graph.children
235
+
236
+ graph.children.each do |node|
237
+ next unless node.hierarchical?
238
+
239
+ # Recursively update nested children first
240
+ if node.children
241
+ temp_graph = Graph::Graph.new(children: node.children)
242
+ update_parent_bounds(temp_graph)
243
+ end
244
+
245
+ # Calculate bounds from children
246
+ bounds = calculate_children_bounds(node)
247
+
248
+ # Get padding
249
+ padding = get_padding(node)
250
+
251
+ # Update node dimensions
252
+ node.width = bounds[:width] + padding[:left] + padding[:right]
253
+ node.height = bounds[:height] + padding[:top] + padding[:bottom]
254
+
255
+ # Update position if needed (ensure children are at positive coords)
256
+ if bounds[:min_x].negative?
257
+ offset_x = -bounds[:min_x] + padding[:left]
258
+ node.children&.each { |child| child.x = (child.x || 0) + offset_x }
259
+ end
260
+
261
+ if bounds[:min_y].negative?
262
+ offset_y = -bounds[:min_y] + padding[:top]
263
+ node.children&.each { |child| child.y = (child.y || 0) + offset_y }
264
+ end
265
+ end
266
+ end
267
+
268
+ # Calculate the bounding box of a node's children.
269
+ def calculate_children_bounds(node)
270
+ return { min_x: 0, min_y: 0, width: 0, height: 0 } unless
271
+ node.children && !node.children.empty?
272
+
273
+ min_x = Float::INFINITY
274
+ min_y = Float::INFINITY
275
+ max_x = -Float::INFINITY
276
+ max_y = -Float::INFINITY
277
+
278
+ node.children.each do |child|
279
+ x = child.x || 0.0
280
+ y = child.y || 0.0
281
+ w = child.width || 0.0
282
+ h = child.height || 0.0
283
+
284
+ min_x = [min_x, x].min
285
+ min_y = [min_y, y].min
286
+ max_x = [max_x, x + w].max
287
+ max_y = [max_y, y + h].max
288
+ end
289
+
290
+ {
291
+ min_x: min_x,
292
+ min_y: min_y,
293
+ width: max_x - min_x,
294
+ height: max_y - min_y,
295
+ }
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ # Module to add automatic label placement to layout algorithms.
6
+ # Handles positioning of node labels, edge labels, and port labels.
7
+ module LabelPlacer
8
+ # Place all labels in the graph after layout is complete.
9
+ #
10
+ # @param graph [Graph::Graph] The laid out graph
11
+ def place_labels(graph)
12
+ return unless graph
13
+
14
+ # Place node labels
15
+ place_node_labels(graph) if graph.children
16
+
17
+ # Place edge labels
18
+ place_edge_labels(graph) if graph.edges
19
+
20
+ # Recursively place labels in hierarchical graphs
21
+ place_hierarchical_labels(graph) if graph.hierarchical?
22
+ end
23
+
24
+ private
25
+
26
+ # Place labels for all nodes in the graph.
27
+ def place_node_labels(graph)
28
+ graph.children.each do |node|
29
+ next unless node.labels && !node.labels.empty?
30
+
31
+ node.labels.each_with_index do |label, index|
32
+ place_node_label(node, label, index)
33
+ end
34
+
35
+ # Place port labels if node has ports
36
+ place_port_labels(node) if node.ports && !node.ports.empty?
37
+ end
38
+ end
39
+
40
+ # Place a single node label.
41
+ def place_node_label(node, label, index = 0)
42
+ placement = label_placement_option(node, "node.label.placement") ||
43
+ "INSIDE CENTER"
44
+
45
+ case placement.upcase
46
+ when /INSIDE/
47
+ place_label_inside_node(node, label, placement, index)
48
+ when /OUTSIDE/
49
+ place_label_outside_node(node, label, placement, index)
50
+ else
51
+ # Default: center inside
52
+ place_label_center(node, label)
53
+ end
54
+ end
55
+
56
+ # Place label inside the node bounds.
57
+ def place_label_inside_node(node, label, placement, index)
58
+ case placement.upcase
59
+ when /TOP/
60
+ place_label_inside_top(node, label, index)
61
+ when /BOTTOM/
62
+ place_label_inside_bottom(node, label, index)
63
+ when /LEFT/
64
+ place_label_inside_left(node, label, index)
65
+ when /RIGHT/
66
+ place_label_inside_right(node, label, index)
67
+ else
68
+ place_label_center(node, label)
69
+ end
70
+ end
71
+
72
+ # Place label outside the node bounds.
73
+ def place_label_outside_node(node, label, placement, _index)
74
+ margin = label_margin_option(node)
75
+
76
+ case placement.upcase
77
+ when /TOP/
78
+ label.x = node.x + ((node.width - label.width) / 2.0)
79
+ label.y = node.y - label.height - margin
80
+ when /BOTTOM/
81
+ label.x = node.x + ((node.width - label.width) / 2.0)
82
+ label.y = node.y + node.height + margin
83
+ when /LEFT/
84
+ label.x = node.x - label.width - margin
85
+ label.y = node.y + ((node.height - label.height) / 2.0)
86
+ when /RIGHT/
87
+ label.x = node.x + node.width + margin
88
+ label.y = node.y + ((node.height - label.height) / 2.0)
89
+ end
90
+ end
91
+
92
+ # Place label at various inside positions.
93
+ def place_label_inside_top(node, label, index)
94
+ padding = label_padding_option(node)
95
+ y_offset = index * (label.height + padding)
96
+
97
+ label.x = node.x + ((node.width - label.width) / 2.0)
98
+ label.y = node.y + padding + y_offset
99
+ end
100
+
101
+ def place_label_inside_bottom(node, label, index)
102
+ padding = label_padding_option(node)
103
+ y_offset = index * (label.height + padding)
104
+
105
+ label.x = node.x + ((node.width - label.width) / 2.0)
106
+ label.y = node.y + node.height - label.height - padding - y_offset
107
+ end
108
+
109
+ def place_label_inside_left(node, label, index)
110
+ padding = label_padding_option(node)
111
+ y_offset = index * (label.height + padding)
112
+
113
+ label.x = node.x + padding
114
+ label.y = node.y + padding + y_offset
115
+ end
116
+
117
+ def place_label_inside_right(node, label, index)
118
+ padding = label_padding_option(node)
119
+ y_offset = index * (label.height + padding)
120
+
121
+ label.x = node.x + node.width - label.width - padding
122
+ label.y = node.y + padding + y_offset
123
+ end
124
+
125
+ def place_label_center(node, label)
126
+ label.x = node.x + ((node.width - label.width) / 2.0)
127
+ label.y = node.y + ((node.height - label.height) / 2.0)
128
+ end
129
+
130
+ # Place labels for all ports on a node.
131
+ def place_port_labels(node)
132
+ node.ports.each do |port|
133
+ next unless port.labels && !port.labels.empty?
134
+
135
+ port.labels.each_with_index do |label, index|
136
+ place_port_label(node, port, label, index)
137
+ end
138
+ end
139
+ end
140
+
141
+ # Place a port label.
142
+ def place_port_label(node, port, label, _index)
143
+ # Port position relative to node
144
+ port_x = node.x + (port.x || 0)
145
+ port_y = node.y + (port.y || 0)
146
+
147
+ placement = label_placement_option(port, "port.label.placement") ||
148
+ "OUTSIDE"
149
+
150
+ margin = label_margin_option(port)
151
+
152
+ case placement.upcase
153
+ when /INSIDE/
154
+ # Place inside port (if port is large enough)
155
+ label.x = port_x + ((port.width - label.width) / 2.0)
156
+ label.y = port_y + ((port.height - label.height) / 2.0)
157
+ else
158
+ # Default: outside, positioned based on port side
159
+ side = port_side(node, port)
160
+ place_port_label_by_side(label, port_x, port_y, port, side, margin)
161
+ end
162
+ end
163
+
164
+ # Determine which side of the node the port is on.
165
+ def port_side(node, port)
166
+ port_x = port.x || 0
167
+ port_y = port.y || 0
168
+
169
+ # Check which edge the port is closest to
170
+ left_dist = port_x
171
+ right_dist = node.width - port_x
172
+ top_dist = port_y
173
+ bottom_dist = node.height - port_y
174
+
175
+ min_dist = [left_dist, right_dist, top_dist, bottom_dist].min
176
+
177
+ case min_dist
178
+ when left_dist then :left
179
+ when right_dist then :right
180
+ when top_dist then :top
181
+ else :bottom
182
+ end
183
+ end
184
+
185
+ # Place port label based on port side.
186
+ def place_port_label_by_side(label, port_x, port_y, port, side, margin)
187
+ case side
188
+ when :left
189
+ label.x = port_x - label.width - margin
190
+ label.y = port_y + ((port.height - label.height) / 2.0)
191
+ when :right
192
+ label.x = port_x + port.width + margin
193
+ label.y = port_y + ((port.height - label.height) / 2.0)
194
+ when :top
195
+ label.x = port_x + ((port.width - label.width) / 2.0)
196
+ label.y = port_y - label.height - margin
197
+ when :bottom
198
+ label.x = port_x + ((port.width - label.width) / 2.0)
199
+ label.y = port_y + port.height + margin
200
+ end
201
+ end
202
+
203
+ # Place labels for all edges in the graph.
204
+ def place_edge_labels(graph)
205
+ graph.edges.each do |edge|
206
+ next unless edge.labels && !edge.labels.empty?
207
+
208
+ edge.labels.each_with_index do |label, index|
209
+ place_edge_label(edge, label, index)
210
+ end
211
+ end
212
+ end
213
+
214
+ # Place a single edge label.
215
+ def place_edge_label(edge, label, index)
216
+ # Get edge path (sections with bend points)
217
+ if edge.sections && !edge.sections.empty?
218
+ place_edge_label_on_section(edge.sections.first, label, index)
219
+ else
220
+ # No sections, estimate from source/target
221
+ place_edge_label_estimated(edge, label, index)
222
+ end
223
+ end
224
+
225
+ # Place edge label on an edge section.
226
+ def place_edge_label_on_section(section, label, index)
227
+ # Calculate center point of the edge path
228
+ center = calculate_edge_center(section)
229
+
230
+ placement = "CENTER" # Could be configurable
231
+
232
+ offset = index * (label.height + 2) # Stack multiple labels
233
+
234
+ case placement.upcase
235
+ when /CENTER/
236
+ label.x = center[:x] - (label.width / 2.0)
237
+ label.y = center[:y] - (label.height / 2.0) + offset
238
+ when /HEAD/
239
+ label.x = section.end_point.x - (label.width / 2.0)
240
+ label.y = section.end_point.y - label.height - 5 + offset
241
+ when /TAIL/
242
+ label.x = section.start_point.x - (label.width / 2.0)
243
+ label.y = section.start_point.y - label.height - 5 + offset
244
+ end
245
+ end
246
+
247
+ # Calculate the center point of an edge section.
248
+ def calculate_edge_center(section)
249
+ points = [section.start_point]
250
+ points.concat(section.bend_points) if section.bend_points
251
+ points << section.end_point
252
+
253
+ # Find midpoint along the path
254
+ total_length = 0.0
255
+ lengths = []
256
+
257
+ (0...(points.length - 1)).each do |i|
258
+ p1 = points[i]
259
+ p2 = points[i + 1]
260
+ length = Math.sqrt(((p2.x - p1.x)**2) + ((p2.y - p1.y)**2))
261
+ lengths << length
262
+ total_length += length
263
+ end
264
+
265
+ # Find point at half the total length
266
+ target_length = total_length / 2.0
267
+ current_length = 0.0
268
+
269
+ (0...lengths.length).each do |i|
270
+ if current_length + lengths[i] >= target_length
271
+ # Interpolate between points[i] and points[i+1]
272
+ ratio = (target_length - current_length) / lengths[i]
273
+ p1 = points[i]
274
+ p2 = points[i + 1]
275
+
276
+ return {
277
+ x: p1.x + (ratio * (p2.x - p1.x)),
278
+ y: p1.y + (ratio * (p2.y - p1.y)),
279
+ }
280
+ end
281
+ current_length += lengths[i]
282
+ end
283
+
284
+ # Fallback: use middle point
285
+ mid_point = points[points.length / 2]
286
+ { x: mid_point.x, y: mid_point.y }
287
+ end
288
+
289
+ # Estimate edge label position when no sections available.
290
+ def place_edge_label_estimated(_edge, label, _index)
291
+ # This is a fallback - in practice edges should have sections
292
+ # after routing, but we handle the case anyway
293
+ label.x = 0
294
+ label.y = 0
295
+ end
296
+
297
+ # Place labels in hierarchical child nodes.
298
+ def place_hierarchical_labels(graph)
299
+ return unless graph.children
300
+
301
+ graph.children.each do |node|
302
+ next unless node.hierarchical?
303
+
304
+ # Create temporary graph for children
305
+ child_graph = Graph::Graph.new(
306
+ children: node.children,
307
+ edges: node.edges,
308
+ )
309
+
310
+ # Recursively place labels
311
+ place_labels(child_graph)
312
+ end
313
+ end
314
+
315
+ # Get label placement option from node/port layout options.
316
+ def label_placement_option(element, option_key)
317
+ return nil unless element.layout_options
318
+
319
+ element.layout_options.properties&.[](option_key) ||
320
+ element.layout_options.properties&.[]("label.placement")
321
+ end
322
+
323
+ # Get label padding option.
324
+ def label_padding_option(element)
325
+ return 5.0 unless element.layout_options
326
+
327
+ element.layout_options.properties&.[]("label.padding") || 5.0
328
+ end
329
+
330
+ # Get label margin option.
331
+ def label_margin_option(element)
332
+ return 5.0 unless element.layout_options
333
+
334
+ element.layout_options.properties&.[]("label.margin") || 5.0
335
+ end
336
+ end
337
+ end
338
+ end