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,389 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_algorithm"
4
+ require_relative "../../geometry/point"
5
+ require_relative "../../geometry/rectangle"
6
+
7
+ module Elkrb
8
+ module Layout
9
+ module Algorithms
10
+ # Libavoid connector routing algorithm
11
+ #
12
+ # Routes orthogonal connectors around obstacles (nodes) using A* pathfinding.
13
+ # Minimizes connector length and bends while avoiding overlaps with nodes.
14
+ # Based on the concepts from the libavoid C++ library.
15
+ #
16
+ # Features:
17
+ # - Orthogonal (90-degree) routing
18
+ # - Obstacle avoidance
19
+ # - Bend minimization
20
+ # - Configurable routing padding
21
+ #
22
+ # Options:
23
+ # - libavoid.routingPadding: Padding around obstacles (default: 10)
24
+ # - libavoid.segmentPenalty: Penalty for additional segments (default: 1.0)
25
+ # - libavoid.bendPenalty: Penalty for bends (default: 2.0)
26
+ class Libavoid < BaseAlgorithm
27
+ # Priority queue node for A* algorithm
28
+ class PathNode
29
+ attr_accessor :point, :parent, :g_score, :f_score, :direction
30
+
31
+ def initialize(point, parent = nil, g_score = Float::INFINITY,
32
+ f_score = Float::INFINITY, direction = nil)
33
+ @point = point
34
+ @parent = parent
35
+ @g_score = g_score
36
+ @f_score = f_score
37
+ @direction = direction # :horizontal or :vertical
38
+ end
39
+
40
+ def ==(other)
41
+ other.is_a?(PathNode) && point.x == other.point.x && point.y == other.point.y
42
+ end
43
+
44
+ def hash
45
+ [point.x, point.y].hash
46
+ end
47
+
48
+ def eql?(other)
49
+ self == other
50
+ end
51
+ end
52
+
53
+ def layout_flat(graph, _options = {})
54
+ return graph if graph.children.nil? || graph.children.empty?
55
+
56
+ # Position nodes if not already positioned
57
+ position_nodes_if_needed(graph)
58
+
59
+ # Build obstacle map from nodes
60
+ obstacles = build_obstacle_map(graph.children)
61
+
62
+ # Route each edge
63
+ route_edges_with_obstacles(graph, obstacles)
64
+
65
+ # Apply padding and set graph dimensions
66
+ apply_padding(graph)
67
+
68
+ graph
69
+ end
70
+
71
+ private
72
+
73
+ # Position nodes if they don't have positions
74
+ def position_nodes_if_needed(graph)
75
+ return if graph.children.all? { |n| n.x && n.y }
76
+
77
+ # Use simple box layout for positioning
78
+ spacing = node_spacing
79
+ max_width = graph.children.map(&:width).max
80
+ max_height = graph.children.map(&:height).max
81
+
82
+ cols = Math.sqrt(graph.children.length * 1.6).ceil
83
+ cols = [cols, 1].max
84
+
85
+ graph.children.each_with_index do |node, i|
86
+ row = i / cols
87
+ col = i % cols
88
+ node.x = col * (max_width + spacing)
89
+ node.y = row * (max_height + spacing)
90
+ end
91
+ end
92
+
93
+ # Build obstacle map from nodes
94
+ def build_obstacle_map(nodes)
95
+ padding = option("libavoid.routingPadding", 10).to_f
96
+
97
+ nodes.map do |node|
98
+ Geometry::Rectangle.new(
99
+ (node.x || 0) - padding,
100
+ (node.y || 0) - padding,
101
+ (node.width || 0) + (2 * padding),
102
+ (node.height || 0) + (2 * padding),
103
+ )
104
+ end
105
+ end
106
+
107
+ # Route all edges with obstacle avoidance
108
+ def route_edges_with_obstacles(graph, obstacles)
109
+ return unless graph.edges&.any?
110
+
111
+ node_map = build_node_map(graph)
112
+
113
+ graph.edges.each do |edge|
114
+ route_single_edge(edge, node_map, obstacles)
115
+ end
116
+ end
117
+
118
+ # Route a single edge around obstacles
119
+ def route_single_edge(edge, node_map, obstacles)
120
+ return unless edge.sources&.any? && edge.targets&.any?
121
+
122
+ source_id = edge.sources.first
123
+ target_id = edge.targets.first
124
+
125
+ source_node = node_map[source_id]
126
+ target_node = node_map[target_id]
127
+
128
+ return unless source_node && target_node
129
+
130
+ # Get start and end points (node centers)
131
+ start_point = get_node_center(source_node)
132
+ end_point = get_node_center(target_node)
133
+
134
+ # Find path using A* algorithm
135
+ path = find_path(start_point, end_point, obstacles)
136
+
137
+ # Create orthogonal segments from path
138
+ bend_points = create_orthogonal_segments(path)
139
+
140
+ # Minimize bends
141
+ bend_points = minimize_bends(bend_points, start_point, end_point,
142
+ obstacles)
143
+
144
+ # Apply to edge section
145
+ edge.sections ||= []
146
+ if edge.sections.empty?
147
+ edge.sections << Graph::EdgeSection.new(id: "#{edge.id}_section_0")
148
+ end
149
+
150
+ section = edge.sections.first
151
+ section.start_point = start_point
152
+ section.end_point = end_point
153
+ section.bend_points = bend_points
154
+ end
155
+
156
+ # A* pathfinding algorithm
157
+ def find_path(start, goal, obstacles)
158
+ segment_penalty = option("libavoid.segmentPenalty", 1.0).to_f
159
+ bend_penalty = option("libavoid.bendPenalty", 2.0).to_f
160
+
161
+ start_node = PathNode.new(start, nil, 0, heuristic(start, goal))
162
+ open_set = [start_node]
163
+ closed_set = {}
164
+ g_scores = { point_key(start) => 0 }
165
+
166
+ while open_set.any?
167
+ # Get node with lowest f_score
168
+ current = open_set.min_by(&:f_score)
169
+
170
+ # Goal reached
171
+ if points_equal?(current.point, goal)
172
+ return reconstruct_path(current)
173
+ end
174
+
175
+ open_set.delete(current)
176
+ closed_set[point_key(current.point)] = true
177
+
178
+ # Explore neighbors (orthogonal directions)
179
+ neighbors = get_orthogonal_neighbors(current.point, goal, obstacles)
180
+
181
+ neighbors.each do |neighbor_point, direction|
182
+ key = point_key(neighbor_point)
183
+ next if closed_set[key]
184
+
185
+ # Calculate cost with penalties for segments and direction changes
186
+ distance = euclidean_distance(current.point, neighbor_point)
187
+ direction_change_penalty = current.direction && current.direction != direction ? bend_penalty : 0
188
+ tentative_g = current.g_score + distance + segment_penalty + direction_change_penalty
189
+
190
+ if !g_scores[key] || tentative_g < g_scores[key]
191
+ g_scores[key] = tentative_g
192
+ f_score = tentative_g + heuristic(neighbor_point, goal)
193
+
194
+ neighbor_node = PathNode.new(neighbor_point, current,
195
+ tentative_g, f_score, direction)
196
+
197
+ # Add or update in open set
198
+ existing = open_set.find do |n|
199
+ points_equal?(n.point, neighbor_point)
200
+ end
201
+ if existing
202
+ open_set.delete(existing)
203
+ end
204
+ open_set << neighbor_node
205
+ end
206
+ end
207
+ end
208
+
209
+ # No path found, return direct path
210
+ [start, goal]
211
+ end
212
+
213
+ # Get orthogonal neighbors (4-directional)
214
+ def get_orthogonal_neighbors(point, _goal, obstacles)
215
+ step_size = option("libavoid.routingPadding", 10).to_f
216
+ neighbors = []
217
+
218
+ # Four orthogonal directions
219
+ [
220
+ [step_size, 0, :horizontal], # right
221
+ [-step_size, 0, :horizontal], # left
222
+ [0, step_size, :vertical], # down
223
+ [0, -step_size, :vertical], # up
224
+ ].each do |dx, dy, direction|
225
+ neighbor = Geometry::Point.new(x: point.x + dx, y: point.y + dy)
226
+
227
+ # Skip if it collides with obstacles
228
+ unless collides_with_obstacles?(point, neighbor, obstacles)
229
+ neighbors << [neighbor, direction]
230
+ end
231
+ end
232
+
233
+ neighbors
234
+ end
235
+
236
+ # Check if line segment collides with obstacles
237
+ def collides_with_obstacles?(p1, p2, obstacles)
238
+ obstacles.any? do |obstacle|
239
+ line_intersects_rectangle?(p1, p2, obstacle)
240
+ end
241
+ end
242
+
243
+ # Check if line segment intersects rectangle
244
+ def line_intersects_rectangle?(p1, p2, rect)
245
+ # Check if either endpoint is inside rectangle
246
+ return true if point_in_rectangle?(p1,
247
+ rect) || point_in_rectangle?(p2,
248
+ rect)
249
+
250
+ # Check if line intersects any edge of rectangle
251
+ rect_edges = [
252
+ [Geometry::Point.new(x: rect.x, y: rect.y),
253
+ Geometry::Point.new(x: rect.x + rect.width, y: rect.y)],
254
+ [Geometry::Point.new(x: rect.x + rect.width, y: rect.y),
255
+ Geometry::Point.new(x: rect.x + rect.width,
256
+ y: rect.y + rect.height)],
257
+ [Geometry::Point.new(x: rect.x + rect.width, y: rect.y + rect.height),
258
+ Geometry::Point.new(x: rect.x, y: rect.y + rect.height)],
259
+ [Geometry::Point.new(x: rect.x, y: rect.y + rect.height),
260
+ Geometry::Point.new(x: rect.x, y: rect.y)],
261
+ ]
262
+
263
+ rect_edges.any? do |edge_p1, edge_p2|
264
+ segments_intersect?(p1, p2, edge_p1, edge_p2)
265
+ end
266
+ end
267
+
268
+ # Check if point is inside rectangle
269
+ def point_in_rectangle?(point, rect)
270
+ point.x.between?(rect.x, rect.x + rect.width) &&
271
+ point.y >= rect.y &&
272
+ point.y <= rect.y + rect.height
273
+ end
274
+
275
+ # Check if two line segments intersect
276
+ def segments_intersect?(p1, p2, p3, p4)
277
+ d1 = direction(p3, p4, p1)
278
+ d2 = direction(p3, p4, p2)
279
+ d3 = direction(p1, p2, p3)
280
+ d4 = direction(p1, p2, p4)
281
+
282
+ ((d1.positive? && d2.negative?) || (d1.negative? && d2.positive?)) &&
283
+ ((d3.positive? && d4.negative?) || (d3.negative? && d4.positive?))
284
+ end
285
+
286
+ # Calculate direction for line segment intersection
287
+ def direction(p1, p2, p3)
288
+ ((p3.x - p1.x) * (p2.y - p1.y)) - ((p2.x - p1.x) * (p3.y - p1.y))
289
+ end
290
+
291
+ # Reconstruct path from A* result
292
+ def reconstruct_path(node)
293
+ path = []
294
+ current = node
295
+
296
+ while current
297
+ path.unshift(current.point)
298
+ current = current.parent
299
+ end
300
+
301
+ path
302
+ end
303
+
304
+ # Manhattan distance heuristic for A*
305
+ def heuristic(point, goal)
306
+ (point.x - goal.x).abs + (point.y - goal.y).abs
307
+ end
308
+
309
+ # Euclidean distance
310
+ def euclidean_distance(p1, p2)
311
+ dx = p2.x - p1.x
312
+ dy = p2.y - p1.y
313
+ Math.sqrt((dx * dx) + (dy * dy))
314
+ end
315
+
316
+ # Create orthogonal segments from path
317
+ def create_orthogonal_segments(path)
318
+ return [] if path.length < 3
319
+
320
+ # Path already contains waypoints from A*, convert to bend points
321
+ # (excluding start and end points)
322
+ path[1..-2].map do |point|
323
+ Geometry::Point.new(x: point.x, y: point.y)
324
+ end
325
+ end
326
+
327
+ # Minimize bends in path
328
+ def minimize_bends(bend_points, start_point, end_point, obstacles)
329
+ return bend_points if bend_points.empty?
330
+
331
+ # Try to remove unnecessary bend points
332
+ all_points = [start_point] + bend_points + [end_point]
333
+ simplified = [all_points.first]
334
+
335
+ i = 0
336
+ while i < all_points.length - 1
337
+ j = all_points.length - 1
338
+
339
+ # Try to connect point i to furthest visible point
340
+ while j > i + 1
341
+ unless collides_with_obstacles?(all_points[i], all_points[j],
342
+ obstacles)
343
+ simplified << all_points[j]
344
+ i = j
345
+ break
346
+ end
347
+ j -= 1
348
+ end
349
+
350
+ # If no direct path found, use next point
351
+ if j == i + 1
352
+ simplified << all_points[i + 1]
353
+ i += 1
354
+ end
355
+ end
356
+
357
+ # Remove start and end points from result
358
+ simplified[1..-2] || []
359
+ end
360
+
361
+ # Helper: create unique key for point
362
+ def point_key(point)
363
+ "#{point.x.round(2)},#{point.y.round(2)}"
364
+ end
365
+
366
+ # Helper: check if two points are equal (within tolerance)
367
+ def points_equal?(p1, p2, tolerance = 0.1)
368
+ (p1.x - p2.x).abs < tolerance && (p1.y - p2.y).abs < tolerance
369
+ end
370
+
371
+ # Build node map from graph
372
+ def build_node_map(graph)
373
+ map = {}
374
+ graph.children&.each do |node|
375
+ map[node.id] = node
376
+ end
377
+ map
378
+ end
379
+
380
+ # Get center point of a node
381
+ def get_node_center(node)
382
+ x = (node.x || 0.0) + ((node.width || 0.0) / 2.0)
383
+ y = (node.y || 0.0) + ((node.height || 0.0) / 2.0)
384
+ Geometry::Point.new(x: x, y: y)
385
+ end
386
+ end
387
+ end
388
+ end
389
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "base_algorithm"
5
+
6
+ module Elkrb
7
+ module Layout
8
+ module Algorithms
9
+ # MRTree (Multi-Rooted Tree) layout algorithm
10
+ #
11
+ # Arranges nodes in a tree structure that can handle multiple root nodes.
12
+ class MRTree < BaseAlgorithm
13
+ def layout_flat(graph, _options = {})
14
+ return graph if graph.children.empty?
15
+
16
+ # Identify root nodes (nodes with no incoming edges)
17
+ roots = find_root_nodes(graph)
18
+
19
+ # If no roots found, treat all nodes as roots
20
+ roots = graph.children if roots.empty?
21
+
22
+ # Build tree structure from roots
23
+ trees = roots.map { |root| build_tree(root, graph) }
24
+
25
+ # Calculate positions for each tree
26
+ spacing_val = node_spacing
27
+ x_offset = 0
28
+ trees.each do |tree|
29
+ layout_tree(tree, x_offset, 0)
30
+ tree_width = calculate_tree_width(tree)
31
+ x_offset += tree_width + spacing_val
32
+ end
33
+
34
+ # Apply padding and set graph dimensions
35
+ apply_padding(graph)
36
+
37
+ graph
38
+ end
39
+
40
+ private
41
+
42
+ def find_root_nodes(graph)
43
+ nodes_with_incoming = Set.new
44
+
45
+ graph.edges&.each do |edge|
46
+ targets = edge.targets || []
47
+ targets.each do |target_id|
48
+ nodes_with_incoming.add(target_id)
49
+ end
50
+ end
51
+
52
+ graph.children.reject { |node| nodes_with_incoming.include?(node.id) }
53
+ end
54
+
55
+ def build_tree(root, graph)
56
+ tree = {
57
+ node: root,
58
+ children: [],
59
+ level: 0,
60
+ }
61
+
62
+ # Find children (nodes connected by outgoing edges)
63
+ children = find_children(root, graph)
64
+ tree[:children] = children.map do |child|
65
+ build_subtree(child, graph, 1)
66
+ end
67
+
68
+ tree
69
+ end
70
+
71
+ def build_subtree(node, graph, level)
72
+ tree = {
73
+ node: node,
74
+ children: [],
75
+ level: level,
76
+ }
77
+
78
+ children = find_children(node, graph)
79
+ tree[:children] = children.map do |child|
80
+ build_subtree(child, graph, level + 1)
81
+ end
82
+
83
+ tree
84
+ end
85
+
86
+ def find_children(node, graph)
87
+ children = []
88
+ edges = graph.edges || []
89
+
90
+ edges.each do |edge|
91
+ sources = edge.sources || []
92
+ targets = edge.targets || []
93
+
94
+ next unless sources.include?(node.id)
95
+
96
+ targets.each do |target_id|
97
+ child = graph.children.find { |n| n.id == target_id }
98
+ children << child if child
99
+ end
100
+ end
101
+
102
+ children
103
+ end
104
+
105
+ def layout_tree(tree, x_offset, y_offset)
106
+ node = tree[:node]
107
+ spacing_val = node_spacing
108
+ level_height = 80.0
109
+
110
+ if tree[:children].empty?
111
+ # Leaf node
112
+ node.x = x_offset
113
+ node.y = y_offset + (tree[:level] * level_height)
114
+ return node.width + spacing_val
115
+ end
116
+
117
+ # Layout children first
118
+ child_x = x_offset
119
+ tree[:children].each do |child_tree|
120
+ width = layout_tree(child_tree, child_x, y_offset)
121
+ child_x += width
122
+ end
123
+
124
+ # Position this node centered above children
125
+ first_child = tree[:children].first[:node]
126
+ last_child = tree[:children].last[:node]
127
+
128
+ center_x = (first_child.x + last_child.x + last_child.width) / 2.0
129
+ node.x = center_x - (node.width / 2.0)
130
+ node.y = y_offset + (tree[:level] * level_height)
131
+
132
+ # Return total width
133
+ last_child.x + last_child.width - x_offset + spacing_val
134
+ end
135
+
136
+ def calculate_tree_width(tree)
137
+ return tree[:node].width if tree[:children].empty?
138
+
139
+ tree[:children].sum { |child| calculate_tree_width(child) }
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_algorithm"
4
+
5
+ module Elkrb
6
+ module Layout
7
+ module Algorithms
8
+ # Radial layout algorithm
9
+ #
10
+ # Arranges nodes in a circular/radial pattern around a center point.
11
+ class Radial < BaseAlgorithm
12
+ def layout_flat(graph, _options = {})
13
+ return graph if graph.children.empty?
14
+
15
+ if graph.children.size == 1
16
+ # Single node - place at origin
17
+ graph.children.first.x = 0.0
18
+ graph.children.first.y = 0.0
19
+ apply_padding(graph)
20
+ return graph
21
+ end
22
+
23
+ # Calculate radius based on node count and sizes
24
+ radius = calculate_radius(graph.children)
25
+
26
+ # Calculate center point
27
+ center_x = radius
28
+ center_y = radius
29
+
30
+ # Arrange nodes in a circle
31
+ angle_step = (2 * Math::PI) / graph.children.size
32
+
33
+ graph.children.each_with_index do |node, index|
34
+ angle = index * angle_step
35
+
36
+ # Calculate position on circle
37
+ # Adjust for node size to center the node
38
+ node.x = center_x + (radius * Math.cos(angle)) - (node.width / 2.0)
39
+ node.y = center_y + (radius * Math.sin(angle)) - (node.height / 2.0)
40
+ end
41
+
42
+ apply_padding(graph)
43
+
44
+ graph
45
+ end
46
+
47
+ private
48
+
49
+ def calculate_radius(nodes)
50
+ # Base radius on number of nodes and their average size
51
+ avg_width = nodes.sum(&:width) / nodes.size.to_f
52
+ avg_height = nodes.sum(&:height) / nodes.size.to_f
53
+ avg_size = (avg_width + avg_height) / 2.0
54
+
55
+ # Ensure enough space for all nodes
56
+ min_radius = (nodes.size * avg_size) / (2 * Math::PI)
57
+
58
+ # Add some extra spacing
59
+ [min_radius * 1.2, 100.0].max
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_algorithm"
4
+
5
+ module Elkrb
6
+ module Layout
7
+ module Algorithms
8
+ # Random layout algorithm
9
+ #
10
+ # Places nodes at random positions within a bounded area.
11
+ # Useful for initial layouts or testing.
12
+ class Random < BaseAlgorithm
13
+ def layout_flat(graph, _options = {})
14
+ return graph if graph.children.nil? || graph.children.empty?
15
+
16
+ # Get configuration
17
+ aspect_ratio = option("aspect_ratio", 1.6).to_f
18
+ spacing = node_spacing
19
+
20
+ # Calculate total area needed
21
+ total_area = graph.children.sum do |node|
22
+ (node.width + spacing) * (node.height + spacing)
23
+ end
24
+
25
+ # Calculate bounds
26
+ width = Math.sqrt(total_area * aspect_ratio)
27
+ height = width / aspect_ratio
28
+
29
+ # Position nodes randomly
30
+ graph.children.each do |node|
31
+ node.x = rand * (width - node.width)
32
+ node.y = rand * (height - node.height)
33
+ end
34
+
35
+ # Apply padding and set graph dimensions
36
+ apply_padding(graph)
37
+
38
+ graph
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end