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,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Parsers
5
+ # Parser for ELKT (ELK Text) format
6
+ # Parses textual graph definitions into ELK graph structures
7
+ class ElktParser
8
+ class ParseError < StandardError; end
9
+
10
+ def self.parse(input)
11
+ new(input).parse
12
+ end
13
+
14
+ def initialize(input)
15
+ @input = input
16
+ @line_number = 0
17
+ @graph = {
18
+ id: "root",
19
+ layoutOptions: {},
20
+ children: [],
21
+ edges: [],
22
+ }
23
+ @node_map = {}
24
+ @current_context = [@graph]
25
+ end
26
+
27
+ def parse
28
+ lines = preprocess(@input)
29
+ parse_lines(lines)
30
+ @graph
31
+ end
32
+
33
+ private
34
+
35
+ def preprocess(input)
36
+ # Remove block comments
37
+ input = input.gsub(%r{/\*.*?\*/}m, "")
38
+
39
+ # Split into lines and remove line comments
40
+ lines = input.split("\n").map do |line|
41
+ line.sub(%r{//.*$}, "").strip
42
+ end
43
+
44
+ # Remove empty lines
45
+ lines.reject(&:empty?)
46
+ end
47
+
48
+ def parse_lines(lines)
49
+ lines.each_with_index do |line, idx|
50
+ @line_number = idx + 1
51
+ parse_line(line)
52
+ end
53
+ end
54
+
55
+ def parse_line(line)
56
+ case line
57
+ when /^algorithm:\s*(.+)$/
58
+ parse_algorithm($1.strip)
59
+ when /^direction:\s*(.+)$/
60
+ parse_direction($1.strip)
61
+ when /^([\w.]+):\s*(.+)$/
62
+ parse_property($1.strip, $2.strip)
63
+ when /^node\s+(\w+)\s*\{/
64
+ parse_node_with_block($1)
65
+ when /^node\s+(\w+)\s*$/
66
+ parse_simple_node($1)
67
+ when /^\}/
68
+ close_block
69
+ when /^edge\s+(.+)$/
70
+ parse_edge($1.strip)
71
+ when /^layout\s*\[\s*(.+?)\s*\]/
72
+ parse_layout_block($1)
73
+ when /^port\s+(\w+)/
74
+ parse_port(line)
75
+ when /^label\s+"([^"]+)"/
76
+ parse_label($1)
77
+ else
78
+ # Ignore unknown lines or handle as needed
79
+ end
80
+ end
81
+
82
+ def parse_algorithm(value)
83
+ current_node[:layoutOptions] ||= {}
84
+ current_node[:layoutOptions]["elk.algorithm"] = value
85
+ end
86
+
87
+ def parse_direction(value)
88
+ current_node[:layoutOptions] ||= {}
89
+ current_node[:layoutOptions]["elk.direction"] = value
90
+ end
91
+
92
+ def parse_property(key, value)
93
+ current_node[:layoutOptions] ||= {}
94
+
95
+ # Convert property name to ELK format
96
+ elk_key = key.start_with?("elk.") ? key : "elk.#{key}"
97
+
98
+ # Parse value
99
+ parsed_value = parse_value(value)
100
+ current_node[:layoutOptions][elk_key] = parsed_value
101
+ end
102
+
103
+ def parse_value(value)
104
+ case value
105
+ when /^-?\d+\.\d+$/
106
+ value.to_f
107
+ when /^-?\d+$/
108
+ value.to_i
109
+ when /^true$/i
110
+ true
111
+ when /^false$/i
112
+ false
113
+ else
114
+ value
115
+ end
116
+ end
117
+
118
+ def parse_simple_node(node_id)
119
+ node = create_node(node_id)
120
+ current_node[:children] ||= []
121
+ current_node[:children] << node
122
+ @node_map[node_id] = node
123
+ end
124
+
125
+ def parse_node_with_block(node_id)
126
+ node = create_node(node_id)
127
+ current_node[:children] ||= []
128
+ current_node[:children] << node
129
+ @node_map[node_id] = node
130
+ @current_context.push(node)
131
+ end
132
+
133
+ def create_node(node_id)
134
+ {
135
+ id: node_id,
136
+ width: 40,
137
+ height: 40,
138
+ layoutOptions: {},
139
+ }
140
+ end
141
+
142
+ def parse_layout_block(content)
143
+ # Parse layout [ size: 30, 30 ] or similar
144
+ if content =~ /size:\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)/
145
+ width = $1.to_f
146
+ height = $2.to_f
147
+ current_node[:width] = width
148
+ current_node[:height] = height
149
+ elsif content =~ /position:\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)/
150
+ x = $1.to_f
151
+ y = $2.to_f
152
+ current_node[:x] = x
153
+ current_node[:y] = y
154
+ end
155
+ end
156
+
157
+ def parse_edge(edge_spec)
158
+ # Parse: node1 -> node2
159
+ # Or: edge_id: node1 -> node2
160
+ # Or: node1.port1 -> node2.port2
161
+
162
+ if edge_spec =~ /^(\w+):\s*(.+)$/
163
+ edge_id = $1
164
+ edge_spec = $2
165
+ else
166
+ edge_id = generate_edge_id
167
+ end
168
+
169
+ unless edge_spec =~ /^(.+?)\s*->\s*(.+)$/
170
+ raise ParseError,
171
+ "Invalid edge syntax at line #{@line_number}: #{edge_spec}"
172
+ end
173
+
174
+ source_spec = $1.strip
175
+ target_spec = $2.strip
176
+
177
+ edge = create_edge(edge_id, source_spec, target_spec)
178
+ current_node[:edges] ||= []
179
+ current_node[:edges] << edge
180
+ end
181
+
182
+ def create_edge(edge_id, source_spec, target_spec)
183
+ source_parts = source_spec.split(".")
184
+ target_parts = target_spec.split(".")
185
+
186
+ edge = {
187
+ id: edge_id,
188
+ sources: [source_parts[0]],
189
+ targets: [target_parts[0]],
190
+ }
191
+
192
+ # Add port references if present
193
+ if source_parts.length > 1
194
+ edge[:sourcePort] = source_parts[1]
195
+ end
196
+
197
+ if target_parts.length > 1
198
+ edge[:targetPort] = target_parts[1]
199
+ end
200
+
201
+ edge
202
+ end
203
+
204
+ def parse_port(line)
205
+ # Parse: port port_id { ... }
206
+ if line =~ /^port\s+(\w+)\s*\{/
207
+ port_id = $1
208
+ port = {
209
+ id: port_id,
210
+ layoutOptions: {},
211
+ }
212
+ current_node[:ports] ||= []
213
+ current_node[:ports] << port
214
+ @current_context.push(port)
215
+ elsif line =~ /^port\s+(\w+)\s*$/
216
+ port_id = $1
217
+ port = {
218
+ id: port_id,
219
+ }
220
+ current_node[:ports] ||= []
221
+ current_node[:ports] << port
222
+ end
223
+ end
224
+
225
+ def parse_label(text)
226
+ label = {
227
+ text: text,
228
+ width: text.length * 7.0,
229
+ height: 14.0,
230
+ }
231
+ current_node[:labels] ||= []
232
+ current_node[:labels] << label
233
+ end
234
+
235
+ def close_block
236
+ @current_context.pop if @current_context.length > 1
237
+ end
238
+
239
+ def current_node
240
+ @current_context.last
241
+ end
242
+
243
+ def generate_edge_id
244
+ "e#{current_node[:edges]&.length || 0}"
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Serializers
5
+ # Serializes ELK graphs to Graphviz DOT format
6
+ #
7
+ # This serializer converts ELK graph structures into DOT format strings
8
+ # that can be rendered by Graphviz. It supports:
9
+ # - Node and edge declarations with attributes
10
+ # - Hierarchical graphs (subgraphs/clusters)
11
+ # - Labels and ports
12
+ # - Layout direction and other properties
13
+ #
14
+ # @example Basic usage
15
+ # serializer = DotSerializer.new
16
+ # dot_string = serializer.serialize(graph)
17
+ # File.write("output.dot", dot_string)
18
+ #
19
+ # @example With options
20
+ # serializer = DotSerializer.new
21
+ # dot_string = serializer.serialize(graph,
22
+ # directed: true,
23
+ # rankdir: "TB"
24
+ # )
25
+ class DotSerializer
26
+ # Default indentation width for DOT output
27
+ INDENT_WIDTH = 2
28
+
29
+ # @param graph [Elkrb::Graph::Graph] The graph to serialize
30
+ # @param options [Hash] Serialization options
31
+ # @option options [Boolean] :directed (true) Whether graph is directed
32
+ # @option options [String] :rankdir Layout direction (TB, LR, BT, RL)
33
+ # @option options [String] :graph_name Name for the graph
34
+ # @option options [Hash] :graph_attrs Additional graph attributes
35
+ # @option options [Hash] :node_attrs Default node attributes
36
+ # @option options [Hash] :edge_attrs Default edge attributes
37
+ # @return [String] DOT format string
38
+ def serialize(graph, options = {})
39
+ @options = {
40
+ directed: true,
41
+ rankdir: nil,
42
+ graph_name: "G",
43
+ graph_attrs: {},
44
+ node_attrs: {},
45
+ edge_attrs: {},
46
+ }.merge(options)
47
+
48
+ @indent_level = 0
49
+ @node_counter = 0
50
+ @cluster_counter = 0
51
+
52
+ # Convert hash to Graph if needed
53
+ @graph = graph.is_a?(Hash) ? hash_to_graph(graph) : graph
54
+
55
+ lines = []
56
+ lines << graph_declaration
57
+ lines << "{"
58
+
59
+ @indent_level += 1
60
+
61
+ # Graph attributes
62
+ lines.concat(format_graph_attributes(@graph))
63
+
64
+ # Default node and edge attributes
65
+ lines << indent("node #{format_attrs(@options[:node_attrs])}") unless
66
+ @options[:node_attrs].empty?
67
+ lines << indent("edge #{format_attrs(@options[:edge_attrs])}") unless
68
+ @options[:edge_attrs].empty?
69
+
70
+ # Process children (nodes)
71
+ if @graph.children && !@graph.children.empty?
72
+ @graph.children.each do |node|
73
+ lines.concat(format_node(node))
74
+ end
75
+ end
76
+
77
+ # Process edges
78
+ if @graph.edges && !@graph.edges.empty?
79
+ @graph.edges.each do |edge|
80
+ lines.concat(format_edge(edge))
81
+ end
82
+ end
83
+
84
+ @indent_level -= 1
85
+ lines << "}"
86
+
87
+ lines.join("\n")
88
+ end
89
+
90
+ private
91
+
92
+ def hash_to_graph(hash)
93
+ require_relative "../graph/graph"
94
+ Elkrb::Graph::Graph.from_hash(hash)
95
+ end
96
+
97
+ # Generate graph declaration
98
+ def graph_declaration
99
+ type = @options[:directed] ? "digraph" : "graph"
100
+ "#{type} #{@options[:graph_name]}"
101
+ end
102
+
103
+ # Format graph-level attributes
104
+ def format_graph_attributes(graph)
105
+ attrs = @options[:graph_attrs].dup
106
+
107
+ # Add rankdir from options or graph layout options
108
+ if @options[:rankdir]
109
+ attrs[:rankdir] = @options[:rankdir]
110
+ elsif graph.layout_options&.direction
111
+ attrs[:rankdir] = elk_direction_to_rankdir(
112
+ graph.layout_options.direction,
113
+ )
114
+ end
115
+
116
+ # Add graph size if specified
117
+ if graph.width && graph.height && graph.width.positive? && graph.height.positive?
118
+ # Convert to inches (DOT uses inches by default)
119
+ attrs[:size] = "#{graph.width / 72},#{graph.height / 72}"
120
+ end
121
+
122
+ # Output graph attributes
123
+ attrs.map do |key, value|
124
+ indent("#{key}=#{quote_value(value)}")
125
+ end
126
+ end
127
+
128
+ # Format a node with all its properties
129
+ def format_node(node, _parent_id = nil)
130
+ lines = []
131
+
132
+ # Hierarchical node - create a subgraph
133
+ if node.hierarchical?
134
+ lines.concat(format_subgraph(node))
135
+ else
136
+ # Simple node
137
+ node_id = sanitize_id(node.id)
138
+ attrs = build_node_attributes(node)
139
+
140
+ lines << indent("#{node_id} #{format_attrs(attrs)}")
141
+ end
142
+
143
+ # Process child edges if any
144
+ if node.edges && !node.edges.empty?
145
+ node.edges.each do |edge|
146
+ lines.concat(format_edge(edge))
147
+ end
148
+ end
149
+
150
+ lines
151
+ end
152
+
153
+ # Format a subgraph (hierarchical node)
154
+ def format_subgraph(node)
155
+ lines = []
156
+ cluster_id = "cluster_#{@cluster_counter}"
157
+ @cluster_counter += 1
158
+
159
+ lines << indent("subgraph #{cluster_id} {")
160
+ @indent_level += 1
161
+
162
+ # Subgraph label
163
+ if node.labels && !node.labels.empty?
164
+ label_text = node.labels.first.text
165
+ lines << indent("label=#{quote_value(label_text)}")
166
+ end
167
+
168
+ # Process children
169
+ if node.children && !node.children.empty?
170
+ node.children.each do |child|
171
+ lines.concat(format_node(child, node.id))
172
+ end
173
+ end
174
+
175
+ # Process edges within this subgraph
176
+ if node.edges && !node.edges.empty?
177
+ node.edges.each do |edge|
178
+ lines.concat(format_edge(edge))
179
+ end
180
+ end
181
+
182
+ @indent_level -= 1
183
+ lines << indent("}")
184
+
185
+ lines
186
+ end
187
+
188
+ # Build node attribute hash
189
+ def build_node_attributes(node)
190
+ attrs = {}
191
+
192
+ # Label
193
+ if node.labels && !node.labels.empty?
194
+ label_text = node.labels.map(&:text).join("\\n")
195
+ attrs[:label] = label_text
196
+ elsif node.id
197
+ attrs[:label] = node.id
198
+ end
199
+
200
+ # Size (DOT uses inches)
201
+ if node.width && node.height && node.width.positive? && node.height.positive?
202
+ attrs[:width] = (node.width / 72.0).round(2)
203
+ attrs[:height] = (node.height / 72.0).round(2)
204
+ attrs[:fixedsize] = "true"
205
+ end
206
+
207
+ # Position (if laid out)
208
+ if node.x && node.y
209
+ # DOT uses center coordinates, ELK uses top-left
210
+ # Also need to account for height since DOT y goes up
211
+ center_x = node.x + ((node.width || 0) / 2.0)
212
+ center_y = node.y + ((node.height || 0) / 2.0)
213
+ attrs[:pos] = "#{center_x.round(2)},#{center_y.round(2)}!"
214
+ end
215
+
216
+ # Shape
217
+ attrs[:shape] = "box" # Default shape for ELK nodes
218
+
219
+ # Properties
220
+ if node.properties && node.properties["dot.shape"]
221
+ attrs[:shape] = node.properties["dot.shape"]
222
+ end
223
+
224
+ attrs
225
+ end
226
+
227
+ # Format an edge
228
+ def format_edge(edge)
229
+ lines = []
230
+
231
+ return lines if !edge.sources || edge.sources.empty? ||
232
+ !edge.targets || edge.targets.empty?
233
+
234
+ # Get source and target
235
+ source_id = sanitize_id(edge.sources.first)
236
+ target_id = sanitize_id(edge.targets.first)
237
+
238
+ # Build edge attributes
239
+ attrs = build_edge_attributes(edge)
240
+
241
+ # Edge operator
242
+ op = @options[:directed] ? "->" : "--"
243
+
244
+ lines << indent("#{source_id} #{op} #{target_id} #{format_attrs(attrs)}")
245
+
246
+ lines
247
+ end
248
+
249
+ # Build edge attribute hash
250
+ def build_edge_attributes(edge)
251
+ attrs = {}
252
+
253
+ # Label
254
+ if edge.labels && !edge.labels.empty?
255
+ label_text = edge.labels.map(&:text).join("\\n")
256
+ attrs[:label] = label_text
257
+ end
258
+
259
+ # Edge routing points
260
+ if edge.sections && !edge.sections.empty?
261
+ section = edge.sections.first
262
+ points = []
263
+
264
+ points << section.start_point if section.start_point
265
+ points.concat(section.bend_points) if section.bend_points
266
+ points << section.end_point if section.end_point
267
+
268
+ if points.length > 2
269
+ # Build spline path for DOT
270
+ pos_str = points.map do |p|
271
+ "#{p.x.round(2)},#{p.y.round(2)}"
272
+ end.join(" ")
273
+ attrs[:pos] = pos_str
274
+ end
275
+ end
276
+
277
+ attrs
278
+ end
279
+
280
+ # Format attribute hash to DOT syntax
281
+ def format_attrs(attrs)
282
+ return "" if attrs.empty?
283
+
284
+ attr_strs = attrs.map do |key, value|
285
+ "#{key}=#{quote_value(value)}"
286
+ end
287
+
288
+ "[#{attr_strs.join(', ')}]"
289
+ end
290
+
291
+ # Quote a value appropriately for DOT
292
+ def quote_value(value)
293
+ value_str = value.to_s
294
+
295
+ # Check if value needs quoting
296
+ if value_str.match?(/[^a-zA-Z0-9_]/) || value_str.empty?
297
+ # Escape quotes and backslashes
298
+ escaped = value_str.gsub("\\", "\\\\\\\\").gsub('"', '\\"')
299
+ "\"#{escaped}\""
300
+ else
301
+ value_str
302
+ end
303
+ end
304
+
305
+ # Sanitize ID for DOT format
306
+ def sanitize_id(id)
307
+ # DOT IDs can contain letters, digits, underscores
308
+ # If ID contains other characters, quote it
309
+ sanitized = id.to_s.gsub(/[^a-zA-Z0-9_]/, "_")
310
+
311
+ # If starts with digit, prepend 'n'
312
+ sanitized = "n#{sanitized}" if sanitized.match?(/^[0-9]/)
313
+
314
+ sanitized
315
+ end
316
+
317
+ # Convert ELK direction to DOT rankdir
318
+ def elk_direction_to_rankdir(direction)
319
+ case direction.to_s.upcase
320
+ when "DOWN"
321
+ "TB"
322
+ when "UP"
323
+ "BT"
324
+ when "RIGHT"
325
+ "LR"
326
+ when "LEFT"
327
+ "RL"
328
+ else
329
+ "TB" # Default
330
+ end
331
+ end
332
+
333
+ # Add indentation to a line
334
+ def indent(line)
335
+ (" " * (@indent_level * INDENT_WIDTH)) + line
336
+ end
337
+ end
338
+ end
339
+ end