elkrb 1.0.2 → 2.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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.adoc +364 -0
  3. data/LICENSE +25 -0
  4. data/README.adoc +695 -557
  5. data/exe/elkrb +36 -2
  6. data/lib/elkrb/atomic_destination.rb +53 -0
  7. data/lib/elkrb/best_effort_write.rb +49 -0
  8. data/lib/elkrb/cli/layout_flag_options.rb +54 -0
  9. data/lib/elkrb/cli.rb +155 -82
  10. data/lib/elkrb/command_resolver.rb +186 -0
  11. data/lib/elkrb/commands/batch_command.rb +28 -5
  12. data/lib/elkrb/commands/convert_command.rb +22 -52
  13. data/lib/elkrb/commands/diagram_command.rb +234 -102
  14. data/lib/elkrb/commands/options_command.rb +87 -0
  15. data/lib/elkrb/commands/render_command.rb +6 -1
  16. data/lib/elkrb/commands/validate_command.rb +146 -91
  17. data/lib/elkrb/errors.rb +20 -1
  18. data/lib/elkrb/format_sniffer.rb +362 -0
  19. data/lib/elkrb/geometry/bezier.rb +14 -4
  20. data/lib/elkrb/geometry/point.rb +5 -6
  21. data/lib/elkrb/geometry/rectangle.rb +2 -2
  22. data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
  23. data/lib/elkrb/graph/edge.rb +89 -3
  24. data/lib/elkrb/graph/graph.rb +43 -15
  25. data/lib/elkrb/graph/label.rb +16 -2
  26. data/lib/elkrb/graph/node.rb +13 -2
  27. data/lib/elkrb/graph/node_constraints.rb +139 -8
  28. data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
  29. data/lib/elkrb/graph/port.rb +29 -21
  30. data/lib/elkrb/graph/read_only_mapping.rb +21 -0
  31. data/lib/elkrb/graphviz_wrapper.rb +152 -44
  32. data/lib/elkrb/layout/algorithm_registry.rb +65 -8
  33. data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
  34. data/lib/elkrb/layout/algorithms/box.rb +47 -20
  35. data/lib/elkrb/layout/algorithms/disco.rb +150 -61
  36. data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
  37. data/lib/elkrb/layout/algorithms/force.rb +145 -86
  38. data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
  39. data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
  40. data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
  41. data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
  42. data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
  43. data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
  44. data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
  45. data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
  46. data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
  47. data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
  48. data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
  49. data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
  50. data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
  51. data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
  52. data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
  53. data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
  54. data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
  55. data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
  56. data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
  57. data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
  58. data/lib/elkrb/layout/algorithms/layered.rb +548 -13
  59. data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
  60. data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
  61. data/lib/elkrb/layout/algorithms/radial.rb +188 -29
  62. data/lib/elkrb/layout/algorithms/random.rb +131 -17
  63. data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
  64. data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
  65. data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
  66. data/lib/elkrb/layout/algorithms/stress.rb +391 -84
  67. data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
  68. data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
  69. data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
  70. data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
  71. data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
  72. data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
  73. data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
  74. data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
  75. data/lib/elkrb/layout/edge_router.rb +533 -337
  76. data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
  77. data/lib/elkrb/layout/java_random.rb +59 -0
  78. data/lib/elkrb/layout/label_placer.rb +165 -157
  79. data/lib/elkrb/layout/layout_engine.rb +46 -23
  80. data/lib/elkrb/layout/node_index.rb +149 -0
  81. data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
  82. data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
  83. data/lib/elkrb/layout/polyomino/grid.rb +106 -0
  84. data/lib/elkrb/layout/polyomino/packer.rb +95 -0
  85. data/lib/elkrb/layout/polyomino/piece.rb +52 -0
  86. data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
  87. data/lib/elkrb/layout/spore/body.rb +65 -0
  88. data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
  89. data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
  90. data/lib/elkrb/layout/spore/gap.rb +76 -0
  91. data/lib/elkrb/layout/spore/geometry.rb +104 -0
  92. data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
  93. data/lib/elkrb/layout/spore/placement.rb +57 -0
  94. data/lib/elkrb/layout/spore/rays.rb +119 -0
  95. data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
  96. data/lib/elkrb/layout/spore/triangulation.rb +159 -0
  97. data/lib/elkrb/layout_flags.rb +69 -0
  98. data/lib/elkrb/options/decimal.rb +50 -0
  99. data/lib/elkrb/options/element_options.rb +43 -0
  100. data/lib/elkrb/options/elk_padding.rb +20 -14
  101. data/lib/elkrb/options/k_vector.rb +6 -7
  102. data/lib/elkrb/options/k_vector_chain.rb +12 -20
  103. data/lib/elkrb/options/option_map.rb +85 -0
  104. data/lib/elkrb/options/registry.rb +335 -0
  105. data/lib/elkrb/options/resolver.rb +151 -0
  106. data/lib/elkrb/options/spellings.rb +62 -0
  107. data/lib/elkrb/options/unhonoured_report.rb +194 -0
  108. data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
  109. data/lib/elkrb/parsers/elkt/parser.rb +424 -0
  110. data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
  111. data/lib/elkrb/parsers/elkt/token.rb +40 -0
  112. data/lib/elkrb/parsers/elkt_parser.rb +32 -230
  113. data/lib/elkrb/serializers/dot_serializer.rb +342 -54
  114. data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
  115. data/lib/elkrb/version.rb +1 -1
  116. data/lib/elkrb.rb +60 -61
  117. metadata +71 -49
  118. data/.rspec +0 -3
  119. data/.rubocop.yml +0 -11
  120. data/Gemfile +0 -13
  121. data/Rakefile +0 -64
  122. data/benchmarks/README.md +0 -172
  123. data/benchmarks/elkjs_benchmark.js +0 -140
  124. data/benchmarks/elkrb_benchmark.rb +0 -145
  125. data/benchmarks/fixtures/graphs.json +0 -10777
  126. data/benchmarks/generate_report.rb +0 -241
  127. data/benchmarks/generate_test_graphs.rb +0 -154
  128. data/benchmarks/results/elkrb_results.json +0 -280
  129. data/benchmarks/results/elkrb_summary.json +0 -285
  130. data/elkrb.gemspec +0 -39
  131. data/examples/dot_export_demo.rb +0 -133
  132. data/examples/hierarchical_graph.rb +0 -19
  133. data/examples/layout_constraints_demo.rb +0 -272
  134. data/examples/port_constraints_demo.rb +0 -291
  135. data/examples/self_loop_demo.rb +0 -391
  136. data/examples/simple_graph.rb +0 -50
  137. data/examples/spline_routing_demo.rb +0 -235
  138. data/lib/elkrb/graph/layout_options.rb +0 -247
  139. data/sig/elkrb/constraints.rbs +0 -114
  140. data/sig/elkrb/geometry.rbs +0 -61
  141. data/sig/elkrb/graph.rbs +0 -112
  142. data/sig/elkrb/layout.rbs +0 -107
  143. data/sig/elkrb/options.rbs +0 -81
  144. data/sig/elkrb.rbs +0 -32
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../options/resolver"
4
+
3
5
  module Elkrb
4
6
  module Serializers
5
7
  # Serializes ELK graphs to Graphviz DOT format
@@ -23,6 +25,9 @@ module Elkrb
23
25
  # rankdir: "TB"
24
26
  # )
25
27
  class DotSerializer
28
+ HtmlValue = Data.define(:text)
29
+ private_constant :HtmlValue
30
+
26
31
  # Default indentation width for DOT output
27
32
  INDENT_WIDTH = 2
28
33
 
@@ -43,14 +48,15 @@ module Elkrb
43
48
  graph_attrs: {},
44
49
  node_attrs: {},
45
50
  edge_attrs: {},
51
+ engine: nil,
46
52
  }.merge(options)
47
53
 
48
54
  @indent_level = 0
49
- @node_counter = 0
50
- @cluster_counter = 0
51
55
 
52
56
  # Convert hash to Graph if needed
53
57
  @graph = graph.is_a?(Hash) ? hash_to_graph(graph) : graph
58
+ index_endpoints(@graph)
59
+ @scope_stack = [@endpoint_scopes.fetch(@graph)]
54
60
 
55
61
  lines = []
56
62
  lines << graph_declaration
@@ -97,20 +103,20 @@ module Elkrb
97
103
  # Generate graph declaration
98
104
  def graph_declaration
99
105
  type = @options[:directed] ? "digraph" : "graph"
100
- "#{type} #{@options[:graph_name]}"
106
+ "#{type} #{quote_id(@options[:graph_name])}"
101
107
  end
102
108
 
103
109
  # Format graph-level attributes
104
110
  def format_graph_attributes(graph)
105
111
  attrs = @options[:graph_attrs].dup
112
+ attrs[:compound] = true if @clusters.any?
106
113
 
107
114
  # Add rankdir from options or graph layout options
108
115
  if @options[:rankdir]
109
116
  attrs[:rankdir] = @options[:rankdir]
110
- elsif graph.layout_options&.direction
111
- attrs[:rankdir] = elk_direction_to_rankdir(
112
- graph.layout_options.direction,
113
- )
117
+ elsif (direction = option_resolver.get("elk.direction", graph,
118
+ default: nil))
119
+ attrs[:rankdir] = elk_direction_to_rankdir(direction)
114
120
  end
115
121
 
116
122
  # Add graph size if specified
@@ -134,48 +140,41 @@ module Elkrb
134
140
  lines.concat(format_subgraph(node))
135
141
  else
136
142
  # Simple node
137
- node_id = sanitize_id(node.id)
143
+ node_id = quote_id(@node_names.fetch(node))
138
144
  attrs = build_node_attributes(node)
139
145
 
140
146
  lines << indent("#{node_id} #{format_attrs(attrs)}")
141
147
  end
142
148
 
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
149
  lines
151
150
  end
152
151
 
153
152
  # Format a subgraph (hierarchical node)
154
153
  def format_subgraph(node)
155
154
  lines = []
156
- cluster_id = "cluster_#{@cluster_counter}"
157
- @cluster_counter += 1
155
+ cluster_id = @clusters.fetch(node)
158
156
 
159
157
  lines << indent("subgraph #{cluster_id} {")
160
158
  @indent_level += 1
161
159
 
162
160
  # Subgraph label
163
161
  if node.labels && !node.labels.empty?
164
- label_text = node.labels.first.text
165
- lines << indent("label=#{quote_value(label_text)}")
162
+ lines << indent("label=#{quote_labels(node.labels.map(&:text))}")
166
163
  end
167
164
 
168
- # Process children
169
- if node.children && !node.children.empty?
170
- node.children.each do |child|
171
- lines.concat(format_node(child, node.id))
165
+ with_endpoint_scope(node) do
166
+ # Process children
167
+ if node.children && !node.children.empty?
168
+ node.children.each do |child|
169
+ lines.concat(format_node(child, node.id))
170
+ end
172
171
  end
173
- end
174
172
 
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))
173
+ # Process edges within this subgraph
174
+ if node.edges && !node.edges.empty?
175
+ node.edges.each do |edge|
176
+ lines.concat(format_edge(edge))
177
+ end
179
178
  end
180
179
  end
181
180
 
@@ -191,10 +190,9 @@ module Elkrb
191
190
 
192
191
  # Label
193
192
  if node.labels && !node.labels.empty?
194
- label_text = node.labels.map(&:text).join("\\n")
195
- attrs[:label] = label_text
193
+ attrs[:label] = node.labels.map(&:text)
196
194
  elsif node.id
197
- attrs[:label] = node.id
195
+ attrs[:label] = [node.id]
198
196
  end
199
197
 
200
198
  # Size (DOT uses inches)
@@ -205,7 +203,7 @@ module Elkrb
205
203
  end
206
204
 
207
205
  # Position (if laid out)
208
- if node.x && node.y
206
+ if neato? && node.x && node.y
209
207
  # DOT uses center coordinates, ELK uses top-left
210
208
  # Also need to account for height since DOT y goes up
211
209
  center_x = node.x + ((node.width || 0) / 2.0)
@@ -221,9 +219,40 @@ module Elkrb
221
219
  attrs[:shape] = node.properties["dot.shape"]
222
220
  end
223
221
 
222
+ apply_port_label(attrs, node) if declared_ports(node).any?
223
+
224
224
  attrs
225
225
  end
226
226
 
227
+ def apply_port_label(attrs, node)
228
+ attrs[:label] = HtmlValue.new(html_port_label(node))
229
+ end
230
+
231
+ def html_port_label(node)
232
+ labels = Array(node.labels).map(&:text)
233
+ labels = [node.id] if labels.empty?
234
+ label = labels.map { |text| html_escape(text) }.join("<BR/>")
235
+ ports = declared_ports(node).map do |port_id, name|
236
+ %(<TD PORT="#{name}" TOOLTIP="#{html_escape(port_id)}"></TD>)
237
+ end.join
238
+ "<<TABLE BORDER=\"0\" CELLBORDER=\"1\" CELLSPACING=\"0\"" \
239
+ "#{html_table_dimensions(node)}>" \
240
+ "<TR><TD>#{label}</TD></TR><TR>#{ports}</TR></TABLE>>"
241
+ end
242
+
243
+ def html_table_dimensions(node)
244
+ return "" unless node.width&.positive? && node.height&.positive?
245
+
246
+ width = node.width.ceil
247
+ height = node.height.ceil
248
+ %( WIDTH="#{width}" HEIGHT="#{height}")
249
+ end
250
+
251
+ def html_escape(value)
252
+ value.to_s.gsub("&", "&amp;").gsub("<", "&lt;")
253
+ .gsub(">", "&gt;").gsub('"', "&quot;")
254
+ end
255
+
227
256
  # Format an edge
228
257
  def format_edge(edge)
229
258
  lines = []
@@ -232,11 +261,13 @@ module Elkrb
232
261
  !edge.targets || edge.targets.empty?
233
262
 
234
263
  # Get source and target
235
- source_id = sanitize_id(edge.sources.first)
236
- target_id = sanitize_id(edge.targets.first)
237
-
238
264
  # Build edge attributes
239
265
  attrs = build_edge_attributes(edge)
266
+ source = resolved_endpoint(edge.sources.first)
267
+ target = resolved_endpoint(edge.targets.first)
268
+ source_id = edge_endpoint(source)
269
+ target_id = edge_endpoint(target)
270
+ add_cluster_attributes(attrs, source, target)
240
271
 
241
272
  # Edge operator
242
273
  op = @options[:directed] ? "->" : "--"
@@ -252,12 +283,11 @@ module Elkrb
252
283
 
253
284
  # Label
254
285
  if edge.labels && !edge.labels.empty?
255
- label_text = edge.labels.map(&:text).join("\\n")
256
- attrs[:label] = label_text
286
+ attrs[:label] = edge.labels.map(&:text)
257
287
  end
258
288
 
259
289
  # Edge routing points
260
- if edge.sections && !edge.sections.empty?
290
+ if neato? && edge.sections && !edge.sections.empty?
261
291
  section = edge.sections.first
262
292
  points = []
263
293
 
@@ -282,7 +312,12 @@ module Elkrb
282
312
  return "" if attrs.empty?
283
313
 
284
314
  attr_strs = attrs.map do |key, value|
285
- "#{key}=#{quote_value(value)}"
315
+ rendered = if key == :label && value.is_a?(Array)
316
+ quote_labels(value)
317
+ else
318
+ quote_value(value)
319
+ end
320
+ "#{key}=#{rendered}"
286
321
  end
287
322
 
288
323
  "[#{attr_strs.join(', ')}]"
@@ -290,28 +325,281 @@ module Elkrb
290
325
 
291
326
  # Quote a value appropriately for DOT
292
327
  def quote_value(value)
328
+ return value.text if value.is_a?(HtmlValue)
329
+
293
330
  value_str = value.to_s
294
331
 
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
332
+ return value_str if bare_id?(value_str)
333
+
334
+ quote_string(value_str)
335
+ end
336
+
337
+ def quote_id(id)
338
+ text = id.to_s
339
+ bare_id?(text) ? text : quote_string(text)
340
+ end
341
+
342
+ def bare_id?(text)
343
+ identifier = text.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
344
+ numeral = text.match?(/\A-?(?:\.\d+|\d+(?:\.\d*)?)\z/)
345
+ (identifier || numeral) && !dot_keyword?(text)
346
+ end
347
+
348
+ def dot_keyword?(text)
349
+ %w[node edge graph digraph subgraph strict].include?(text.downcase)
350
+ end
351
+
352
+ def quote_string(text)
353
+ escaped = text.gsub(/[\\"]/) do |char|
354
+ char == "\\" ? "\\\\" : '\\"'
355
+ end
356
+ "\"#{escaped}\""
357
+ end
358
+
359
+ def quote_labels(labels)
360
+ escaped = labels.map do |label|
361
+ label.to_s.gsub(/[\\"]/) do |char|
362
+ char == "\\" ? "\\\\" : '\\"'
363
+ end
302
364
  end
365
+ "\"#{escaped.join('\\n')}\""
366
+ end
367
+
368
+ def resolved_endpoint(id)
369
+ endpoint_entry(id.to_s) || { type: :node, id: id.to_s, clusters: [] }
303
370
  end
304
371
 
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_]/, "_")
372
+ def edge_endpoint(endpoint)
373
+ if endpoint.fetch(:type) == :port
374
+ return "#{quote_id(endpoint[:owner])}:#{endpoint[:port]}"
375
+ end
310
376
 
311
- # If starts with digit, prepend 'n'
312
- sanitized = "n#{sanitized}" if sanitized.match?(/^[0-9]/)
377
+ if endpoint.fetch(:type) == :compound
378
+ return quote_id(endpoint[:representative])
379
+ end
380
+
381
+ quote_id(endpoint[:id])
382
+ end
383
+
384
+ def add_cluster_attributes(attrs, source, target)
385
+ source_cluster = source[:cluster]
386
+ target_cluster = target[:cluster]
387
+ if source_cluster && !target.fetch(:clusters).include?(source_cluster)
388
+ attrs[:ltail] = source_cluster
389
+ end
390
+ if target_cluster && !source.fetch(:clusters).include?(target_cluster)
391
+ attrs[:lhead] = target_cluster
392
+ end
393
+ end
394
+
395
+ def index_endpoints(graph)
396
+ @endpoint_scopes = {}.compare_by_identity
397
+ @clusters = {}.compare_by_identity
398
+ nodes = descendant_nodes(graph)
399
+ index_node_names(nodes)
400
+ index_clusters(nodes)
401
+ index_cluster_memberships(graph)
402
+ index_all_ports(nodes)
403
+ index_container(graph)
404
+ end
405
+
406
+ def index_cluster_memberships(graph)
407
+ @cluster_memberships = {}.compare_by_identity
408
+ index_child_memberships(Array(graph.children), [])
409
+ end
410
+
411
+ def index_child_memberships(children, clusters)
412
+ children.each do |node|
413
+ own_clusters = node.hierarchical? ? [*clusters, @clusters.fetch(node)] : clusters
414
+ @cluster_memberships[node] = own_clusters
415
+ index_child_memberships(Array(node.children), own_clusters)
416
+ end
417
+ end
418
+
419
+ def index_container(container)
420
+ children = Array(container.children)
421
+ @endpoint_scopes[container] = build_endpoint_scope(children)
422
+ children.each { |node| index_container(node) }
423
+ end
424
+
425
+ def descendant_nodes(container)
426
+ Array(container.children).flat_map do |node|
427
+ [node, *descendant_nodes(node)]
428
+ end
429
+ end
430
+
431
+ def index_node_names(nodes)
432
+ @node_names = {}.compare_by_identity
433
+ reserved = nodes.to_h { |node| [node.id.to_s, true] }
434
+ used = {}
435
+ nodes.each do |node|
436
+ name = node.id.to_s
437
+ name = available_node_name(name, reserved, used) if used.key?(name)
438
+ @node_names[node] = name
439
+ used[name] = true
440
+ end
441
+ end
442
+
443
+ def available_node_name(base, reserved, used)
444
+ index = 2
445
+ loop do
446
+ candidate = "#{base}_#{index}"
447
+ return candidate unless reserved.key?(candidate) || used.key?(candidate)
448
+
449
+ index += 1
450
+ end
451
+ end
452
+
453
+ def index_clusters(nodes)
454
+ nodes.select(&:hierarchical?).each_with_index do |node, index|
455
+ @clusters[node] = "cluster_#{index}"
456
+ end
457
+ end
458
+
459
+ def index_all_ports(nodes)
460
+ @port_names = {}.compare_by_identity
461
+ @declared_ports = {}.compare_by_identity
462
+ ports_by_owner = {}.compare_by_identity
463
+ nodes.each do |node|
464
+ owner = port_representative(node)
465
+ next unless owner
466
+
467
+ Array(node.ports).each do |port|
468
+ (ports_by_owner[owner] ||= []) << [node, port]
469
+ end
470
+ end
471
+ ports_by_owner.each { |owner, entries| index_owner_ports(owner, entries) }
472
+ end
473
+
474
+ def index_owner_ports(owner, entries)
475
+ reserved = entries.filter_map do |_node, port|
476
+ port_id = port.id.to_s
477
+ port_id if bare_id?(port_id)
478
+ end.to_h { |port_id| [port_id, true] }
479
+ used = {}
480
+ @declared_ports[owner] = entries.filter_map.with_index do |(node, port), index|
481
+ names = (@port_names[node] ||= {})
482
+ port_id = port.id.to_s
483
+ next if names.key?(port_id)
484
+
485
+ name = if bare_id?(port_id) && !used.key?(port_id)
486
+ port_id
487
+ else
488
+ available_port_name(index, reserved.merge(used))
489
+ end
490
+ names[port_id] = name
491
+ used[name] = true
492
+ [port.id, name]
493
+ end
494
+ end
495
+
496
+ def available_port_name(index, used)
497
+ loop do
498
+ candidate = "port#{index}"
499
+ return candidate unless used.key?(candidate)
500
+
501
+ index += 1
502
+ end
503
+ end
504
+
505
+ def build_endpoint_scope(children)
506
+ scope = children.each_with_object({}) do |node, entries|
507
+ entries[node.id.to_s] ||= node_endpoint(node)
508
+ end
509
+
510
+ children.each do |node|
511
+ add_ports_to_scope(scope, node)
512
+ end
513
+ add_descendant_scopes(scope, children)
514
+
515
+ scope
516
+ end
517
+
518
+ def add_descendant_scopes(scope, children)
519
+ children.each { |node| add_descendants_to_scope(scope, node) }
520
+ end
521
+
522
+ def add_descendants_to_scope(scope, node)
523
+ Array(node.children).each do |descendant|
524
+ scope[descendant.id.to_s] ||= node_endpoint(descendant)
525
+ add_ports_to_scope(scope, descendant)
526
+ add_descendants_to_scope(scope, descendant)
527
+ end
528
+ end
529
+
530
+ def add_ports_to_scope(scope, node)
531
+ owner = port_representative(node)
532
+ return unless owner
533
+
534
+ Array(node.ports).each do |port|
535
+ scope[port.id.to_s] ||= port_endpoint(node, owner, port)
536
+ end
537
+ end
538
+
539
+ def port_endpoint(node, owner, port)
540
+ {
541
+ type: :port, owner: @node_names.fetch(owner),
542
+ port: @port_names.fetch(node).fetch(port.id.to_s),
543
+ cluster: @clusters[node], clusters: @cluster_memberships.fetch(owner)
544
+ }
545
+ end
546
+
547
+ def node_endpoint(node)
548
+ unless node.hierarchical?
549
+ return {
550
+ type: :node, id: @node_names.fetch(node),
551
+ clusters: @cluster_memberships.fetch(node)
552
+ }
553
+ end
554
+
555
+ representative = representative_node(node)
556
+
557
+ {
558
+ type: :compound, id: @node_names.fetch(node),
559
+ cluster: @clusters.fetch(node),
560
+ representative: @node_names.fetch(representative),
561
+ clusters: @cluster_memberships.fetch(representative)
562
+ }
563
+ end
564
+
565
+ def endpoint_entry(id)
566
+ @scope_stack.reverse_each do |scope|
567
+ return scope[id] if scope.key?(id)
568
+ end
569
+ nil
570
+ end
571
+
572
+ def with_endpoint_scope(container)
573
+ @scope_stack << @endpoint_scopes.fetch(container)
574
+ yield
575
+ ensure
576
+ @scope_stack.pop
577
+ end
578
+
579
+ def port_representative(node)
580
+ node.hierarchical? ? representative_node(node) : node
581
+ end
582
+
583
+ def representative_node(node)
584
+ Array(node.children).each do |child|
585
+ return child unless child.hierarchical?
586
+
587
+ representative = representative_node(child)
588
+ return representative if representative
589
+ end
590
+ nil
591
+ end
592
+
593
+ def declared_ports(node)
594
+ @declared_ports.fetch(node, [])
595
+ end
596
+
597
+ def option_resolver
598
+ @option_resolver ||= Elkrb::Options::Resolver.new
599
+ end
313
600
 
314
- sanitized
601
+ def neato?
602
+ @options[:engine].to_s == "neato"
315
603
  end
316
604
 
317
605
  # Convert ELK direction to DOT rankdir