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,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ module Algorithms
6
+ # DISCO (Disconnected Graph Layout) algorithm
7
+ #
8
+ # Handles graphs with disconnected components by:
9
+ # 1. Identifying connected components
10
+ # 2. Laying out each component independently
11
+ # 3. Arranging components in a grid or row
12
+ class Disco < BaseAlgorithm
13
+ def layout_flat(graph, _options = {})
14
+ return graph if graph.children.empty?
15
+
16
+ # Find connected components
17
+ components = find_connected_components(graph)
18
+
19
+ # Layout each component independently
20
+ component_algo = graph.layout_options&.[]("disco.componentAlgorithm") || "layered"
21
+ components.each do |component|
22
+ layout_component(component, component_algo)
23
+ end
24
+
25
+ # Arrange components
26
+ spacing = graph.layout_options&.[]("disco.componentSpacing") || 20.0
27
+ arrange_components(components, graph, spacing)
28
+
29
+ # Apply padding
30
+ apply_padding(graph)
31
+
32
+ graph
33
+ end
34
+
35
+ private
36
+
37
+ def find_connected_components(graph)
38
+ visited = Set.new
39
+ components = []
40
+
41
+ graph.children.each do |node|
42
+ next if visited.include?(node)
43
+
44
+ component = {
45
+ nodes: [],
46
+ edges: [],
47
+ }
48
+
49
+ # BFS to find all connected nodes
50
+ queue = [node]
51
+ while queue.any?
52
+ current = queue.shift
53
+ next if visited.include?(current)
54
+
55
+ visited.add(current)
56
+ component[:nodes] << current
57
+
58
+ # Find connected nodes through edges
59
+ connected_edges = graph.edges.select do |edge|
60
+ edge_nodes = []
61
+ edge.sources&.each do |port|
62
+ edge_nodes << (port.respond_to?(:node) ? port.node : port)
63
+ end
64
+ edge.targets&.each do |port|
65
+ edge_nodes << (port.respond_to?(:node) ? port.node : port)
66
+ end
67
+ edge_nodes.include?(current)
68
+ end
69
+
70
+ component[:edges].concat(connected_edges)
71
+
72
+ connected_edges.each do |edge|
73
+ nodes = []
74
+ edge.sources&.each do |port|
75
+ nodes << (port.respond_to?(:node) ? port.node : port)
76
+ end
77
+ edge.targets&.each do |port|
78
+ nodes << (port.respond_to?(:node) ? port.node : port)
79
+ end
80
+ nodes.each { |n| queue << n unless visited.include?(n) }
81
+ end
82
+ end
83
+
84
+ components << component
85
+ end
86
+
87
+ components
88
+ end
89
+
90
+ def layout_component(component, algorithm_name)
91
+ return if component[:nodes].empty?
92
+
93
+ # Create a temporary graph for this component
94
+ temp_graph = Graph::Graph.new
95
+ temp_graph.children = component[:nodes]
96
+ temp_graph.edges = component[:edges]
97
+
98
+ # Get algorithm from registry
99
+ algorithm_class = Layout::AlgorithmRegistry.get(algorithm_name)
100
+ return unless algorithm_class
101
+
102
+ # Apply layout algorithm to component
103
+ algorithm = algorithm_class.new
104
+ algorithm.layout(temp_graph)
105
+ end
106
+
107
+ def arrange_components(components, graph, spacing)
108
+ return if components.empty?
109
+
110
+ arrangement = graph.layout_options&.[]("disco.componentArrangement") || "row"
111
+
112
+ case arrangement
113
+ when "grid"
114
+ arrange_in_grid(components, spacing)
115
+ when "column"
116
+ arrange_in_column(components, spacing)
117
+ else
118
+ arrange_in_row(components, spacing)
119
+ end
120
+ end
121
+
122
+ def arrange_in_row(components, spacing)
123
+ x_offset = 0.0
124
+
125
+ components.each do |component|
126
+ # Calculate component bounds
127
+ min_x = component[:nodes].map(&:x).min || 0.0
128
+ max_x = component[:nodes].map { |n| n.x + n.width }.max || 0.0
129
+ component_width = max_x - min_x
130
+
131
+ # Offset all nodes in component
132
+ offset_x = x_offset - min_x
133
+ component[:nodes].each do |node|
134
+ node.x += offset_x
135
+ end
136
+
137
+ x_offset += component_width + spacing
138
+ end
139
+ end
140
+
141
+ def arrange_in_column(components, spacing)
142
+ y_offset = 0.0
143
+
144
+ components.each do |component|
145
+ # Calculate component bounds
146
+ min_y = component[:nodes].map(&:y).min || 0.0
147
+ max_y = component[:nodes].map { |n| n.y + n.height }.max || 0.0
148
+ component_height = max_y - min_y
149
+
150
+ # Offset all nodes in component
151
+ offset_y = y_offset - min_y
152
+ component[:nodes].each do |node|
153
+ node.y += offset_y
154
+ end
155
+
156
+ y_offset += component_height + spacing
157
+ end
158
+ end
159
+
160
+ def arrange_in_grid(components, spacing)
161
+ return if components.empty?
162
+
163
+ # Calculate grid dimensions
164
+ cols = Math.sqrt(components.size).ceil
165
+ rows = (components.size.to_f / cols).ceil
166
+
167
+ y_offset = 0.0
168
+ row_heights = []
169
+
170
+ rows.times do |row|
171
+ x_offset = 0.0
172
+ max_row_height = 0.0
173
+
174
+ cols.times do |col|
175
+ index = (row * cols) + col
176
+ break if index >= components.size
177
+
178
+ component = components[index]
179
+
180
+ # Calculate component bounds
181
+ min_x = component[:nodes].map(&:x).min || 0.0
182
+ min_y = component[:nodes].map(&:y).min || 0.0
183
+ max_x = component[:nodes].map { |n| n.x + n.width }.max || 0.0
184
+ max_y = component[:nodes].map { |n| n.y + n.height }.max || 0.0
185
+
186
+ component_width = max_x - min_x
187
+ component_height = max_y - min_y
188
+
189
+ # Offset nodes
190
+ component[:nodes].each do |node|
191
+ node.x += x_offset - min_x
192
+ node.y += y_offset - min_y
193
+ end
194
+
195
+ x_offset += component_width + spacing
196
+ max_row_height = [max_row_height, component_height].max
197
+ end
198
+
199
+ row_heights << max_row_height
200
+ y_offset += max_row_height + spacing
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_algorithm"
4
+
5
+ module Elkrb
6
+ module Layout
7
+ module Algorithms
8
+ # Fixed layout algorithm
9
+ #
10
+ # Keeps nodes at their current positions. Only applies padding
11
+ # and calculates graph dimensions based on existing node positions.
12
+ # Useful when node positions are pre-determined or manually set.
13
+ class Fixed < BaseAlgorithm
14
+ def layout_flat(graph, _options = {})
15
+ return graph if graph.children.nil? || graph.children.empty?
16
+
17
+ # Ensure all nodes have positions
18
+ graph.children.each do |node|
19
+ node.x ||= 0.0
20
+ node.y ||= 0.0
21
+ end
22
+
23
+ # Simply apply padding and calculate dimensions
24
+ # Nodes keep their existing x, y positions
25
+ apply_padding(graph)
26
+
27
+ graph
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_algorithm"
4
+
5
+ module Elkrb
6
+ module Layout
7
+ module Algorithms
8
+ # Force-directed layout algorithm
9
+ #
10
+ # Creates organic, symmetric layouts using force simulation.
11
+ # Nodes repel each other while edges act as springs pulling
12
+ # connected nodes together.
13
+ #
14
+ # Ideal for:
15
+ # - Network diagrams
16
+ # - Social graphs
17
+ # - Mind maps
18
+ # - General undirected graphs
19
+ class Force < BaseAlgorithm
20
+ DEFAULT_ITERATIONS = 300
21
+ DEFAULT_REPULSION = 5.0
22
+ DEFAULT_TEMPERATURE = 0.001
23
+
24
+ def layout_flat(graph, _options = {})
25
+ return graph if graph.children.nil? || graph.children.empty?
26
+
27
+ # Get configuration
28
+ iterations = option("iterations", DEFAULT_ITERATIONS).to_i
29
+ repulsion = option("repulsion", DEFAULT_REPULSION).to_f
30
+ temperature = option("temperature", DEFAULT_TEMPERATURE).to_f
31
+
32
+ # Initialize positions randomly if not set
33
+ initialize_positions(graph)
34
+
35
+ # Run force simulation
36
+ iterations.times do |i|
37
+ apply_forces(graph, repulsion, temperature, i, iterations)
38
+ end
39
+
40
+ # Apply padding and set graph dimensions
41
+ apply_padding(graph)
42
+
43
+ graph
44
+ end
45
+
46
+ private
47
+
48
+ def initialize_positions(graph)
49
+ # Calculate approximate area needed
50
+ total_area = graph.children.sum do |n|
51
+ (n.width + 20) * (n.height + 20)
52
+ end
53
+ side = Math.sqrt(total_area)
54
+
55
+ graph.children.each do |node|
56
+ # Set random position if not already set
57
+ unless node.x && node.y
58
+ node.x = rand * side
59
+ node.y = rand * side
60
+ end
61
+ end
62
+ end
63
+
64
+ def apply_forces(graph, repulsion, temperature, iteration,
65
+ max_iterations)
66
+ # Calculate temperature decay
67
+ temp = temperature * (1.0 - (iteration.to_f / max_iterations))
68
+
69
+ # Calculate forces for each node
70
+ forces = calculate_forces(graph, repulsion)
71
+
72
+ # Apply forces with temperature
73
+ graph.children.each_with_index do |node, i|
74
+ force = forces[i]
75
+ magnitude = Math.sqrt((force[:x]**2) + (force[:y]**2))
76
+
77
+ next if magnitude.zero?
78
+
79
+ # Apply displacement with temperature
80
+ displacement = [magnitude, temp].min
81
+ node.x += (force[:x] / magnitude) * displacement
82
+ node.y += (force[:y] / magnitude) * displacement
83
+ end
84
+ end
85
+
86
+ def calculate_forces(graph, repulsion)
87
+ forces = graph.children.map { { x: 0.0, y: 0.0 } }
88
+
89
+ # Repulsive forces between all pairs
90
+ graph.children.each_with_index do |node1, i|
91
+ graph.children.each_with_index do |node2, j|
92
+ next if i >= j
93
+
94
+ apply_repulsive_force(node1, node2, forces[i], forces[j],
95
+ repulsion)
96
+ end
97
+ end
98
+
99
+ # Attractive forces for edges
100
+ all_edges = collect_all_edges(graph)
101
+ all_edges.each do |edge|
102
+ source_id = edge.sources&.first
103
+ target_id = edge.targets&.first
104
+
105
+ next unless source_id && target_id
106
+
107
+ source_idx = graph.children.index { |n| n.id == source_id }
108
+ target_idx = graph.children.index { |n| n.id == target_id }
109
+
110
+ next unless source_idx && target_idx
111
+
112
+ apply_attractive_force(
113
+ graph.children[source_idx],
114
+ graph.children[target_idx],
115
+ forces[source_idx],
116
+ forces[target_idx],
117
+ )
118
+ end
119
+
120
+ forces
121
+ end
122
+
123
+ def apply_repulsive_force(node1, node2, force1, force2, repulsion)
124
+ dx = node2.x - node1.x
125
+ dy = node2.y - node1.y
126
+ distance_sq = (dx**2) + (dy**2)
127
+
128
+ # Avoid division by zero
129
+ return if distance_sq < 0.01
130
+
131
+ # Repulsive force inversely proportional to distance
132
+ force = repulsion / distance_sq
133
+ force1[:x] -= (dx / Math.sqrt(distance_sq)) * force
134
+ force1[:y] -= (dy / Math.sqrt(distance_sq)) * force
135
+ force2[:x] += (dx / Math.sqrt(distance_sq)) * force
136
+ force2[:y] += (dy / Math.sqrt(distance_sq)) * force
137
+ end
138
+
139
+ def apply_attractive_force(node1, node2, force1, force2)
140
+ dx = node2.x - node1.x
141
+ dy = node2.y - node1.y
142
+ distance = Math.sqrt((dx**2) + (dy**2))
143
+
144
+ return if distance.zero?
145
+
146
+ # Spring force proportional to distance
147
+ force = distance / 10.0
148
+ force1[:x] += (dx / distance) * force
149
+ force1[:y] += (dy / distance) * force
150
+ force2[:x] -= (dx / distance) * force
151
+ force2[:y] -= (dy / distance) * force
152
+ end
153
+
154
+ def collect_all_edges(graph)
155
+ edges = []
156
+ edges.concat(graph.edges) if graph.edges
157
+ graph.children&.each do |node|
158
+ edges.concat(node.edges) if node.edges
159
+ end
160
+ edges
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ module Algorithms
6
+ module Layered
7
+ # Breaks cycles in the graph to make it acyclic
8
+ #
9
+ # This is the first phase of the Sugiyama framework.
10
+ # Uses a greedy approach to reverse edges that create cycles.
11
+ class CycleBreaker
12
+ def initialize(graph)
13
+ @graph = graph
14
+ @visited = {}
15
+ @in_stack = {}
16
+ @edges_to_reverse = []
17
+ end
18
+
19
+ def break_cycles
20
+ return unless @graph.children
21
+
22
+ # Find all edges that create cycles using DFS
23
+ @graph.children.each do |node|
24
+ dfs(node) unless @visited[node.id]
25
+ end
26
+
27
+ # Reverse the problematic edges
28
+ reverse_edges
29
+
30
+ @edges_to_reverse
31
+ end
32
+
33
+ private
34
+
35
+ def dfs(node)
36
+ @visited[node.id] = true
37
+ @in_stack[node.id] = true
38
+
39
+ # Process outgoing edges
40
+ get_outgoing_edges(node).each do |edge|
41
+ target_id = edge.targets.first
42
+ next unless target_id
43
+
44
+ target = @graph.find_node(target_id)
45
+ next unless target
46
+
47
+ if @in_stack[target.id]
48
+ # Found a cycle - mark this edge for reversal
49
+ @edges_to_reverse << edge
50
+ elsif !@visited[target.id]
51
+ dfs(target)
52
+ end
53
+ end
54
+
55
+ @in_stack[node.id] = false
56
+ end
57
+
58
+ def get_outgoing_edges(node)
59
+ edges = []
60
+
61
+ # Get edges from the node itself
62
+ edges.concat(node.edges) if node.edges
63
+
64
+ # Get edges from the graph that have this node as source
65
+ @graph.edges&.each do |edge|
66
+ edges << edge if edge.sources&.include?(node.id)
67
+ end
68
+
69
+ edges
70
+ end
71
+
72
+ def reverse_edges
73
+ @edges_to_reverse.each do |edge|
74
+ # Swap sources and targets
75
+ edge.sources, edge.targets = edge.targets, edge.sources
76
+
77
+ # Mark as reversed for later processing
78
+ edge.properties ||= {}
79
+ edge.properties["reversed"] = true
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ module Algorithms
6
+ module Layered
7
+ # Assigns nodes to layers in the graph
8
+ #
9
+ # This is the second phase of the Sugiyama framework.
10
+ # Uses longest path layering to create a balanced layout.
11
+ class LayerAssigner
12
+ attr_reader :layers
13
+
14
+ def initialize(graph)
15
+ @graph = graph
16
+ @layers = []
17
+ @node_layers = {}
18
+ end
19
+
20
+ def assign_layers
21
+ return [] unless @graph.children
22
+
23
+ # Calculate layer for each node
24
+ @graph.children.each do |node|
25
+ calculate_layer(node)
26
+ end
27
+
28
+ # Group nodes by layer
29
+ max_layer = @node_layers.values.max || 0
30
+ @layers = Array.new(max_layer + 1) { [] }
31
+
32
+ @node_layers.each do |node_id, layer|
33
+ node = @graph.find_node(node_id)
34
+ @layers[layer] << node if node
35
+ end
36
+
37
+ @layers
38
+ end
39
+
40
+ def get_layer(node_id)
41
+ @node_layers[node_id]
42
+ end
43
+
44
+ private
45
+
46
+ def calculate_layer(node)
47
+ return @node_layers[node.id] if @node_layers.key?(node.id)
48
+
49
+ # Find incoming edges
50
+ incoming = get_incoming_edges(node)
51
+
52
+ if incoming.empty?
53
+ # Root node - assign to layer 0
54
+ @node_layers[node.id] = 0
55
+ else
56
+ # Assign to one layer below the maximum of predecessors
57
+ max_pred_layer = incoming.filter_map do |edge|
58
+ source_id = edge.sources.first
59
+ next 0 unless source_id
60
+
61
+ source = @graph.find_node(source_id)
62
+ next 0 unless source
63
+
64
+ calculate_layer(source)
65
+ end.max || 0
66
+
67
+ @node_layers[node.id] = max_pred_layer + 1
68
+ end
69
+
70
+ @node_layers[node.id]
71
+ end
72
+
73
+ def get_incoming_edges(node)
74
+ edges = []
75
+
76
+ # Get all edges that target this node
77
+ @graph.edges&.each do |edge|
78
+ edges << edge if edge.targets&.include?(node.id)
79
+ end
80
+
81
+ # Also check edges from other nodes
82
+ @graph.children&.each do |other_node|
83
+ next unless other_node.edges
84
+
85
+ other_node.edges.each do |edge|
86
+ edges << edge if edge.targets&.include?(node.id)
87
+ end
88
+ end
89
+
90
+ edges
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ module Algorithms
6
+ module Layered
7
+ # Places nodes within their assigned layers
8
+ #
9
+ # This phase calculates the x and y coordinates for each node
10
+ # based on their layer assignment and spacing requirements.
11
+ class NodePlacer
12
+ def initialize(graph, layers, options = {})
13
+ @graph = graph
14
+ @layers = layers
15
+ @options = options
16
+ @layer_spacing = options[:layer_spacing] || 60.0
17
+ @node_spacing = options[:spacing_node_node] || 20.0
18
+ end
19
+
20
+ def place_nodes
21
+ return unless @layers && !@layers.empty?
22
+
23
+ # Calculate layer widths
24
+ layer_widths = calculate_layer_widths
25
+
26
+ # Calculate y positions for each layer
27
+ y_positions = calculate_layer_y_positions
28
+
29
+ # Place nodes in each layer
30
+ @layers.each_with_index do |layer_nodes, layer_index|
31
+ place_layer(layer_nodes, layer_index, layer_widths[layer_index],
32
+ y_positions[layer_index])
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def calculate_layer_widths
39
+ @layers.map do |layer_nodes|
40
+ return 0 if layer_nodes.empty?
41
+
42
+ total_width = layer_nodes.sum { |n| n.width || 0 }
43
+ total_spacing = (layer_nodes.length - 1) * @node_spacing
44
+ total_width + total_spacing
45
+ end
46
+ end
47
+
48
+ def calculate_layer_y_positions
49
+ y = 0
50
+ positions = []
51
+
52
+ @layers.each do |layer_nodes|
53
+ positions << y
54
+ max_height = layer_nodes.map { |n| n.height || 0 }.max || 0
55
+ y += max_height + @layer_spacing
56
+ end
57
+
58
+ positions
59
+ end
60
+
61
+ def place_layer(nodes, _layer_index, _layer_width, y_pos)
62
+ return if nodes.empty?
63
+
64
+ # Center the layer horizontally
65
+ x = 0
66
+
67
+ nodes.each do |node|
68
+ node.x = x
69
+ node.y = y_pos
70
+ x += (node.width || 0) + @node_spacing
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_algorithm"
4
+ require_relative "layered/cycle_breaker"
5
+ require_relative "layered/layer_assigner"
6
+ require_relative "layered/node_placer"
7
+
8
+ module Elkrb
9
+ module Layout
10
+ module Algorithms
11
+ # Layered (Sugiyama) layout algorithm
12
+ #
13
+ # The flagship algorithm for hierarchical graph layout.
14
+ # Implements the Sugiyama framework in phases:
15
+ # 1. Cycle breaking - make the graph acyclic
16
+ # 2. Layer assignment - assign nodes to horizontal layers
17
+ # 3. Node placement - position nodes within layers
18
+ #
19
+ # Ideal for:
20
+ # - UML class diagrams
21
+ # - Call graphs
22
+ # - Data flow diagrams
23
+ # - Organization charts
24
+ # - Any directed acyclic graph
25
+ class LayeredAlgorithm < BaseAlgorithm
26
+ def layout_flat(graph, _options = {})
27
+ return graph if graph.children.nil? || graph.children.empty?
28
+
29
+ # Phase 1: Break cycles
30
+ cycle_breaker = Layered::CycleBreaker.new(graph)
31
+ cycle_breaker.break_cycles
32
+
33
+ # Phase 2: Assign layers
34
+ layer_assigner = Layered::LayerAssigner.new(graph)
35
+ layers = layer_assigner.assign_layers
36
+
37
+ # Phase 3: Place nodes
38
+ node_placer = Layered::NodePlacer.new(graph, layers, @options)
39
+ node_placer.place_nodes
40
+
41
+ # Apply padding and set graph dimensions
42
+ apply_padding(graph)
43
+
44
+ graph
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end