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,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Elkrb
6
+ module Graph
7
+ class Port < Lutaml::Model::Serializable
8
+ attribute :id, :string
9
+ attribute :x, :float
10
+ attribute :y, :float
11
+ attribute :width, :float
12
+ attribute :height, :float
13
+ attribute :labels, Label, collection: true
14
+ attribute :layout_options, LayoutOptions
15
+ attribute :properties, :hash
16
+ attribute :side, :string, default: -> { "UNDEFINED" }
17
+ attribute :index, :integer, default: -> { -1 }
18
+ attribute :offset, :float, default: -> { 0.0 }
19
+
20
+ # Port sides
21
+ NORTH = "NORTH"
22
+ SOUTH = "SOUTH"
23
+ EAST = "EAST"
24
+ WEST = "WEST"
25
+ UNDEFINED = "UNDEFINED"
26
+
27
+ SIDES = [NORTH, SOUTH, EAST, WEST, UNDEFINED].freeze
28
+
29
+ # Node reference (not serialized)
30
+ attr_accessor :node
31
+
32
+ json do
33
+ map "id", to: :id
34
+ map "x", to: :x
35
+ map "y", to: :y
36
+ map "width", to: :width
37
+ map "height", to: :height
38
+ map "labels", to: :labels
39
+ map "layoutOptions", to: :layout_options
40
+ map "properties", to: :properties
41
+ map "side", to: :side
42
+ map "index", to: :index
43
+ map "offset", to: :offset
44
+ end
45
+
46
+ yaml do
47
+ map "id", to: :id
48
+ map "x", to: :x
49
+ map "y", to: :y
50
+ map "width", to: :width
51
+ map "height", to: :height
52
+ map "labels", to: :labels
53
+ map "layout_options", to: :layout_options
54
+ map "properties", to: :properties
55
+ map "side", to: :side
56
+ map "index", to: :index
57
+ map "offset", to: :offset
58
+ end
59
+
60
+ # Validate and set port side
61
+ #
62
+ # @param value [String] The port side (NORTH, SOUTH, EAST, WEST, UNDEFINED)
63
+ # @raise [ArgumentError] If the side value is invalid
64
+ def side=(value)
65
+ return if value.nil?
66
+
67
+ normalized = value.to_s.upcase
68
+ unless SIDES.include?(normalized)
69
+ raise ArgumentError,
70
+ "Invalid port side: #{value}. Must be one of #{SIDES.join(', ')}"
71
+ end
72
+ @side = normalized
73
+ end
74
+
75
+ # Detect port side from position relative to node
76
+ #
77
+ # This method analyzes the port's position (x, y) relative to the node's
78
+ # dimensions to determine which side of the node the port is closest to.
79
+ #
80
+ # @param node_width [Float] Width of the parent node
81
+ # @param node_height [Float] Height of the parent node
82
+ # @return [String] The detected side (NORTH, SOUTH, EAST, WEST, UNDEFINED)
83
+ def detect_side(node_width, node_height)
84
+ return UNDEFINED if x.nil? || y.nil? || node_width.nil? || node_height.nil?
85
+ return UNDEFINED if node_width <= 0 || node_height <= 0
86
+
87
+ # Calculate relative position (0.0 to 1.0)
88
+ rel_x = x / node_width.to_f
89
+ rel_y = y / node_height.to_f
90
+
91
+ # Calculate distance to each side
92
+ distances = {
93
+ NORTH => rel_y, # Distance from top
94
+ SOUTH => 1.0 - rel_y, # Distance from bottom
95
+ WEST => rel_x, # Distance from left
96
+ EAST => 1.0 - rel_x, # Distance from right
97
+ }
98
+
99
+ # Return the side with minimum distance
100
+ distances.min_by { |_, dist| dist }.first
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ # Wrapper for optional Graphviz integration
5
+ # Provides graceful degradation when Graphviz is not installed
6
+ class GraphvizWrapper
7
+ class GraphvizNotFoundError < StandardError; end
8
+
9
+ SUPPORTED_FORMATS = %i[png svg pdf ps eps].freeze
10
+ SUPPORTED_ENGINES = %w[dot neato fdp sfdp twopi circo].freeze
11
+
12
+ def initialize
13
+ @dot_path = find_graphviz
14
+ end
15
+
16
+ def available?
17
+ !@dot_path.nil?
18
+ end
19
+
20
+ def render(dot_file, output_file, format, options = {})
21
+ raise GraphvizNotFoundError, installation_message unless available?
22
+
23
+ validate_format!(format)
24
+ validate_file_exists!(dot_file)
25
+
26
+ engine = options[:engine] || "dot"
27
+ validate_engine!(engine)
28
+
29
+ dpi = options[:dpi] || 96
30
+
31
+ cmd = build_command(engine, format, dot_file, output_file, dpi)
32
+ execute_command(cmd)
33
+ end
34
+
35
+ def version
36
+ return nil unless available?
37
+
38
+ output = `#{@dot_path} -V 2>&1`
39
+ output.match(/version\s+([\d.]+)/i)&.captures&.first
40
+ end
41
+
42
+ def supported_formats
43
+ SUPPORTED_FORMATS
44
+ end
45
+
46
+ def supported_engines
47
+ SUPPORTED_ENGINES
48
+ end
49
+
50
+ private
51
+
52
+ def find_graphviz
53
+ # Try common locations
54
+ candidates = [
55
+ "dot",
56
+ "/usr/bin/dot",
57
+ "/usr/local/bin/dot",
58
+ "/opt/homebrew/bin/dot",
59
+ "/opt/local/bin/dot",
60
+ ]
61
+
62
+ candidates.each do |path|
63
+ if File.executable?(path)
64
+ return path
65
+ elsif system("which #{path} > /dev/null 2>&1")
66
+ return path
67
+ end
68
+ end
69
+
70
+ nil
71
+ end
72
+
73
+ def build_command(engine, format, input_file, output_file, dpi)
74
+ cmd_parts = [
75
+ @dot_path,
76
+ "-K#{engine}",
77
+ "-T#{format}",
78
+ "-Gdpi=#{dpi}",
79
+ ]
80
+
81
+ cmd_parts << "-o#{output_file}" if output_file
82
+ cmd_parts << input_file
83
+
84
+ cmd_parts.join(" ")
85
+ end
86
+
87
+ def execute_command(cmd)
88
+ success = system(cmd)
89
+ unless success
90
+ raise GraphvizNotFoundError,
91
+ "Graphviz command failed: #{cmd}"
92
+ end
93
+
94
+ success
95
+ end
96
+
97
+ def validate_format!(format)
98
+ format_sym = format.to_sym
99
+ return if SUPPORTED_FORMATS.include?(format_sym)
100
+
101
+ raise ArgumentError, "Unsupported format: #{format}. " \
102
+ "Supported formats: #{SUPPORTED_FORMATS.join(', ')}"
103
+ end
104
+
105
+ def validate_engine!(engine)
106
+ return if SUPPORTED_ENGINES.include?(engine.to_s)
107
+
108
+ raise ArgumentError, "Unsupported engine: #{engine}. " \
109
+ "Supported engines: #{SUPPORTED_ENGINES.join(', ')}"
110
+ end
111
+
112
+ def validate_file_exists!(file)
113
+ return if File.exist?(file)
114
+
115
+ raise ArgumentError, "Input file not found: #{file}"
116
+ end
117
+
118
+ def installation_message
119
+ <<~MSG
120
+ Graphviz is required but not found.
121
+
122
+ Installation instructions:
123
+ macOS: brew install graphviz
124
+ Ubuntu: sudo apt-get install graphviz
125
+ Fedora: sudo dnf install graphviz
126
+ Windows: https://graphviz.org/download/
127
+
128
+ Alternatively, export to DOT format and render manually:
129
+ elkrb diagram input.json -o output.dot
130
+ MSG
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ class AlgorithmRegistry
6
+ @algorithms = {}
7
+ @metadata = {}
8
+
9
+ class << self
10
+ def register(name, algorithm_class, metadata = {})
11
+ name_str = name.to_s
12
+ @algorithms[name_str] = algorithm_class
13
+ @metadata[name_str] = metadata
14
+ end
15
+
16
+ def get(name)
17
+ algorithm_name = normalize_name(name)
18
+ @algorithms[algorithm_name]
19
+ end
20
+
21
+ def available_algorithms
22
+ @algorithms.keys.sort
23
+ end
24
+
25
+ def algorithm_info(name)
26
+ algorithm_class = get(name)
27
+ return nil unless algorithm_class
28
+
29
+ name_str = normalize_name(name)
30
+ metadata = @metadata[name_str] || {}
31
+
32
+ {
33
+ id: name_str,
34
+ name: metadata[:name] || name_str.capitalize,
35
+ description: metadata[:description] || "",
36
+ category: metadata[:category] || "general",
37
+ supports_hierarchy: metadata[:supports_hierarchy] || false,
38
+ }
39
+ end
40
+
41
+ def all_algorithm_info
42
+ available_algorithms.map { |name| algorithm_info(name) }
43
+ end
44
+
45
+ private
46
+
47
+ def normalize_name(name)
48
+ # Support both full names and short names
49
+ # e.g., "org.eclipse.elk.layered" -> "layered"
50
+ name = name.to_s
51
+ name = name.split(".").last if name.include?(".")
52
+ name.downcase
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../edge_router"
4
+ require_relative "../hierarchical_processor"
5
+ require_relative "../label_placer"
6
+ require_relative "../port_constraint_processor"
7
+ require_relative "../constraints/constraint_processor"
8
+
9
+ module Elkrb
10
+ module Layout
11
+ module Algorithms
12
+ # Base class for all layout algorithms
13
+ #
14
+ # Layout algorithms are responsible for computing positions for nodes
15
+ # and routing paths for edges in a graph. Each algorithm implements
16
+ # a specific layout strategy (e.g., hierarchical, force-directed, etc.)
17
+ class BaseAlgorithm
18
+ include EdgeRouter
19
+ include HierarchicalProcessor
20
+ include LabelPlacer
21
+ include PortConstraintProcessor
22
+
23
+ attr_reader :options
24
+
25
+ def initialize(options = {})
26
+ @options = options
27
+ end
28
+
29
+ # Main layout method - automatically handles hierarchical graphs and labels
30
+ #
31
+ # Subclasses should implement #layout_flat for their specific
32
+ # algorithm logic. This method will automatically handle hierarchical
33
+ # graphs by calling layout_hierarchical when needed, and place labels
34
+ # after layout is complete.
35
+ #
36
+ # @param graph [Elkrb::Graph::Graph] The graph to layout
37
+ # @return [Elkrb::Graph::Graph] The graph with updated positions
38
+ def layout(graph)
39
+ # Apply port constraints before layout
40
+ apply_port_constraints(graph)
41
+
42
+ # Apply pre-layout constraints (marks nodes)
43
+ apply_pre_layout_constraints(graph)
44
+
45
+ # Perform layout
46
+ if option("hierarchical", false) || graph.hierarchical?
47
+ layout_hierarchical(graph, @options)
48
+ else
49
+ layout_flat(graph, @options)
50
+ end
51
+
52
+ # Enforce post-layout constraints (adjust positions)
53
+ enforce_post_layout_constraints(graph)
54
+
55
+ # Apply edge routing
56
+ apply_edge_routing(graph)
57
+
58
+ # Place labels after layout (unless disabled)
59
+ unless option("label.placement.disabled", false)
60
+ place_labels(graph)
61
+ end
62
+
63
+ graph
64
+ end
65
+
66
+ # Layout a flat (non-hierarchical) graph
67
+ #
68
+ # This method must be implemented by subclasses with their specific
69
+ # layout algorithm logic.
70
+ #
71
+ # @param graph [Elkrb::Graph::Graph] The graph to layout
72
+ # @param options [Hash] Layout options
73
+ # @return [Elkrb::Graph::Graph] The graph with updated positions
74
+ def layout_flat(graph, options = {})
75
+ raise NotImplementedError,
76
+ "#{self.class.name} must implement #layout_flat method"
77
+ end
78
+
79
+ protected
80
+
81
+ # Get an option value with a default fallback
82
+ #
83
+ # @param key [String, Symbol] The option key
84
+ # @param default [Object] The default value if option is not set
85
+ # @return [Object] The option value or default
86
+ def option(key, default = nil)
87
+ key_str = key.to_s
88
+ @options[key_str] || @options[key.to_sym] || default
89
+ end
90
+
91
+ # Get spacing between nodes
92
+ #
93
+ # @return [Float] The node spacing value
94
+ def node_spacing
95
+ option("spacing_node_node", 20.0).to_f
96
+ end
97
+
98
+ # Get padding values
99
+ #
100
+ # @return [Hash] Padding values for top, bottom, left, right
101
+ def padding
102
+ default_padding = { top: 12, bottom: 12, left: 12, right: 12 }
103
+ padding_opt = option("padding", default_padding)
104
+
105
+ if padding_opt.is_a?(Hash)
106
+ default_padding.merge(padding_opt)
107
+ else
108
+ default_padding
109
+ end
110
+ end
111
+
112
+ # Calculate the bounding box for a set of nodes
113
+ #
114
+ # @param nodes [Array<Elkrb::Graph::Node>] The nodes
115
+ # @return [Elkrb::Geometry::Rectangle] The bounding rectangle
116
+ def calculate_bounding_box(nodes)
117
+ return Elkrb::Geometry::Rectangle.new(0, 0, 0, 0) if nodes.empty?
118
+
119
+ min_x = nodes.map(&:x).min
120
+ min_y = nodes.map(&:y).min
121
+ max_x = nodes.map { |n| n.x + n.width }.max
122
+ max_y = nodes.map { |n| n.y + n.height }.max
123
+
124
+ Elkrb::Geometry::Rectangle.new(
125
+ min_x,
126
+ min_y,
127
+ max_x - min_x,
128
+ max_y - min_y,
129
+ )
130
+ end
131
+
132
+ # Apply padding to graph dimensions
133
+ #
134
+ # @param graph [Elkrb::Graph::Graph] The graph
135
+ def apply_padding(graph)
136
+ return if graph.children.nil? || graph.children.empty?
137
+
138
+ pad = padding
139
+ bbox = calculate_bounding_box(graph.children)
140
+
141
+ # Shift all nodes by padding
142
+ graph.children.each do |node|
143
+ node.x = node.x - bbox.x + pad[:left]
144
+ node.y = node.y - bbox.y + pad[:top]
145
+ end
146
+
147
+ # Set graph dimensions
148
+ graph.width = bbox.width + pad[:left] + pad[:right]
149
+ graph.height = bbox.height + pad[:top] + pad[:bottom]
150
+ end
151
+
152
+ # Apply edge routing based on routing style option
153
+ #
154
+ # @param graph [Elkrb::Graph::Graph] The graph
155
+ def apply_edge_routing(graph)
156
+ routing_style = get_edge_routing_style(graph)
157
+ route_edges(graph, nil, routing_style)
158
+ end
159
+
160
+ # Get edge routing style from graph options
161
+ #
162
+ # @param graph [Elkrb::Graph::Graph] The graph
163
+ # @return [String] Routing style (ORTHOGONAL, POLYLINE, SPLINES)
164
+ def get_edge_routing_style(graph)
165
+ return "ORTHOGONAL" unless graph.layout_options
166
+
167
+ style = graph.layout_options["elk.edgeRouting"] ||
168
+ graph.layout_options["edgeRouting"] ||
169
+ graph.layout_options.edge_routing ||
170
+ option("elk.edgeRouting") ||
171
+ option("edgeRouting")
172
+
173
+ style ? style.to_s.upcase : "ORTHOGONAL"
174
+ end
175
+
176
+ # Apply pre-layout constraints
177
+ #
178
+ # These constraints mark nodes for special algorithm handling.
179
+ #
180
+ # @param graph [Elkrb::Graph::Graph] The graph
181
+ def apply_pre_layout_constraints(graph)
182
+ processor = Constraints::ConstraintProcessor.new
183
+ processor.apply_pre_layout(graph)
184
+ end
185
+
186
+ # Enforce post-layout constraints
187
+ #
188
+ # These constraints adjust positions after layout algorithm runs.
189
+ #
190
+ # @param graph [Elkrb::Graph::Graph] The graph
191
+ def enforce_post_layout_constraints(graph)
192
+ processor = Constraints::ConstraintProcessor.new
193
+ processor.enforce_post_layout(graph)
194
+
195
+ # Validate all constraints
196
+ errors = processor.validate_all(graph)
197
+
198
+ return if errors.empty?
199
+
200
+ # Log warnings for constraint violations
201
+ errors.each do |error|
202
+ warn "Layout constraint violation: #{error}"
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_algorithm"
4
+
5
+ module Elkrb
6
+ module Layout
7
+ module Algorithms
8
+ # Box layout algorithm
9
+ #
10
+ # Arranges nodes in a simple box/grid pattern. Nodes are placed
11
+ # in rows from left to right, top to bottom, with uniform spacing.
12
+ # Useful for simple diagrams and quick visualization.
13
+ class Box < BaseAlgorithm
14
+ def layout_flat(graph, _options = {})
15
+ return graph if graph.children.nil? || graph.children.empty?
16
+
17
+ # Get configuration
18
+ aspect_ratio = option("aspect_ratio", 1.6).to_f
19
+ spacing = node_spacing
20
+
21
+ # Calculate number of columns based on aspect ratio
22
+ num_nodes = graph.children.length
23
+ cols = Math.sqrt(num_nodes * aspect_ratio).ceil
24
+ cols = [cols, 1].max
25
+
26
+ # Find maximum node dimensions for uniform grid
27
+ max_width = graph.children.map(&:width).max
28
+ max_height = graph.children.map(&:height).max
29
+
30
+ # Position nodes in grid
31
+ graph.children.each_with_index do |node, i|
32
+ row = i / cols
33
+ col = i % cols
34
+
35
+ node.x = col * (max_width + spacing)
36
+ node.y = row * (max_height + spacing)
37
+ end
38
+
39
+ # Apply padding and set graph dimensions
40
+ apply_padding(graph)
41
+
42
+ graph
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end