elkrb 1.0.0 → 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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "set"
4
4
  require_relative "base_algorithm"
5
+ require_relative "../node_index"
5
6
 
6
7
  module Elkrb
7
8
  module Layout
@@ -10,27 +11,45 @@ module Elkrb
10
11
  #
11
12
  # Arranges nodes in a tree structure that can handle multiple root nodes.
12
13
  class MRTree < BaseAlgorithm
14
+ # Both readers of the adjacency map ask for every child of the
15
+ # graph, sinks included, so a missing key has to answer something
16
+ # enumerable rather than raise or grow the map.
17
+ NO_CHILDREN = [].freeze
18
+ private_constant :NO_CHILDREN
19
+
13
20
  def layout_flat(graph, _options = {})
14
- return graph if graph.children.empty?
21
+ # `layout_flat` is public and `children` really can be nil -- a
22
+ # deserialized graph that omits the key keeps it nil, and
23
+ # `BaseAlgorithm#layout` only guards its OWN dispatch with
24
+ # `if graph.children`. Every sibling algorithm defends itself the
25
+ # same way (box, fixed, force, layered, libavoid, random); mrtree
26
+ # was the one that did not.
27
+ return graph if graph.children.nil? || graph.children.empty?
28
+
29
+ index = NodeIndex.build(graph)
15
30
 
16
31
  # Identify root nodes (nodes with no incoming edges)
17
- roots = find_root_nodes(graph)
32
+ roots = find_root_nodes(graph, index)
18
33
 
19
34
  # If no roots found, treat all nodes as roots
20
35
  roots = graph.children if roots.empty?
21
36
 
22
- # Build tree structure from roots
23
- trees = roots.map { |root| build_tree(root, graph) }
37
+ trees = build_forest(roots, graph, adjacency(graph, index))
38
+ level_offsets = level_offsets(trees)
24
39
 
25
40
  # Calculate positions for each tree
26
- spacing_val = node_spacing
27
41
  x_offset = 0
42
+ # `layout_tree` returns the width it actually consumed, spacing
43
+ # included. Recomputing that separately is what let two trees collide:
44
+ # `calculate_tree_width` summed leaf widths and omitted the gap
45
+ # between siblings, so a tree was reported narrower than it was drawn.
28
46
  trees.each do |tree|
29
- layout_tree(tree, x_offset, 0)
30
- tree_width = calculate_tree_width(tree)
31
- x_offset += tree_width + spacing_val
47
+ consumed, = layout_tree(tree, x_offset, 0, [], level_offsets)
48
+ x_offset += consumed
32
49
  end
33
50
 
51
+ orient(graph.children)
52
+
34
53
  # Apply padding and set graph dimensions
35
54
  apply_padding(graph)
36
55
 
@@ -39,104 +58,605 @@ module Elkrb
39
58
 
40
59
  private
41
60
 
42
- def find_root_nodes(graph)
61
+ def find_root_nodes(graph, index)
43
62
  nodes_with_incoming = Set.new
44
63
 
45
64
  graph.edges&.each do |edge|
46
- targets = edge.targets || []
47
- targets.each do |target_id|
48
- nodes_with_incoming.add(target_id)
65
+ sources = index.endpoint_nodes(edge.sources)
66
+
67
+ # Count a target as having incoming traffic only when at
68
+ # least one resolved source is a DIFFERENT node: a real
69
+ # self-loop ("p1" -> "p2" on one node) and the
70
+ # self-referencing leg of a mixed hyperedge
71
+ # (a -> [a's port, b]) are not incoming traffic from
72
+ # elsewhere, but a target that ALSO appears among the
73
+ # sources (e.g. [a, b] -> b) still has genuine incoming
74
+ # traffic from the other source.
75
+ index.endpoint_nodes(edge.targets).each do |target|
76
+ next unless sources.any? { |source| source != target }
77
+
78
+ nodes_with_incoming.add(target.id)
49
79
  end
50
80
  end
51
81
 
52
82
  graph.children.reject { |node| nodes_with_incoming.include?(node.id) }
53
83
  end
54
84
 
55
- def build_tree(root, graph)
56
- tree = {
57
- node: root,
58
- children: [],
59
- level: 0,
60
- }
85
+ # Child nodes per source id, resolved once for the whole graph.
86
+ # Rebuilding this per node would rescan every edge inside the
87
+ # relaxation loop.
88
+ #
89
+ # Reads `graph.edges`, matching #find_root_nodes, NOT `index.edges`.
90
+ # The index folds in the edges declared on childless top-level nodes,
91
+ # so it is a strict superset: reading it here while root finding reads
92
+ # the narrower set would let a node whose only incoming edge sits on a
93
+ # sibling leaf be a root AND somebody's child at the same time.
94
+ #
95
+ # Ids identify nodes here where #find_root_nodes compares objects. The
96
+ # two agree: NodeIndex keeps ids unique within a level and answers the
97
+ # same object for the same id.
98
+ def adjacency(graph, index)
99
+ map = {}
100
+
101
+ (graph.edges || []).each do |edge|
102
+ targets = index.endpoint_nodes(edge.targets)
103
+
104
+ index.endpoint_nodes(edge.sources).each do |source|
105
+ # A hyperedge can target one of its source's own ports
106
+ # alongside a real child (a -> [a's port, b]). Resolving that
107
+ # port back to the source would make the node its own child,
108
+ # and stepping down it would let the node keep deepening its
109
+ # own level until it hit the relaxation bound.
110
+ children = targets.reject { |target| target.id == source.id }
111
+ (map[source.id] ||= []).concat(children)
112
+ end
113
+ end
114
+
115
+ map.each_value { |children| children.uniq!(&:id) }
116
+ # Set AFTER the loop above, not before it. With the default already
117
+ # in place `(map[source.id] ||= [])` reads the frozen constant, finds
118
+ # it truthy, and concats into it — FrozenError on the first edge.
119
+ map.default = NO_CHILDREN
120
+ map
121
+ end
122
+
123
+ # Every child has to end up in some tree. A component that is wholly
124
+ # cyclic (a -> b -> a) contains no root, so it is never reached from
125
+ # the graph's roots and its nodes keep nil coordinates until padding
126
+ # trips over them. Whenever a pass leaves nodes unplaced, seed one of
127
+ # them as a fallback root and keep going.
128
+ def build_forest(roots, graph, adjacent)
129
+ # A node reachable by two paths belongs at the DEEPER one, or it
130
+ # ends up above its own parent. Levels are settled for the whole
131
+ # graph before any tree is built, so placement order stops mattering.
132
+ # That is a DAG guarantee: a cyclic component gets bounded fallback
133
+ # numbers instead, and #build_subtree's floor is what re-orders those.
134
+ levels = {}
135
+ seeds = roots.dup
136
+
137
+ # Relaxation is scoped to ONE component at a time. It used to run
138
+ # graph-wide per seed, and the level bound was the whole graph's node
139
+ # count, so a two-node cycle kept incrementing a <-> b until it hit
140
+ # that bound: O(n) passes over O(n) nodes for every component, which
141
+ # is cubic. Measured on disjoint two-cycles before this change --
142
+ # 160 nodes 0.45s, 320 nodes 3.21s, 480 nodes 10.58s.
143
+ members = components(graph, adjacent)
144
+
145
+ relax_component_of(roots, members, adjacent, levels)
146
+
147
+ # Walk the children once. Rescanning for the first unlevelled node on
148
+ # every iteration was itself quadratic.
149
+ graph.children.each do |node|
150
+ next if levels.key?(node.id)
151
+
152
+ seeds << node
153
+ relax_component_of([node], members, adjacent, levels)
154
+ end
155
+
156
+ # `placed` doubles as the walk's visited set, so a later tree cannot
157
+ # reach back into a node an earlier tree already owns — placing a
158
+ # node twice lets the second placement win and drags it above its own
159
+ # parent. Nothing reads `placed` mid-walk except the walk itself.
160
+ placed = Set.new
161
+ seeds.filter_map do |seed|
162
+ next if placed.include?(seed.id)
163
+
164
+ build_subtree(seed, adjacent, placed, levels, 0)
165
+ end
166
+ end
167
+
168
+ # Undirected connected components, computed once. Returns node id ->
169
+ # the array of nodes sharing its component, so a relaxation can bound
170
+ # itself by its own component's size instead of the whole graph's.
171
+ def components(graph, adjacent)
172
+ neighbours = undirected(graph, adjacent)
173
+
174
+ by_id = {}
175
+ graph.children.each do |node|
176
+ next if by_id.key?(node.id)
177
+
178
+ group = reachable_from(node, neighbours)
179
+ group.each { |member| by_id[member.id] = group }
180
+ end
181
+
182
+ by_id
183
+ end
184
+
185
+ # An edge constrains level in one direction but joins a component in
186
+ # both, so connectivity is walked undirected.
187
+ def undirected(graph, adjacent)
188
+ neighbours = Hash.new { |hash, key| hash[key] = [] }
61
189
 
62
- # Find children (nodes connected by outgoing edges)
63
- children = find_children(root, graph)
64
- tree[:children] = children.map do |child|
65
- build_subtree(child, graph, 1)
190
+ graph.children.each do |node|
191
+ adjacent[node.id].each do |child|
192
+ neighbours[node.id] << child
193
+ neighbours[child.id] << node
194
+ end
195
+ end
196
+
197
+ neighbours
198
+ end
199
+
200
+ def reachable_from(node, neighbours)
201
+ group = []
202
+ stack = [node]
203
+ seen = { node.id => true }
204
+
205
+ until stack.empty?
206
+ current = stack.pop
207
+ group << current
208
+ neighbours[current.id].each do |other|
209
+ next if seen[other.id]
210
+
211
+ seen[other.id] = true
212
+ stack << other
213
+ end
214
+ end
215
+
216
+ group
217
+ end
218
+
219
+ # Seed the given nodes at level 0 and relax only the components they
220
+ # belong to, bounded by those components' sizes. Seeds may span
221
+ # SEVERAL components -- the roots do on a forest -- so the groups are
222
+ # collected per component and each is relaxed once, carrying its own
223
+ # seeds into the relaxation as its starting front.
224
+ #
225
+ # `compare_by_identity` is load-bearing. The values are arrays of
226
+ # graph nodes, and lutaml-model gives those VALUE equality: two
227
+ # distinct components holding look-alike nodes are `==` and hash the
228
+ # same, so an ordinary Hash would merge them and relax one of them
229
+ # against the other's bound.
230
+ #
231
+ # A seed with no component is skipped, which cannot happen: `members`
232
+ # is built from the same `graph.children` the seeds come from. It is
233
+ # written this way so a future disagreement between the two skips
234
+ # that seed rather than raising mid-layout.
235
+ def relax_component_of(seeds, members, adjacent, levels)
236
+ seeds.each { |seed| levels[seed.id] = 0 }
237
+
238
+ per_component = {}.compare_by_identity
239
+ seeds.each do |seed|
240
+ group = members[seed.id]
241
+ next if group.nil?
242
+
243
+ (per_component[group] ||= []) << seed
244
+ end
245
+
246
+ per_component.each do |group, group_seeds|
247
+ relax_levels(group, group_seeds, adjacent, levels)
248
+ end
249
+
250
+ nil
251
+ end
252
+
253
+ # On a DAG this settles on the LONGEST path to each node, which is what
254
+ # puts a node reachable by two routes below its deepest parent. Walking
255
+ # every simple path to find that is factorial — a complete 8-node
256
+ # DIGRAPH (every node pointing at every other, not a simple cycle)
257
+ # took 2.5s and each extra node multiplied it by roughly ten. Relax
258
+ # instead: a longest path visits each node at most once, so a node
259
+ # re-queued whenever its own level moves settles every level a DAG
260
+ # can produce.
261
+ #
262
+ # A cyclic component has no longest path, so what its nodes get here is
263
+ # a bounded fallback number rather than a depth — `max_level` stops it
264
+ # climbing, it does not make it mean anything. It is also the ONLY
265
+ # thing that stops it: the sweep form had a pass count to fall out
266
+ # of as well, and the worklist does not. Measured with `max_level`
267
+ # removed, a six-node complete digraph does not finish in five
268
+ # seconds; with it, it lays out in seven rows. Ordering inside such a
269
+ # component is restored afterwards by #build_subtree's floor.
270
+ #
271
+ # `nodes` is ONE component, so the level ceiling is that component's
272
+ # size. It used to be the whole graph's node count, which is what let
273
+ # a two-node cycle climb for O(n) passes.
274
+ #
275
+ # The relaxation is a WORKLIST, not repeated full sweeps of the
276
+ # component. Only a node whose own level just moved can move its
277
+ # children's, so re-reading every settled node each time is pure
278
+ # waste -- and the fallback loop in #build_forest calls this once per
279
+ # unlevelled node, so that waste multiplied. Several rootless cycles
280
+ # sharing ONE component (a pile of two-cycles all pointing at one
281
+ # sink) made every seed re-sweep the whole component, which is cubic:
282
+ # measured before this change at 0.16s for 80 nodes, 1.03s for 160,
283
+ # 2.75s for 240 and past five seconds at 320.
284
+ #
285
+ # WHAT THIS CHANGED, measured over 3000 random graphs. Every ACYCLIC
286
+ # graph lays out byte-identically: on a DAG both forms reach the same
287
+ # longest-path levels, and 525 of 525 agreed. All 571 differences are
288
+ # CYCLIC, where a level is a bounded fallback number rather than a
289
+ # depth (see the comment above) -- and the difference is that the
290
+ # sweep stopped after `component size` passes whether or not it had
291
+ # settled, so what it produced depended on the order the component's
292
+ # nodes happened to come out of the DFS. Reversing that order in
293
+ # place changed 710 of 3000 layouts under the sweep and 0 of 3000
294
+ # under the worklist, which runs to the fixpoint every time.
295
+ #
296
+ # Mutates `levels` in place and returns nothing useful.
297
+ def relax_levels(nodes, seeds, adjacent, levels)
298
+ max_level = nodes.size
299
+ queue = seeds.dup
300
+
301
+ until queue.empty?
302
+ node = queue.shift
303
+ candidate = levels[node.id] + 1
304
+ next if candidate > max_level
305
+
306
+ adjacent[node.id].each do |child|
307
+ next if candidate <= (levels[child.id] || -1)
308
+
309
+ levels[child.id] = candidate
310
+ queue << child
311
+ end
312
+ end
313
+
314
+ nil
315
+ end
316
+
317
+ # `visited` is one mutable set for the whole walk, not a per-path copy.
318
+ # Copying it per path means a node reachable by many routes is expanded
319
+ # once per route, which is factorial on a dense graph — and it would
320
+ # place that node more than once anyway.
321
+ #
322
+ # `floor` is the level this node may not sit above: one below its parent
323
+ # in the tree being built. A cyclic component gets bounded fallback
324
+ # levels rather than path depths, so a back edge can hand a node a level
325
+ # deeper than its own child's and draw the parent underneath it. Taking
326
+ # the floor forces every edge the forest actually picked to point down.
327
+ # The edge that CLOSES a cycle is not one of those, and still points
328
+ # upward — no tree can honour it, so it stays a violation.
329
+ #
330
+ # A floored level can exceed the relaxation bound: r0 -> a -> b -> c
331
+ # with c -> a closing it puts c at level 6 across four nodes. Cyclic
332
+ # components therefore come out taller than they need to be.
333
+ # Use an explicit stack because the walk's depth is controlled by the
334
+ # input graph, not by this implementation.
335
+ def build_subtree(root, adjacent, visited, levels, floor)
336
+ visited << root.id
337
+ tree = subtree_node(root, levels, floor)
338
+ stack = [[root, tree]]
339
+
340
+ until stack.empty?
341
+ node, node_tree = stack.pop
342
+ children = adjacent[node.id]
343
+ .reject { |child| visited.include?(child.id) }
344
+ children.each { |child| visited << child.id }
345
+
346
+ child_floor = node_tree[:level] + 1
347
+ child_trees = children.map do |child|
348
+ subtree_node(child, levels, child_floor)
349
+ end
350
+ node_tree[:children] = child_trees
351
+ children.zip(child_trees).reverse_each { |pair| stack << pair }
66
352
  end
67
353
 
68
354
  tree
69
355
  end
70
356
 
71
- def build_subtree(node, graph, level)
72
- tree = {
357
+ def subtree_node(node, levels, floor)
358
+ {
73
359
  node: node,
74
360
  children: [],
75
- level: level,
361
+ level: [levels.fetch(node.id), floor].max,
76
362
  }
363
+ end
77
364
 
78
- children = find_children(node, graph)
79
- tree[:children] = children.map do |child|
80
- build_subtree(child, graph, level + 1)
365
+ # `placed` collects every node of the tree being laid out, in post
366
+ # order, appended exactly once. A subtree therefore occupies a
367
+ # CONTIGUOUS slice, and its parent can read or shift that slice by
368
+ # index without walking the tree again.
369
+ #
370
+ # It matters: reading the extent by re-walking the subtree at every
371
+ # level is quadratic on a deep tree. Instrumented on a chain before
372
+ # this change -- 20,099 visits for 200 nodes and 320,399 for 800.
373
+ # That is (n+2)(n-1)/2, not n^2/2; the shape is what matters, and
374
+ # doubling n multiplies the visits by four. This walk is iterative
375
+ # because its depth is likewise controlled by the input graph.
376
+ LayoutFrame = Struct.new(:tree, :x_offset, :y_offset, :start,
377
+ :child_x, :left, :right, :index)
378
+ private_constant :LayoutFrame
379
+
380
+ def layout_tree(tree, x_offset, y_offset, placed = [], offsets = {})
381
+ if tree[:children].empty?
382
+ return place_leaf(tree, x_offset, y_offset, placed, offsets)
81
383
  end
82
384
 
83
- tree
385
+ stack = [layout_frame(tree, x_offset, y_offset, placed.size)]
386
+ result = nil
387
+
388
+ until stack.empty?
389
+ frame = stack.last
390
+ if frame.index < frame.tree[:children].size
391
+ advance_layout(frame, stack, placed, offsets)
392
+ else
393
+ stack.pop
394
+ result = finish_layout(frame, stack.last, placed, offsets)
395
+ end
396
+ end
397
+
398
+ result
84
399
  end
85
400
 
86
- def find_children(node, graph)
87
- children = []
88
- edges = graph.edges || []
401
+ def layout_frame(tree, x_offset, y_offset, start)
402
+ LayoutFrame.new(tree, x_offset, y_offset, start,
403
+ x_offset, nil, nil, 0)
404
+ end
89
405
 
90
- edges.each do |edge|
91
- sources = edge.sources || []
92
- targets = edge.targets || []
406
+ def advance_layout(frame, stack, placed, offsets)
407
+ child = frame.tree[:children][frame.index]
408
+ frame.index += 1
93
409
 
94
- next unless sources.include?(node.id)
410
+ if child[:children].empty?
411
+ extend_layout(frame,
412
+ *place_leaf(child, frame.child_x, frame.y_offset,
413
+ placed, offsets))
414
+ else
415
+ stack << layout_frame(child, frame.child_x, frame.y_offset,
416
+ placed.size)
417
+ end
418
+ end
95
419
 
96
- targets.each do |target_id|
97
- child = graph.children.find { |n| n.id == target_id }
98
- children << child if child
99
- end
420
+ def finish_layout(frame, parent, placed, offsets)
421
+ centre_over_children(frame.tree, frame.y_offset, offsets)
422
+ placed << frame.tree[:node]
423
+ result = settle_subtree(frame.tree[:node], placed, frame.start,
424
+ frame.x_offset, [frame.left, frame.right])
425
+ extend_layout(parent, *result) if parent
426
+ result
427
+ end
428
+
429
+ def extend_layout(frame, consumed, left, right)
430
+ frame.child_x += consumed
431
+ frame.left = left if frame.left.nil? || left < frame.left
432
+ frame.right = right if frame.right.nil? || right > frame.right
433
+ end
434
+
435
+ # The width CONSUMED, measured from where the nodes actually landed.
436
+ # `child_x` alone is not it: it accounts only for the children's
437
+ # allocation, and a node WIDER than its children protrudes past them
438
+ # on both sides. A 200px parent over two 10px children reported 60
439
+ # while occupying 200, and the next tree started 60px inside it.
440
+ # Centring can also push the parent left of `x_offset`, so the
441
+ # subtree is nudged back before its extent is read.
442
+ #
443
+ # Only when the parent actually protrudes LEFT does this touch the
444
+ # subtree again, and then only that subtree's own slice. Reading the
445
+ # extent by re-walking every level instead cost n^2/2 node visits on
446
+ # a deep tree -- measured on a chain: 320,399 visits for 800 nodes,
447
+ # now 0, and the layout went from 0.443s to 0.244s.
448
+ def settle_subtree(node, placed, start, x_offset, extent)
449
+ left, right = extent
450
+ own_right = node.x + (node.width || 0.0)
451
+ left = node.x if node.x < left
452
+ right = own_right if own_right > right
453
+
454
+ if left < x_offset
455
+ shift = x_offset - left
456
+ shift_slice(placed, start, shift)
457
+ left += shift
458
+ right += shift
100
459
  end
101
460
 
102
- children
461
+ [right - x_offset + node_spacing, left, right]
103
462
  end
104
463
 
105
- def layout_tree(tree, x_offset, y_offset)
464
+ # A leaf sits where it was told to, and occupies exactly its own box.
465
+ def place_leaf(tree, x_offset, y_offset, placed, offsets)
106
466
  node = tree[:node]
107
- spacing_val = node_spacing
108
- level_height = 80.0
467
+ node.x = x_offset
468
+ node.y = y_offset + offsets.fetch(tree[:level], 0.0)
469
+ placed << node
470
+ width = node.width || 0.0
109
471
 
110
- if tree[:children].empty?
111
- # Leaf node
112
- node.x = x_offset
113
- node.y = y_offset + (tree[:level] * level_height)
114
- return node.width + spacing_val
472
+ [width + node_spacing, node.x, node.x + width]
473
+ end
474
+
475
+ # Centres a node over the children already placed beneath it.
476
+ def centre_over_children(tree, y_offset, offsets)
477
+ node = tree[:node]
478
+ first_child = tree[:children].first[:node]
479
+ last_child = tree[:children].last[:node]
480
+ last_child_width = last_child.width || 0.0
481
+ center_x = (first_child.x + last_child.x + last_child_width) / 2.0
482
+
483
+ node.x = center_x - ((node.width || 0.0) / 2.0)
484
+ node.y = y_offset + offsets.fetch(tree[:level], 0.0)
485
+ end
486
+
487
+ # Every row starts below the tallest node in the preceding row. This
488
+ # preserves the tree's level/breadth geometry without assuming a node
489
+ # height, and makes the configured node spacing the actual gap.
490
+ def level_offsets(trees)
491
+ heights = Hash.new(0.0)
492
+ stack = trees.dup
493
+
494
+ until stack.empty?
495
+ tree = stack.pop
496
+ level = tree[:level]
497
+ heights[level] = [heights[level], tree[:node].height || 0.0].max
498
+ stack.concat(tree[:children])
115
499
  end
116
500
 
117
- # Layout children first
118
- child_x = x_offset
119
- tree[:children].each do |child_tree|
120
- width = layout_tree(child_tree, child_x, y_offset)
121
- child_x += width
501
+ offset = 0.0
502
+ (0..(heights.keys.max || 0)).to_h do |level|
503
+ current = offset
504
+ offset += heights[level] + node_spacing
505
+ [level, current]
122
506
  end
507
+ end
123
508
 
124
- # Position this node centered above children
125
- first_child = tree[:children].first[:node]
126
- last_child = tree[:children].last[:node]
509
+ # Layout is calculated once in DOWN's (breadth, level) coordinate
510
+ # frame. Other directions are rotations/mirrors of that same shape;
511
+ # apply_padding normalises the resulting negative coordinates.
512
+ def orient(nodes)
513
+ direction = option("elk.direction", default: "DOWN").to_s.upcase
514
+
515
+ case direction
516
+ when "RIGHT"
517
+ nodes.each { |node| node.x, node.y = node.y, node.x }
518
+ when "UP"
519
+ nodes.each { |node| node.y = -node.y - (node.height || 0.0) }
520
+ when "LEFT"
521
+ nodes.each do |node|
522
+ breadth = node.x
523
+ node.x = -node.y - (node.width || 0.0)
524
+ node.y = breadth
525
+ end
526
+ end
527
+ end
528
+
529
+ # ELK's MRTree layout uses 20px padding even though the shared core
530
+ # registry default is 12px. An explicit graph/call option still wins.
531
+ def padding
532
+ option(
533
+ "elk.padding",
534
+ default: { left: 20.0, top: 20.0, right: 20.0, bottom: 20.0 },
535
+ ).to_h
536
+ end
537
+
538
+ # MRTree connects a parent's side to the centre of each child's near
539
+ # side. Sibling edges receive distinct, evenly spaced attachment
540
+ # points, matching ELK's tree routing and keeping every endpoint on a
541
+ # node border.
542
+ def apply_edge_routing(graph)
543
+ super
544
+ index = NodeIndex.build(graph)
545
+ outgoing = Hash.new { |hash, key| hash[key] = [] }
546
+
547
+ (graph.edges || []).each do |edge|
548
+ connection = tree_connection(edge, index)
549
+ next unless connection
550
+
551
+ outgoing[connection[1].id] << connection
552
+ end
553
+
554
+ outgoing.each_value do |entries|
555
+ entries.each_with_index do |connection, position|
556
+ route_tree_edge(
557
+ connection, position + 1, entries.size + 1, graph
558
+ )
559
+ end
560
+ end
561
+ end
127
562
 
128
- center_x = (first_child.x + last_child.x + last_child.width) / 2.0
129
- node.x = center_x - (node.width / 2.0)
130
- node.y = y_offset + (tree[:level] * level_height)
563
+ def tree_connection(edge, index)
564
+ source = index.endpoint_nodes(edge.sources).first
565
+ target = index.endpoint_nodes(edge.targets).first
566
+ return unless source && target && source.id != target.id
131
567
 
132
- # Return total width
133
- last_child.x + last_child.width - x_offset + spacing_val
568
+ [edge, source, target]
134
569
  end
135
570
 
136
- def calculate_tree_width(tree)
137
- return tree[:node].width if tree[:children].empty?
571
+ def route_tree_edge(connection, position, denominator, graph)
572
+ edge, source, target = connection
573
+ fraction = position.to_f / denominator
574
+ direction = option("elk.direction", default: "DOWN").to_s.upcase
575
+ direction = "DOWN" if direction == "UNDEFINED"
138
576
 
139
- tree[:children].sum { |child| calculate_tree_width(child) }
577
+ start, finish, bend = tree_edge_points(
578
+ source, target, direction, fraction
579
+ )
580
+ start = tree_endpoint_position(
581
+ edge.sources.first, source, start, :outgoing
582
+ )
583
+ finish = tree_endpoint_position(
584
+ edge.targets.first, target, finish, :incoming
585
+ )
586
+ section = reset_section(edge, graph)
587
+ section.start_point = start
588
+ section.end_point = finish
589
+ section.bend_points = [bend]
590
+ connection.first.container = graph.id
591
+ end
592
+
593
+ def tree_edge_points(source, target, direction, fraction)
594
+ case direction
595
+ when "RIGHT"
596
+ start = Geometry::Point.new(
597
+ x: source.x + node_width(source),
598
+ y: source.y + (node_height(source) * fraction),
599
+ )
600
+ finish = Geometry::Point.new(
601
+ x: target.x,
602
+ y: target.y + (node_height(target) / 2.0),
603
+ )
604
+ bend = Geometry::Point.new(x: start.x + 3.0, y: start.y)
605
+ when "UP"
606
+ start = Geometry::Point.new(
607
+ x: source.x + (node_width(source) * fraction),
608
+ y: source.y,
609
+ )
610
+ finish = Geometry::Point.new(
611
+ x: target.x + (node_width(target) / 2.0),
612
+ y: target.y + node_height(target),
613
+ )
614
+ bend = Geometry::Point.new(x: start.x, y: start.y - 3.0)
615
+ when "LEFT"
616
+ start = Geometry::Point.new(
617
+ x: source.x,
618
+ y: source.y + (node_height(source) * fraction),
619
+ )
620
+ finish = Geometry::Point.new(
621
+ x: target.x + node_width(target),
622
+ y: target.y + (node_height(target) / 2.0),
623
+ )
624
+ bend = Geometry::Point.new(x: start.x - 3.0, y: start.y)
625
+ else
626
+ start = Geometry::Point.new(
627
+ x: source.x + (node_width(source) * fraction),
628
+ y: source.y + node_height(source),
629
+ )
630
+ finish = Geometry::Point.new(
631
+ x: target.x + (node_width(target) / 2.0),
632
+ y: target.y,
633
+ )
634
+ bend = Geometry::Point.new(x: start.x, y: start.y + 3.0)
635
+ end
636
+
637
+ [start, finish, bend]
638
+ end
639
+
640
+ def tree_endpoint_position(endpoint_id, node, node_point, direction)
641
+ return node_point unless find_port_by_id(endpoint_id, node)
642
+
643
+ get_port_position(endpoint_id, node, direction)
644
+ end
645
+
646
+ def node_width(node)
647
+ node.width || 0.0
648
+ end
649
+
650
+ def node_height(node)
651
+ node.height || 0.0
652
+ end
653
+
654
+ def shift_slice(placed, start, shift)
655
+ index = start
656
+ while index < placed.size
657
+ placed[index].x += shift
658
+ index += 1
659
+ end
140
660
  end
141
661
  end
142
662
  end