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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +11 -0
- data/Gemfile +13 -0
- data/README.adoc +1028 -0
- data/Rakefile +64 -0
- data/benchmarks/README.md +172 -0
- data/benchmarks/elkjs_benchmark.js +140 -0
- data/benchmarks/elkrb_benchmark.rb +145 -0
- data/benchmarks/fixtures/graphs.json +10777 -0
- data/benchmarks/generate_report.rb +241 -0
- data/benchmarks/generate_test_graphs.rb +154 -0
- data/benchmarks/results/elkrb_results.json +280 -0
- data/benchmarks/results/elkrb_summary.json +285 -0
- data/elkrb.gemspec +39 -0
- data/examples/dot_export_demo.rb +133 -0
- data/examples/hierarchical_graph.rb +19 -0
- data/examples/layout_constraints_demo.rb +272 -0
- data/examples/port_constraints_demo.rb +291 -0
- data/examples/self_loop_demo.rb +391 -0
- data/examples/simple_graph.rb +50 -0
- data/examples/spline_routing_demo.rb +235 -0
- data/exe/elkrb +8 -0
- data/lib/elkrb/cli.rb +224 -0
- data/lib/elkrb/commands/batch_command.rb +66 -0
- data/lib/elkrb/commands/convert_command.rb +130 -0
- data/lib/elkrb/commands/diagram_command.rb +208 -0
- data/lib/elkrb/commands/render_command.rb +52 -0
- data/lib/elkrb/commands/validate_command.rb +241 -0
- data/lib/elkrb/errors.rb +30 -0
- data/lib/elkrb/geometry/bezier.rb +163 -0
- data/lib/elkrb/geometry/dimension.rb +32 -0
- data/lib/elkrb/geometry/point.rb +68 -0
- data/lib/elkrb/geometry/rectangle.rb +86 -0
- data/lib/elkrb/geometry/vector.rb +67 -0
- data/lib/elkrb/graph/edge.rb +95 -0
- data/lib/elkrb/graph/graph.rb +90 -0
- data/lib/elkrb/graph/label.rb +45 -0
- data/lib/elkrb/graph/layout_options.rb +247 -0
- data/lib/elkrb/graph/node.rb +79 -0
- data/lib/elkrb/graph/node_constraints.rb +107 -0
- data/lib/elkrb/graph/port.rb +104 -0
- data/lib/elkrb/graphviz_wrapper.rb +133 -0
- data/lib/elkrb/layout/algorithm_registry.rb +57 -0
- data/lib/elkrb/layout/algorithms/base_algorithm.rb +208 -0
- data/lib/elkrb/layout/algorithms/box.rb +47 -0
- data/lib/elkrb/layout/algorithms/disco.rb +206 -0
- data/lib/elkrb/layout/algorithms/fixed.rb +32 -0
- data/lib/elkrb/layout/algorithms/force.rb +165 -0
- data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +86 -0
- data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +96 -0
- data/lib/elkrb/layout/algorithms/layered/node_placer.rb +77 -0
- data/lib/elkrb/layout/algorithms/layered.rb +49 -0
- data/lib/elkrb/layout/algorithms/libavoid.rb +389 -0
- data/lib/elkrb/layout/algorithms/mrtree.rb +144 -0
- data/lib/elkrb/layout/algorithms/radial.rb +64 -0
- data/lib/elkrb/layout/algorithms/random.rb +43 -0
- data/lib/elkrb/layout/algorithms/rectpacking.rb +93 -0
- data/lib/elkrb/layout/algorithms/spore_compaction.rb +139 -0
- data/lib/elkrb/layout/algorithms/spore_overlap.rb +117 -0
- data/lib/elkrb/layout/algorithms/stress.rb +176 -0
- data/lib/elkrb/layout/algorithms/topdown_packing.rb +183 -0
- data/lib/elkrb/layout/algorithms/vertiflex.rb +174 -0
- data/lib/elkrb/layout/constraints/alignment_constraint.rb +150 -0
- data/lib/elkrb/layout/constraints/base_constraint.rb +72 -0
- data/lib/elkrb/layout/constraints/constraint_processor.rb +134 -0
- data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +87 -0
- data/lib/elkrb/layout/constraints/layer_constraint.rb +71 -0
- data/lib/elkrb/layout/constraints/relative_position_constraint.rb +110 -0
- data/lib/elkrb/layout/edge_router.rb +935 -0
- data/lib/elkrb/layout/hierarchical_processor.rb +299 -0
- data/lib/elkrb/layout/label_placer.rb +338 -0
- data/lib/elkrb/layout/layout_engine.rb +170 -0
- data/lib/elkrb/layout/port_constraint_processor.rb +173 -0
- data/lib/elkrb/options/elk_padding.rb +94 -0
- data/lib/elkrb/options/k_vector.rb +100 -0
- data/lib/elkrb/options/k_vector_chain.rb +135 -0
- data/lib/elkrb/parsers/elkt_parser.rb +248 -0
- data/lib/elkrb/serializers/dot_serializer.rb +339 -0
- data/lib/elkrb/serializers/elkt_serializer.rb +236 -0
- data/lib/elkrb/version.rb +5 -0
- data/lib/elkrb.rb +509 -0
- data/sig/elkrb/constraints.rbs +114 -0
- data/sig/elkrb/geometry.rbs +61 -0
- data/sig/elkrb/graph.rbs +112 -0
- data/sig/elkrb/layout.rbs +107 -0
- data/sig/elkrb/options.rbs +81 -0
- data/sig/elkrb.rbs +32 -0
- 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
|