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,83 +1,152 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # This file is loadable on its own, so do not rely on another algorithm
4
+ # having loaded Ruby's Set first.
5
+ # rubocop:disable Lint/RedundantRequireStatement
6
+ require "set"
7
+ # rubocop:enable Lint/RedundantRequireStatement
8
+
3
9
  module Elkrb
4
10
  module Layout
5
11
  module Algorithms
6
12
  module Layered
7
- # Breaks cycles in the graph to make it acyclic
13
+ # Finds back edges without changing the graph supplied by the caller.
14
+ # The returned EDGES are consumed by LayerAssigner to orient each one
15
+ # only while calculating layers.
16
+ #
17
+ # The set holds the edge OBJECTS and compares by identity, not their
18
+ # ids. An id is optional on an edge, so keying by id gave every
19
+ # id-less edge in the graph the same handle: reverse one of them and
20
+ # LayerAssigner reversed all of them. Identity is the only handle
21
+ # every edge has. A PLAIN Set is not enough either -- lutaml-model
22
+ # gives these classes VALUE equality, so two distinct edges whose
23
+ # attributes ALL match, id and endpoints alike, are `==`, hash
24
+ # alike, and collapse into one entry. Differing ids are enough to
25
+ # keep them apart in a plain Set; two anonymous parallel edges are
26
+ # not.
8
27
  #
9
- # This is the first phase of the Sugiyama framework.
10
- # Uses a greedy approach to reverse edges that create cycles.
28
+ # `outgoing_edges` still walks every source against every target,
29
+ # even though LayeredAlgorithm#validate_simple_edge! now rejects any
30
+ # edge that isn't exactly one source and one target before this class
31
+ # is ever constructed -- so today that cross product is always over
32
+ # arrays of length 0 or 1. Kept general on purpose, unlike
33
+ # LayerAssigner#endpoint_owner_id (singular, .first-based): this
34
+ # class is unit-tested directly with hyperedge-shaped input
35
+ # (cycle_breaker_spec.rb), and narrowing it to the current caller's
36
+ # guarantee would make it correct only through that one caller.
11
37
  class CycleBreaker
12
- def initialize(graph)
38
+ def initialize(graph, index)
13
39
  @graph = graph
14
- @visited = {}
15
- @in_stack = {}
16
- @edges_to_reverse = []
40
+ @index = index
17
41
  end
18
42
 
19
43
  def break_cycles
20
- return unless @graph.children
44
+ reversed = Set.new.compare_by_identity
45
+ return reversed unless @graph.children
46
+
47
+ adjacency = outgoing_edges
48
+ colors = {}
21
49
 
22
- # Find all edges that create cycles using DFS
23
50
  @graph.children.each do |node|
24
- dfs(node) unless @visited[node.id]
51
+ # keep this `colors[node.id]` truthiness check, even though
52
+ # `colors.key?(node.id)` reads identically: every value ever
53
+ # stored in `colors` is `:active` or `:complete`, both
54
+ # truthy, and nothing ever stores a falsy value under a key
55
+ # -- so "has a value" and "has a truthy value" can never
56
+ # diverge for this hash. Confirmed by hand: swapping in
57
+ # `colors.key?(node.id)` leaves the whole suite green.
58
+ next if colors[node.id]
59
+
60
+ walk_from(node.id, adjacency, colors, reversed)
25
61
  end
26
62
 
27
- # Reverse the problematic edges
28
- reverse_edges
29
-
30
- @edges_to_reverse
63
+ reversed
31
64
  end
32
65
 
33
66
  private
34
67
 
35
- def dfs(node)
36
- @visited[node.id] = true
37
- @in_stack[node.id] = true
38
-
39
- # Process outgoing edges
40
- get_outgoing_edges(node).each do |edge|
41
- target_id = edge.targets.first
42
- next unless target_id
43
-
44
- target = @graph.find_node(target_id)
45
- next unless target
46
-
47
- if @in_stack[target.id]
48
- # Found a cycle - mark this edge for reversal
49
- @edges_to_reverse << edge
50
- elsif !@visited[target.id]
51
- dfs(target)
68
+ # Every mutant mutant finds in this method is equivalent, confirmed
69
+ # by hand (each one individually applied, whole suite still
70
+ # green) -- they all fall into two classes:
71
+ # - `stack[-1]`, `edges[edge_index]`: `Array#[]` with a single
72
+ # Integer index reads identically to `#at` and, since the
73
+ # `if` just above always holds `edge_index` in bounds before
74
+ # either line runs, identically to `#fetch` too.
75
+ # - `edge_index >= edges.length`, `next`, and the exact
76
+ # `:complete` symbol: `edge_index` only ever grows by
77
+ # `frame[1] = edge_index + 1` and is re-checked every pass
78
+ # before it can grow again, so it can equal `edges.length`
79
+ # but never exceed it -- making `>=`, `==`, `eql?` and
80
+ # `equal?` the same test here, and `colors` is a local
81
+ # consumed only by `== :active` / nil checks (never a
82
+ # specific "is it :complete" check, never read outside this
83
+ # method), so renaming that value or falling through `next`
84
+ # onto a redundant `edges[edge_index]` read changes nothing
85
+ # reachable from `reversed`, the one value this method hands
86
+ # back out.
87
+ def walk_from(root_id, adjacency, colors, reversed)
88
+ colors[root_id] = :active
89
+ stack = [[root_id, 0]]
90
+
91
+ until stack.empty?
92
+ frame = stack[-1]
93
+ current_id, edge_index = frame
94
+ edges = adjacency[current_id]
95
+
96
+ if edge_index >= edges.length
97
+ colors[current_id] = :complete
98
+ stack.pop
99
+ next
52
100
  end
101
+
102
+ target_id, edge = edges[edge_index]
103
+ frame[1] = edge_index + 1
104
+ visit_target(target_id, edge, stack, colors, reversed)
53
105
  end
106
+ end
54
107
 
55
- @in_stack[node.id] = false
108
+ def visit_target(target_id, edge, stack, colors, reversed)
109
+ case colors[target_id]
110
+ when :active
111
+ reversed << edge
112
+ when nil
113
+ colors[target_id] = :active
114
+ stack << [target_id, 0]
115
+ end
56
116
  end
57
117
 
58
- def get_outgoing_edges(node)
59
- edges = []
118
+ def outgoing_edges
119
+ adjacency = Hash.new { |hash, id| hash[id] = [] }
120
+
121
+ @index.edges.each do |edge|
122
+ source_ids = endpoint_owner_ids(edge.sources)
123
+ target_ids = endpoint_owner_ids(edge.targets)
60
124
 
61
- # Get edges from the node itself
62
- edges.concat(node.edges) if node.edges
125
+ source_ids.each do |source_id|
126
+ target_ids.each do |target_id|
127
+ next if source_id == target_id
63
128
 
64
- # Get edges from the graph that have this node as source
65
- @graph.edges&.each do |edge|
66
- edges << edge if edge.sources&.include?(node.id)
129
+ adjacency[source_id] << [target_id, edge]
130
+ end
131
+ end
67
132
  end
68
133
 
69
- edges
134
+ adjacency
70
135
  end
71
136
 
72
- def reverse_edges
73
- @edges_to_reverse.each do |edge|
74
- # Swap sources and targets
75
- edge.sources, edge.targets = edge.targets, edge.sources
76
-
77
- # Mark as reversed for later processing
78
- edge.properties ||= {}
79
- edge.properties["reversed"] = true
80
- end
137
+ def endpoint_owner_ids(endpoints)
138
+ (endpoints || []).filter_map do |id|
139
+ # keep the `if id` guard even though `@index.owner(nil)` is
140
+ # itself nil-safe (NodeIndex#owner is `node(id) ||
141
+ # @owners_by_descendant_id[id]`, and a Hash lookup of a nil
142
+ # key that was never stored just answers nil) -- so for the
143
+ # one falsy id this array can ever hold, skipping the call
144
+ # and calling it unconditionally land on the identical nil.
145
+ # Confirmed by hand: forcing this branch to always run
146
+ # leaves the whole suite green.
147
+ owner = @index.owner(id) if id
148
+ owner&.id
149
+ end.uniq
81
150
  end
82
151
  end
83
152
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ module Algorithms
6
+ module Layered
7
+ # The order ELK lists a connected graph's nodes in: a depth-first walk
8
+ # from the first node, following each node's edges in the order the
9
+ # importer created them. Every layer starts out in that order, and it
10
+ # decides which of two equally good orders the sweep keeps.
11
+ class DiscoveryOrder
12
+ # @param graph [Graph::Node] the graph whose children are layered
13
+ # @param index [NodeIndex, nil] resolves edge endpoints to nodes
14
+ def initialize(graph, index)
15
+ @children = graph.children || []
16
+ @neighbours = neighbours(index)
17
+ end
18
+
19
+ # Reorders each layer in place; dummy slots keep their place at the
20
+ # end.
21
+ def sort(layers)
22
+ ranks = discover
23
+ layers.each do |layer|
24
+ layer.replace(layer.each_with_index.sort_by do |item, position|
25
+ [ranks.fetch(item.id, ranks.length), position]
26
+ end.map(&:first))
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def discover
33
+ ranks = {}
34
+ @children.each { |child| walk(child.id, ranks) }
35
+ ranks
36
+ end
37
+
38
+ def walk(root, ranks)
39
+ return if ranks.key?(root)
40
+
41
+ pending = [[root]]
42
+ until pending.empty?
43
+ found = pending.last.shift
44
+ next pending.pop unless found
45
+ next if ranks.key?(found)
46
+
47
+ ranks[found] = ranks.length
48
+ pending.push(@neighbours.fetch(found).dup)
49
+ end
50
+ end
51
+
52
+ def neighbours(index)
53
+ table = @children.to_h { |child| [child.id, []] }
54
+ index&.edges&.each do |edge|
55
+ link(table, owner_id(index, edge.sources),
56
+ owner_id(index, edge.targets))
57
+ end
58
+ table
59
+ end
60
+
61
+ def owner_id(index, endpoints)
62
+ index.owner(endpoints&.first)&.id
63
+ end
64
+
65
+ def link(table, source, target)
66
+ return unless table.key?(source) && table.key?(target)
67
+
68
+ table[source] << target
69
+ table[target] << source
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ module Algorithms
6
+ module Layered
7
+ # Internal layer position reserved for one segment of a long edge.
8
+ class DummySlot
9
+ attr_accessor :x, :y, :width, :height
10
+ attr_reader :edge, :edge_id, :layer_index, :edge_order, :id
11
+
12
+ def initialize(edge:, layer_index:, edge_order:)
13
+ @edge = edge
14
+ @edge_id = edge.id
15
+ @layer_index = layer_index
16
+ @edge_order = edge_order
17
+ @id = "__elkrb_dummy_#{edge_order}_#{layer_index}"
18
+ @x = 0.0
19
+ @y = 0.0
20
+ @width = 0.0
21
+ @height = 0.0
22
+ end
23
+
24
+ def dummy?
25
+ true
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,18 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # This file is loadable on its own, so do not rely on another algorithm
4
+ # having loaded Ruby's Set first.
5
+ # rubocop:disable Lint/RedundantRequireStatement
6
+ require "set"
7
+ # rubocop:enable Lint/RedundantRequireStatement
8
+ require_relative "dummy_slot"
9
+
10
+ require_relative "../../../options/resolver"
11
+ require_relative "layer_constraints"
12
+
3
13
  module Elkrb
4
14
  module Layout
5
15
  module Algorithms
6
16
  module Layered
7
- # Assigns nodes to layers in the graph
8
- #
9
- # This is the second phase of the Sugiyama framework.
10
- # Uses longest path layering to create a balanced layout.
17
+ # Assigns nodes to layers using an iterative longest-path pass, then
18
+ # applies layer constraints: `elk.layered.layering.layerConstraint`
19
+ # (FIRST, FIRST_SEPARATE, LAST, LAST_SEPARATE) and
20
+ # `constraints.layer`. A constraint wins over the edges, so an edge
21
+ # may end up pointing against the layer order.
11
22
  class LayerAssigner
12
23
  attr_reader :layers
13
24
 
14
- def initialize(graph)
25
+ # `reversed_edges` holds the edge OBJECTS CycleBreaker decided to
26
+ # orient backwards, compared by identity. See CycleBreaker for why
27
+ # neither an id nor a plain Set can carry that decision.
28
+ #
29
+ # keep this `compare_by_identity` on the DEFAULT too, even though
30
+ # nothing in this codebase can observe it today: the default is
31
+ # only ever read via #include? and never added to, and an empty
32
+ # Set answers #include? identically whichever comparison strategy
33
+ # it uses -- so no spec can distinguish this from a plain
34
+ # `Set.new` (confirmed by mutating it by hand). It becomes the
35
+ # only thing standing between a dedup bug and a direct caller
36
+ # that relies on the default AND later mutates it in place.
37
+ def initialize(graph, index,
38
+ reversed_edges = Set.new.compare_by_identity,
39
+ resolver: Options::Resolver.new)
15
40
  @graph = graph
41
+ @resolver = resolver
42
+ @index = index
43
+ @reversed_edges = reversed_edges
16
44
  @layers = []
17
45
  @node_layers = {}
18
46
  end
@@ -20,20 +48,12 @@ module Elkrb
20
48
  def assign_layers
21
49
  return [] unless @graph.children
22
50
 
23
- # Calculate layer for each node
24
- @graph.children.each do |node|
25
- calculate_layer(node)
26
- end
27
-
28
- # Group nodes by layer
29
- max_layer = @node_layers.values.max || 0
30
- @layers = Array.new(max_layer + 1) { [] }
31
-
32
- @node_layers.each do |node_id, layer|
33
- node = @graph.find_node(node_id)
34
- @layers[layer] << node if node
35
- end
36
-
51
+ nodes = @graph.children.to_h { |node| [node.id, node] }
52
+ predecessors = build_predecessors(nodes)
53
+ assign_predecessor_layers(nodes, predecessors)
54
+ apply_layer_constraints(nodes)
55
+ build_layers(nodes)
56
+ insert_dummy_slots
37
57
  @layers
38
58
  end
39
59
 
@@ -43,51 +63,171 @@ module Elkrb
43
63
 
44
64
  private
45
65
 
46
- def calculate_layer(node)
47
- return @node_layers[node.id] if @node_layers.key?(node.id)
66
+ # Keep the predecessor order used by the former recursive pass.
67
+ # That order is observable in NodePlacer's same-layer coordinates,
68
+ # so an acyclic graph must not be reordered as a side effect of the
69
+ # iterative implementation.
70
+ def build_predecessors(nodes)
71
+ predecessors = Hash.new { |hash, id| hash[id] = [] }
48
72
 
49
- # Find incoming edges
50
- incoming = get_incoming_edges(node)
73
+ @index.edges.each do |edge|
74
+ source_id, target_id = oriented_endpoints(edge)
75
+ next unless usable_edge?(source_id, target_id, nodes)
51
76
 
52
- if incoming.empty?
53
- # Root node - assign to layer 0
54
- @node_layers[node.id] = 0
55
- else
56
- # Assign to one layer below the maximum of predecessors
57
- max_pred_layer = incoming.filter_map do |edge|
58
- source_id = edge.sources.first
59
- next 0 unless source_id
77
+ predecessors[target_id] << source_id
78
+ end
60
79
 
61
- source = @graph.find_node(source_id)
62
- next 0 unless source
80
+ predecessors
81
+ end
82
+
83
+ # Iterative post-order traversal of each node's predecessors. This
84
+ # is the stack-safe equivalent of the original memoised recursive
85
+ # longest-path calculation and preserves its insertion order.
86
+ def assign_predecessor_layers(nodes, predecessors)
87
+ @node_layers = {}
88
+ colors = {}
89
+
90
+ nodes.each_key do |node_id|
91
+ next if @node_layers.key?(node_id)
92
+
93
+ walk_predecessors(node_id, predecessors, colors)
94
+ end
95
+ end
96
+
97
+ def walk_predecessors(root_id, predecessors, colors)
98
+ colors[root_id] = :active
99
+ stack = [[root_id, 0]]
63
100
 
64
- calculate_layer(source)
65
- end.max || 0
101
+ until stack.empty?
102
+ step_predecessor_stack(stack, predecessors, colors)
103
+ end
104
+ end
66
105
 
67
- @node_layers[node.id] = max_pred_layer + 1
106
+ def step_predecessor_stack(stack, predecessors, colors)
107
+ # `stack[-1]` and `stack.at(-1)` are the same Array call with a
108
+ # single integer index -- no spec can tell them apart, and
109
+ # mutant flags the swap as a surviving mutation every run.
110
+ # Equivalent mutant, not a coverage gap.
111
+ frame = stack[-1]
112
+ current_id, predecessor_index = frame
113
+ incoming = predecessors[current_id]
114
+
115
+ if predecessor_index >= incoming.length
116
+ finish_predecessor_frame(stack, current_id, incoming, colors)
117
+ return
68
118
  end
69
119
 
70
- @node_layers[node.id]
120
+ predecessor_id = incoming[predecessor_index]
121
+ frame[1] = predecessor_index + 1
122
+ return if predecessor_assigned?(predecessor_id, colors)
123
+
124
+ colors[predecessor_id] = :active
125
+ stack << [predecessor_id, 0]
71
126
  end
72
127
 
73
- def get_incoming_edges(node)
74
- edges = []
128
+ def finish_predecessor_frame(stack, node_id, incoming, colors)
129
+ assign_layer(node_id, incoming)
130
+ # keep this write even though `colors` is only ever read with
131
+ # `== :active` in predecessor_assigned?, which checks
132
+ # @node_layers.key?(node_id) FIRST and returns true before it
133
+ # would ever read :complete here -- assign_layer above always
134
+ # runs first, so by the time anything could read this node's
135
+ # color, the key-check has already short-circuited. Confirmed
136
+ # by hand: removing this line changes nothing observable. It
137
+ # becomes load-bearing only if that ordering, or the
138
+ # :active-only read in predecessor_assigned?, ever changes.
139
+ colors[node_id] = :complete
140
+ stack.pop
141
+ end
75
142
 
76
- # Get all edges that target this node
77
- @graph.edges&.each do |edge|
78
- edges << edge if edge.targets&.include?(node.id)
143
+ # False here is the ordinary case: predecessor_id hasn't been
144
+ # visited by an earlier root yet, so the caller descends into it
145
+ # next. True from the :active check is the defensive case --
146
+ # CycleBreaker orients every back edge away from the active DFS
147
+ # path, so through the real pipeline this branch never fires; it
148
+ # guards a direct caller of LayerAssigner that supplies an
149
+ # incomplete reversal set. Warn there specifically (not on the
150
+ # plain-revisit case above it): silently proceeding is what
151
+ # dropped a diagnostic this class used to print for exactly that
152
+ # misuse.
153
+ def predecessor_assigned?(predecessor_id, colors)
154
+ return true if @node_layers.key?(predecessor_id)
155
+ return false unless colors[predecessor_id] == :active
156
+
157
+ warn "LayerAssigner: cycle through #{predecessor_id} not " \
158
+ "fully broken; treating as a root for this branch"
159
+ true
160
+ end
161
+
162
+ def assign_layer(node_id, incoming)
163
+ max_predecessor_layer = incoming.filter_map do |predecessor_id|
164
+ @node_layers[predecessor_id]
165
+ end.max
166
+
167
+ @node_layers[node_id] =
168
+ if max_predecessor_layer
169
+ max_predecessor_layer + 1
170
+ else
171
+ 0
172
+ end
173
+ end
174
+
175
+ def apply_layer_constraints(nodes)
176
+ LayerConstraints.new(nodes, @node_layers, @resolver).apply
177
+ end
178
+
179
+ def build_layers(nodes)
180
+ max_layer = @node_layers.values.max || 0
181
+ @layers = Array.new(max_layer + 1) { [] }
182
+ @node_layers.each do |node_id, layer|
183
+ @layers[layer] << nodes[node_id]
79
184
  end
80
185
 
81
- # Also check edges from other nodes
82
- @graph.children&.each do |other_node|
83
- next unless other_node.edges
186
+ @layers
187
+ end
84
188
 
85
- other_node.edges.each do |edge|
86
- edges << edge if edge.targets&.include?(node.id)
189
+ def insert_dummy_slots
190
+ @index.edges.each_with_index do |edge, edge_order|
191
+ source_id, target_id = oriented_endpoints(edge)
192
+ source_layer = @node_layers[source_id]
193
+ target_layer = @node_layers[target_id]
194
+ next unless source_layer && target_layer
195
+ next unless (source_layer - target_layer).abs > 1
196
+
197
+ intermediate_layers(source_layer, target_layer).each do |layer|
198
+ @layers[layer] << DummySlot.new(
199
+ edge: edge, layer_index: layer, edge_order: edge_order,
200
+ )
87
201
  end
88
202
  end
203
+ end
204
+
205
+ def intermediate_layers(source_layer, target_layer)
206
+ low, high = [source_layer, target_layer].minmax
207
+ ((low + 1)...high).to_a
208
+ end
209
+
210
+ def usable_edge?(source_id, target_id, nodes)
211
+ source_id && target_id && source_id != target_id &&
212
+ nodes.key?(source_id) && nodes.key?(target_id)
213
+ end
214
+
215
+ def oriented_endpoints(edge)
216
+ source_id = endpoint_owner_id(edge.sources)
217
+ target_id = endpoint_owner_id(edge.targets)
218
+ return [nil, nil] unless source_id && target_id
219
+
220
+ if @reversed_edges.include?(edge)
221
+ [target_id, source_id]
222
+ else
223
+ [source_id, target_id]
224
+ end
225
+ end
89
226
 
90
- edges
227
+ def endpoint_owner_id(endpoints)
228
+ id = (endpoints || []).first
229
+ owner = @index.owner(id) if id
230
+ owner&.id
91
231
  end
92
232
  end
93
233
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ module Algorithms
6
+ module Layered
7
+ # Moves nodes to the layers their constraints ask for, after the
8
+ # longest-path pass has assigned every node a layer.
9
+ #
10
+ # `elk.layered.layering.layerConstraint` puts a node first or last:
11
+ # FIRST and LAST share a layer with other nodes, FIRST_SEPARATE and
12
+ # LAST_SEPARATE get a layer of their own. `constraints.layer` is an
13
+ # explicit layer index, applied last, so it wins over the option.
14
+ # Both override the edges.
15
+ class LayerConstraints
16
+ OPTION = "elk.layered.layering.layerConstraint"
17
+
18
+ # @param nodes [Hash{String => Graph::Node}] the nodes by id
19
+ # @param node_layers [Hash{String => Integer}] layer by node id,
20
+ # updated in place
21
+ # @param resolver [Options::Resolver]
22
+ def initialize(nodes, node_layers, resolver)
23
+ @nodes = nodes
24
+ @layers = node_layers
25
+ @kinds = nodes.transform_values do |node|
26
+ resolver.get(OPTION, node)
27
+ end
28
+ end
29
+
30
+ def apply
31
+ place_by_kind
32
+ place_by_index
33
+ end
34
+
35
+ private
36
+
37
+ def ids_of(kind)
38
+ @kinds.select { |_id, value| value == kind }.keys
39
+ end
40
+
41
+ def place_by_kind
42
+ first_separate = ids_of("FIRST_SEPARATE")
43
+ last_separate = ids_of("LAST_SEPARATE")
44
+ first_layer = first_separate.empty? ? 0 : 1
45
+
46
+ shift(@nodes.keys - first_separate - last_separate, first_layer)
47
+ set(ids_of("FIRST"), first_layer)
48
+ set(first_separate, 0)
49
+
50
+ place_last(last_separate, first_layer)
51
+ end
52
+
53
+ def place_last(last_separate, fallback)
54
+ last_layer = last_movable_layer(last_separate, fallback)
55
+ set(ids_of("LAST"), last_layer)
56
+ set(last_separate, last_layer + 1)
57
+ end
58
+
59
+ def last_movable_layer(last_separate, fallback)
60
+ movable = @nodes.keys - last_separate - ids_of("LAST")
61
+ @layers.values_at(*movable).max || fallback
62
+ end
63
+
64
+ def place_by_index
65
+ @nodes.each do |id, node|
66
+ index = node.constraints&.layer
67
+ @layers[id] = [index, 0].max if index
68
+ end
69
+ end
70
+
71
+ def shift(ids, by)
72
+ ids.each { |id| @layers[id] += by }
73
+ end
74
+
75
+ def set(ids, layer)
76
+ ids.each { |id| @layers[id] = layer }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end