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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_algorithm"
4
+
5
+ module Elkrb
6
+ module Layout
7
+ module Algorithms
8
+ # Rectangle Packing layout algorithm
9
+ #
10
+ # Efficiently packs rectangular nodes using a shelf-based bin packing approach.
11
+ class RectPacking < BaseAlgorithm
12
+ def layout_flat(graph, _options = {})
13
+ return graph if graph.children.empty?
14
+
15
+ if graph.children.size == 1
16
+ # Single node 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
+ # Pack rectangles using shelf algorithm
24
+ pack_rectangles(graph.children)
25
+
26
+ apply_padding(graph)
27
+
28
+ graph
29
+ end
30
+
31
+ private
32
+
33
+ def pack_rectangles(nodes)
34
+ return if nodes.empty?
35
+
36
+ spacing = node_spacing
37
+
38
+ # Sort nodes by height (tallest first) for better packing
39
+ sorted_nodes = nodes.sort_by { |n| -n.height }
40
+
41
+ # Initialize first shelf
42
+ shelves = []
43
+ current_shelf = {
44
+ y: 0.0,
45
+ height: 0.0,
46
+ width: 0.0,
47
+ nodes: [],
48
+ }
49
+
50
+ sorted_nodes.each do |node|
51
+ # Try to fit on current shelf
52
+ unless can_fit_on_shelf?(node, current_shelf, spacing)
53
+ # Start a new shelf
54
+ shelves << current_shelf unless current_shelf[:nodes].empty?
55
+
56
+ current_shelf = {
57
+ y: shelves.empty? ? 0.0 : shelves.last[:y] + shelves.last[:height] + spacing,
58
+ height: node.height,
59
+ width: 0.0,
60
+ nodes: [],
61
+ }
62
+
63
+ end
64
+ place_on_shelf(node, current_shelf, spacing)
65
+ end
66
+
67
+ # Add the last shelf
68
+ shelves << current_shelf unless current_shelf[:nodes].empty?
69
+ end
70
+
71
+ def can_fit_on_shelf?(node, shelf, _spacing)
72
+ # First node always fits
73
+ return true if shelf[:nodes].empty?
74
+
75
+ # Check if adding this node would make the shelf too tall
76
+ # (we want relatively uniform shelf heights)
77
+ shelf[:height] >= node.height * 0.8
78
+ end
79
+
80
+ def place_on_shelf(node, shelf, spacing)
81
+ # Place node at the end of the current shelf
82
+ node.x = shelf[:width]
83
+ node.y = shelf[:y]
84
+
85
+ # Update shelf dimensions
86
+ shelf[:nodes] << node
87
+ shelf[:width] += node.width + spacing
88
+ shelf[:height] = [shelf[:height], node.height].max
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ module Algorithms
6
+ # SPOrE Compaction algorithm
7
+ #
8
+ # Compacts the layout by removing whitespace while preserving
9
+ # the relative ordering and structure of nodes.
10
+ class SporeCompaction < BaseAlgorithm
11
+ def layout_flat(graph, _options = {})
12
+ return graph if graph.children.empty?
13
+
14
+ # Compact in both directions
15
+ direction = graph.layout_options&.[]("spore.compactionDirection") || "both"
16
+ min_spacing = graph.layout_options&.[]("spore.nodeSpacing") || 10.0
17
+
18
+ case direction
19
+ when "horizontal"
20
+ compact_horizontal(graph.children, min_spacing)
21
+ when "vertical"
22
+ compact_vertical(graph.children, min_spacing)
23
+ else
24
+ compact_horizontal(graph.children, min_spacing)
25
+ compact_vertical(graph.children, min_spacing)
26
+ end
27
+
28
+ # Normalize to start at origin
29
+ normalize_positions(graph.children)
30
+
31
+ # Apply padding
32
+ apply_padding(graph)
33
+
34
+ graph
35
+ end
36
+
37
+ private
38
+
39
+ def compact_horizontal(nodes, min_spacing)
40
+ # Sort nodes by x coordinate
41
+ sorted_nodes = nodes.sort_by(&:x)
42
+
43
+ # Compact from left to right
44
+ sorted_nodes.each_with_index do |node, index|
45
+ next if index.zero?
46
+
47
+ # Find the rightmost x position among nodes to the left
48
+ # that don't vertically overlap with current node
49
+ max_left_x = find_max_left_x(node, sorted_nodes[0...index],
50
+ min_spacing)
51
+
52
+ # Move node left if there's space
53
+ if max_left_x && max_left_x < node.x
54
+ node.x = max_left_x
55
+ end
56
+ end
57
+ end
58
+
59
+ def find_max_left_x(node, left_nodes, min_spacing)
60
+ # Find nodes that vertically overlap with current node
61
+ overlapping = left_nodes.select do |left_node|
62
+ vertically_overlaps?(node, left_node)
63
+ end
64
+
65
+ return 0.0 if overlapping.empty?
66
+
67
+ # Find the rightmost position among overlapping nodes
68
+ rightmost = overlapping.map { |n| n.x + n.width }.max
69
+ rightmost + min_spacing
70
+ end
71
+
72
+ def compact_vertical(nodes, min_spacing)
73
+ # Sort nodes by y coordinate
74
+ sorted_nodes = nodes.sort_by(&:y)
75
+
76
+ # Compact from top to bottom
77
+ sorted_nodes.each_with_index do |node, index|
78
+ next if index.zero?
79
+
80
+ # Find the bottommost y position among nodes above
81
+ # that don't horizontally overlap with current node
82
+ max_top_y = find_max_top_y(node, sorted_nodes[0...index],
83
+ min_spacing)
84
+
85
+ # Move node up if there's space
86
+ if max_top_y && max_top_y < node.y
87
+ node.y = max_top_y
88
+ end
89
+ end
90
+ end
91
+
92
+ def find_max_top_y(node, top_nodes, min_spacing)
93
+ # Find nodes that horizontally overlap with current node
94
+ overlapping = top_nodes.select do |top_node|
95
+ horizontally_overlaps?(node, top_node)
96
+ end
97
+
98
+ return 0.0 if overlapping.empty?
99
+
100
+ # Find the bottommost position among overlapping nodes
101
+ bottommost = overlapping.map { |n| n.y + n.height }.max
102
+ bottommost + min_spacing
103
+ end
104
+
105
+ def vertically_overlaps?(node1, node2)
106
+ top1 = node1.y
107
+ bottom1 = node1.y + node1.height
108
+ top2 = node2.y
109
+ bottom2 = node2.y + node2.height
110
+
111
+ !(bottom1 <= top2 || bottom2 <= top1)
112
+ end
113
+
114
+ def horizontally_overlaps?(node1, node2)
115
+ left1 = node1.x
116
+ right1 = node1.x + node1.width
117
+ left2 = node2.x
118
+ right2 = node2.x + node2.width
119
+
120
+ !(right1 <= left2 || right2 <= left1)
121
+ end
122
+
123
+ def normalize_positions(nodes)
124
+ return if nodes.empty?
125
+
126
+ # Find minimum x and y
127
+ min_x = nodes.map(&:x).min
128
+ min_y = nodes.map(&:y).min
129
+
130
+ # Shift all nodes to start at origin
131
+ nodes.each do |node|
132
+ node.x -= min_x
133
+ node.y -= min_y
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ module Algorithms
6
+ # SPOrE Overlap Removal algorithm
7
+ #
8
+ # Removes node overlaps while preserving the overall structure
9
+ # of the layout by applying constrained positioning.
10
+ class SporeOverlap < BaseAlgorithm
11
+ def layout_flat(graph, _options = {})
12
+ return graph if graph.children.empty?
13
+
14
+ # Iteratively remove overlaps
15
+ max_iterations = graph.layout_options&.[]("spore.maxIterations") || 50
16
+ min_spacing = graph.layout_options&.[]("spore.nodeSpacing") || 10.0
17
+
18
+ max_iterations.times do
19
+ overlaps = find_overlaps(graph.children, min_spacing)
20
+ break if overlaps.empty?
21
+
22
+ resolve_overlaps(overlaps, min_spacing)
23
+ end
24
+
25
+ # Apply padding
26
+ apply_padding(graph)
27
+
28
+ graph
29
+ end
30
+
31
+ private
32
+
33
+ def find_overlaps(nodes, min_spacing)
34
+ overlaps = []
35
+
36
+ nodes.each_with_index do |node1, i|
37
+ nodes[(i + 1)..].each do |node2|
38
+ if overlapping?(node1, node2, min_spacing)
39
+ overlaps << [node1, node2]
40
+ end
41
+ end
42
+ end
43
+
44
+ overlaps
45
+ end
46
+
47
+ def overlapping?(node1, node2, min_spacing)
48
+ # Calculate bounding boxes with spacing
49
+ left1 = node1.x - (min_spacing / 2.0)
50
+ right1 = node1.x + node1.width + (min_spacing / 2.0)
51
+ top1 = node1.y - (min_spacing / 2.0)
52
+ bottom1 = node1.y + node1.height + (min_spacing / 2.0)
53
+
54
+ left2 = node2.x - (min_spacing / 2.0)
55
+ right2 = node2.x + node2.width + (min_spacing / 2.0)
56
+ top2 = node2.y - (min_spacing / 2.0)
57
+ bottom2 = node2.y + node2.height + (min_spacing / 2.0)
58
+
59
+ # Check for overlap
60
+ !(right1 <= left2 || left1 >= right2 || bottom1 <= top2 || top1 >= bottom2)
61
+ end
62
+
63
+ def resolve_overlaps(overlaps, min_spacing)
64
+ overlaps.each do |node1, node2|
65
+ # Calculate overlap amounts
66
+ center1_x = node1.x + (node1.width / 2.0)
67
+ center1_y = node1.y + (node1.height / 2.0)
68
+ center2_x = node2.x + (node2.width / 2.0)
69
+ center2_y = node2.y + (node2.height / 2.0)
70
+
71
+ dx = center2_x - center1_x
72
+ dy = center2_y - center1_y
73
+
74
+ # Calculate minimum required distance
75
+ min_dist_x = ((node1.width + node2.width) / 2.0) + min_spacing
76
+ min_dist_y = ((node1.height + node2.height) / 2.0) + min_spacing
77
+
78
+ # Determine movement direction
79
+ if dx.abs > dy.abs
80
+ # Move horizontally
81
+ if dx.positive?
82
+ # node2 is to the right
83
+ overlap = min_dist_x - dx
84
+ if overlap.positive?
85
+ node2.x += overlap / 2.0
86
+ node1.x -= overlap / 2.0
87
+ end
88
+ else
89
+ # node2 is to the left
90
+ overlap = min_dist_x + dx
91
+ if overlap.positive?
92
+ node2.x -= overlap / 2.0
93
+ node1.x += overlap / 2.0
94
+ end
95
+ end
96
+ elsif dy.positive?
97
+ # Move vertically
98
+ overlap = min_dist_y - dy
99
+ if overlap.positive?
100
+ node2.y += overlap / 2.0
101
+ node1.y -= overlap / 2.0
102
+ end
103
+ # node2 is below
104
+ else
105
+ # node2 is above
106
+ overlap = min_dist_y + dy
107
+ if overlap.positive?
108
+ node2.y -= overlap / 2.0
109
+ node1.y += overlap / 2.0
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_algorithm"
4
+
5
+ module Elkrb
6
+ module Layout
7
+ module Algorithms
8
+ # Stress minimization layout algorithm
9
+ #
10
+ # Quality-focused layout that minimizes stress by optimizing
11
+ # the placement of nodes to match ideal distances.
12
+ # Produces aesthetically pleasing layouts with good edge lengths.
13
+ #
14
+ # Ideal for:
15
+ # - High-quality graph visualization
16
+ # - Research diagrams
17
+ # - Publication-ready layouts
18
+ # - Small to medium-sized graphs
19
+ class Stress < BaseAlgorithm
20
+ DEFAULT_ITERATIONS = 500
21
+ DEFAULT_EPSILON = 0.0001
22
+
23
+ def layout_flat(graph, _options = {})
24
+ return graph if graph.children.nil? || graph.children.empty?
25
+
26
+ # Get configuration
27
+ iterations = option("iterations", DEFAULT_ITERATIONS).to_i
28
+ epsilon = option("epsilon", DEFAULT_EPSILON).to_f
29
+
30
+ # Initialize positions
31
+ initialize_positions(graph)
32
+
33
+ # Calculate shortest path distances
34
+ distances = calculate_distances(graph)
35
+
36
+ # Iteratively minimize stress
37
+ iterations.times do |_i|
38
+ old_stress = calculate_stress(graph, distances)
39
+ optimize_positions(graph, distances)
40
+ new_stress = calculate_stress(graph, distances)
41
+
42
+ # Stop if converged
43
+ break if (old_stress - new_stress).abs < epsilon
44
+ end
45
+
46
+ # Apply padding and set graph dimensions
47
+ apply_padding(graph)
48
+
49
+ graph
50
+ end
51
+
52
+ private
53
+
54
+ def initialize_positions(graph)
55
+ # Use circular initial layout
56
+ n = graph.children.length
57
+ radius = n * 10.0
58
+
59
+ graph.children.each_with_index do |node, i|
60
+ angle = 2 * Math::PI * i / n
61
+ node.x = (radius * Math.cos(angle)) + radius
62
+ node.y = (radius * Math.sin(angle)) + radius
63
+ end
64
+ end
65
+
66
+ def calculate_distances(graph)
67
+ n = graph.children.length
68
+ distances = Array.new(n) { Array.new(n, Float::INFINITY) }
69
+
70
+ # Initialize distances
71
+ n.times { |i| distances[i][i] = 0 }
72
+
73
+ # Set edge distances
74
+ all_edges = collect_all_edges(graph)
75
+ all_edges.each do |edge|
76
+ source_id = edge.sources&.first
77
+ target_id = edge.targets&.first
78
+ next unless source_id && target_id
79
+
80
+ i = graph.children.index { |n| n.id == source_id }
81
+ j = graph.children.index { |n| n.id == target_id }
82
+
83
+ next unless i && j
84
+
85
+ distances[i][j] = 1.0
86
+ distances[j][i] = 1.0
87
+ end
88
+
89
+ # Floyd-Warshall for shortest paths
90
+ n.times do |k|
91
+ n.times do |i|
92
+ n.times do |j|
93
+ distances[i][j] = [
94
+ distances[i][j],
95
+ distances[i][k] + distances[k][j],
96
+ ].min
97
+ end
98
+ end
99
+ end
100
+
101
+ distances
102
+ end
103
+
104
+ def calculate_stress(graph, ideal_distances)
105
+ stress = 0.0
106
+ n = graph.children.length
107
+
108
+ n.times do |i|
109
+ (i + 1).upto(n - 1) do |j|
110
+ node_i = graph.children[i]
111
+ node_j = graph.children[j]
112
+
113
+ dx = node_j.x - node_i.x
114
+ dy = node_j.y - node_i.y
115
+ actual_dist = Math.sqrt((dx**2) + (dy**2))
116
+
117
+ ideal_dist = ideal_distances[i][j]
118
+ next if ideal_dist == Float::INFINITY
119
+
120
+ diff = actual_dist - ideal_dist
121
+ stress += diff * diff
122
+ end
123
+ end
124
+
125
+ stress
126
+ end
127
+
128
+ def optimize_positions(graph, ideal_distances)
129
+ n = graph.children.length
130
+
131
+ # Calculate new positions using stress majorization
132
+ graph.children.each_with_index do |node, i|
133
+ sum_x = 0.0
134
+ sum_y = 0.0
135
+ sum_weight = 0.0
136
+
137
+ n.times do |j|
138
+ next if i == j
139
+
140
+ ideal_dist = ideal_distances[i][j]
141
+ next if ideal_dist == Float::INFINITY
142
+
143
+ other = graph.children[j]
144
+ dx = other.x - node.x
145
+ dy = other.y - node.y
146
+ actual_dist = Math.sqrt((dx**2) + (dy**2))
147
+
148
+ next if actual_dist.zero?
149
+
150
+ weight = 1.0 / (ideal_dist * ideal_dist)
151
+ ratio = ideal_dist / actual_dist
152
+
153
+ sum_x += weight * (other.x - (ratio * dx))
154
+ sum_y += weight * (other.y - (ratio * dy))
155
+ sum_weight += weight
156
+ end
157
+
158
+ if sum_weight.positive?
159
+ node.x = sum_x / sum_weight
160
+ node.y = sum_y / sum_weight
161
+ end
162
+ end
163
+ end
164
+
165
+ def collect_all_edges(graph)
166
+ edges = []
167
+ edges.concat(graph.edges) if graph.edges
168
+ graph.children&.each do |node|
169
+ edges.concat(node.edges) if node.edges
170
+ end
171
+ edges
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_algorithm"
4
+
5
+ module Elkrb
6
+ module Layout
7
+ module Algorithms
8
+ # TopdownPacking layout algorithm
9
+ #
10
+ # Arranges nodes in a grid using top-down, left-right placement.
11
+ # Unlike RectPacking which uses shelf-based bin packing, TopdownPacking
12
+ # arranges nodes in a uniform grid, making it ideal for:
13
+ # - Treemap-style layouts
14
+ # - Dashboard tile arrangements
15
+ # - Hierarchical layouts with uniform node sizes
16
+ #
17
+ # The algorithm calculates grid dimensions to approximate a square
18
+ # aspect ratio, then places nodes from left to right, top to bottom.
19
+ class TopdownPacking < BaseAlgorithm
20
+ def initialize(options = {})
21
+ super
22
+ end
23
+
24
+ # Layout nodes in a grid using top-down packing
25
+ #
26
+ # @param graph [Elkrb::Graph::Graph] The graph to layout
27
+ # @param options [Hash] Layout options
28
+ # @return [Elkrb::Graph::Graph] The graph with updated positions
29
+ def layout_flat(graph, _options = {})
30
+ return graph if graph.children.empty?
31
+
32
+ if graph.children.size == 1
33
+ # Single node at origin
34
+ graph.children.first.x = 0.0
35
+ graph.children.first.y = 0.0
36
+ apply_padding(graph)
37
+ return graph
38
+ end
39
+
40
+ # Calculate grid dimensions
41
+ nodes = graph.children
42
+ grid_dims = calculate_grid_dimensions(nodes.size)
43
+ cols = grid_dims[:cols]
44
+ rows = grid_dims[:rows]
45
+
46
+ # Get node dimensions (uniform for grid layout)
47
+ node_dims = calculate_node_dimensions(graph, nodes, cols, rows)
48
+ node_width = node_dims[:width]
49
+ node_height = node_dims[:height]
50
+
51
+ # Place nodes in grid
52
+ place_nodes_in_grid(nodes, cols, node_width, node_height)
53
+
54
+ apply_padding(graph)
55
+
56
+ graph
57
+ end
58
+
59
+ private
60
+
61
+ # Calculate grid dimensions to approximate square aspect ratio
62
+ #
63
+ # Uses the formula from the Java implementation:
64
+ # cols = ceil(sqrt(N))
65
+ # rows = cols if N > cols^2 - cols, else cols - 1
66
+ #
67
+ # @param node_count [Integer] Number of nodes to arrange
68
+ # @return [Hash] Grid dimensions with :cols and :rows
69
+ def calculate_grid_dimensions(node_count)
70
+ return { cols: 0, rows: 0 } if node_count.zero?
71
+
72
+ # Calculate columns to approximate square
73
+ cols = Math.sqrt(node_count).ceil
74
+
75
+ # Calculate rows based on remaining nodes
76
+ # This ensures we don't have an empty last row
77
+ rows = if node_count > (cols * cols) - cols || cols.zero?
78
+ cols
79
+ else
80
+ cols - 1
81
+ end
82
+
83
+ { cols: cols, rows: rows }
84
+ end
85
+
86
+ # Calculate uniform node dimensions for grid cells
87
+ #
88
+ # @param graph [Elkrb::Graph::Graph] The graph
89
+ # @param nodes [Array<Elkrb::Graph::Node>] The nodes
90
+ # @param cols [Integer] Number of columns
91
+ # @param rows [Integer] Number of rows
92
+ # @return [Hash] Node dimensions with :width and :height
93
+ def calculate_node_dimensions(graph, nodes, cols, rows)
94
+ if nodes.empty? || cols.zero? || rows.zero?
95
+ return { width: 0.0,
96
+ height: 0.0 }
97
+ end
98
+
99
+ # Get options from graph layout options
100
+ layout_opts = graph.layout_options || {}
101
+
102
+ # Get target aspect ratio (default: 1.0 for square cells)
103
+ target_aspect_ratio = get_option(layout_opts,
104
+ "topdownpacking.aspectRatio", 1.0).to_f
105
+ target_aspect_ratio = 1.0 if target_aspect_ratio <= 0.0
106
+
107
+ # Calculate node dimensions based on aspect ratio
108
+ # We can either use specified dimensions or calculate from available space
109
+ node_width_opt = get_option(layout_opts, "topdownpacking.nodeWidth")
110
+ if node_width_opt
111
+ node_width = node_width_opt.to_f
112
+ node_height = node_width / target_aspect_ratio
113
+ else
114
+ # Calculate from node sizes to maintain proportions
115
+ avg_width = nodes.sum(&:width) / nodes.size.to_f
116
+ avg_height = nodes.sum(&:height) / nodes.size.to_f
117
+
118
+ # Use the larger dimension as base
119
+ if avg_width >= avg_height
120
+ node_width = avg_width
121
+ node_height = node_width / target_aspect_ratio
122
+ else
123
+ node_height = avg_height
124
+ node_width = node_height * target_aspect_ratio
125
+ end
126
+ end
127
+
128
+ { width: node_width, height: node_height }
129
+ end
130
+
131
+ # Get option value from layout options or default
132
+ #
133
+ # @param layout_opts [Hash, LayoutOptions] The layout options
134
+ # @param key [String] The option key
135
+ # @param default [Object] The default value
136
+ # @return [Object] The option value or default
137
+ def get_option(layout_opts, key, default = nil)
138
+ return default unless layout_opts
139
+
140
+ value = if layout_opts.respond_to?(:[])
141
+ layout_opts[key]
142
+ end
143
+
144
+ value.nil? ? default : value
145
+ end
146
+
147
+ # Place nodes in grid positions
148
+ #
149
+ # @param nodes [Array<Elkrb::Graph::Node>] The nodes to place
150
+ # @param cols [Integer] Number of columns
151
+ # @param node_width [Float] Width of each grid cell
152
+ # @param node_height [Float] Height of each grid cell
153
+ def place_nodes_in_grid(nodes, cols, node_width, node_height)
154
+ spacing = node_spacing
155
+ current_x = 0.0
156
+ current_y = 0.0
157
+ current_col = 0
158
+
159
+ nodes.each do |node|
160
+ # Set node dimensions
161
+ node.width = node_width
162
+ node.height = node_height
163
+
164
+ # Set node position
165
+ node.x = current_x
166
+ node.y = current_y
167
+
168
+ # Advance to next position
169
+ current_col += 1
170
+ current_x += node_width + spacing
171
+
172
+ # Move to next row if we've filled the current row
173
+ if current_col >= cols
174
+ current_x = 0.0
175
+ current_y += node_height + spacing
176
+ current_col = 0
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end