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.
- checksums.yaml +4 -4
- data/CHANGELOG.adoc +364 -0
- data/LICENSE +25 -0
- data/README.adoc +695 -557
- data/exe/elkrb +36 -2
- data/lib/elkrb/atomic_destination.rb +53 -0
- data/lib/elkrb/best_effort_write.rb +49 -0
- data/lib/elkrb/cli/layout_flag_options.rb +54 -0
- data/lib/elkrb/cli.rb +155 -82
- data/lib/elkrb/command_resolver.rb +186 -0
- data/lib/elkrb/commands/batch_command.rb +28 -5
- data/lib/elkrb/commands/convert_command.rb +22 -52
- data/lib/elkrb/commands/diagram_command.rb +234 -102
- data/lib/elkrb/commands/options_command.rb +87 -0
- data/lib/elkrb/commands/render_command.rb +6 -1
- data/lib/elkrb/commands/validate_command.rb +146 -91
- data/lib/elkrb/errors.rb +20 -1
- data/lib/elkrb/format_sniffer.rb +362 -0
- data/lib/elkrb/geometry/bezier.rb +14 -4
- data/lib/elkrb/geometry/point.rb +5 -6
- data/lib/elkrb/geometry/rectangle.rb +2 -2
- data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
- data/lib/elkrb/graph/edge.rb +89 -3
- data/lib/elkrb/graph/graph.rb +43 -15
- data/lib/elkrb/graph/label.rb +16 -2
- data/lib/elkrb/graph/node.rb +13 -2
- data/lib/elkrb/graph/node_constraints.rb +139 -8
- data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
- data/lib/elkrb/graph/port.rb +29 -21
- data/lib/elkrb/graph/read_only_mapping.rb +21 -0
- data/lib/elkrb/graphviz_wrapper.rb +152 -44
- data/lib/elkrb/layout/algorithm_registry.rb +65 -8
- data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
- data/lib/elkrb/layout/algorithms/box.rb +47 -20
- data/lib/elkrb/layout/algorithms/disco.rb +150 -61
- data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
- data/lib/elkrb/layout/algorithms/force.rb +145 -86
- data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
- data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
- data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
- data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
- data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
- data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
- data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
- data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
- data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
- data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
- data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
- data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
- data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
- data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
- data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
- data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
- data/lib/elkrb/layout/algorithms/layered.rb +548 -13
- data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
- data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
- data/lib/elkrb/layout/algorithms/radial.rb +188 -29
- data/lib/elkrb/layout/algorithms/random.rb +131 -17
- data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
- data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
- data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
- data/lib/elkrb/layout/algorithms/stress.rb +391 -84
- data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
- data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
- data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
- data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
- data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
- data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
- data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
- data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
- data/lib/elkrb/layout/edge_router.rb +533 -337
- data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
- data/lib/elkrb/layout/java_random.rb +59 -0
- data/lib/elkrb/layout/label_placer.rb +165 -157
- data/lib/elkrb/layout/layout_engine.rb +46 -23
- data/lib/elkrb/layout/node_index.rb +149 -0
- data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
- data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
- data/lib/elkrb/layout/polyomino/grid.rb +106 -0
- data/lib/elkrb/layout/polyomino/packer.rb +95 -0
- data/lib/elkrb/layout/polyomino/piece.rb +52 -0
- data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
- data/lib/elkrb/layout/spore/body.rb +65 -0
- data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
- data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
- data/lib/elkrb/layout/spore/gap.rb +76 -0
- data/lib/elkrb/layout/spore/geometry.rb +104 -0
- data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
- data/lib/elkrb/layout/spore/placement.rb +57 -0
- data/lib/elkrb/layout/spore/rays.rb +119 -0
- data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
- data/lib/elkrb/layout/spore/triangulation.rb +159 -0
- data/lib/elkrb/layout_flags.rb +69 -0
- data/lib/elkrb/options/decimal.rb +50 -0
- data/lib/elkrb/options/element_options.rb +43 -0
- data/lib/elkrb/options/elk_padding.rb +20 -14
- data/lib/elkrb/options/k_vector.rb +6 -7
- data/lib/elkrb/options/k_vector_chain.rb +12 -20
- data/lib/elkrb/options/option_map.rb +85 -0
- data/lib/elkrb/options/registry.rb +335 -0
- data/lib/elkrb/options/resolver.rb +151 -0
- data/lib/elkrb/options/spellings.rb +62 -0
- data/lib/elkrb/options/unhonoured_report.rb +194 -0
- data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
- data/lib/elkrb/parsers/elkt/parser.rb +424 -0
- data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
- data/lib/elkrb/parsers/elkt/token.rb +40 -0
- data/lib/elkrb/parsers/elkt_parser.rb +32 -230
- data/lib/elkrb/serializers/dot_serializer.rb +342 -54
- data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
- data/lib/elkrb/version.rb +1 -1
- data/lib/elkrb.rb +60 -61
- metadata +71 -49
- data/.rspec +0 -3
- data/.rubocop.yml +0 -11
- data/Gemfile +0 -13
- data/Rakefile +0 -64
- data/benchmarks/README.md +0 -172
- data/benchmarks/elkjs_benchmark.js +0 -140
- data/benchmarks/elkrb_benchmark.rb +0 -145
- data/benchmarks/fixtures/graphs.json +0 -10777
- data/benchmarks/generate_report.rb +0 -241
- data/benchmarks/generate_test_graphs.rb +0 -154
- data/benchmarks/results/elkrb_results.json +0 -280
- data/benchmarks/results/elkrb_summary.json +0 -285
- data/elkrb.gemspec +0 -39
- data/examples/dot_export_demo.rb +0 -133
- data/examples/hierarchical_graph.rb +0 -19
- data/examples/layout_constraints_demo.rb +0 -272
- data/examples/port_constraints_demo.rb +0 -291
- data/examples/self_loop_demo.rb +0 -391
- data/examples/simple_graph.rb +0 -50
- data/examples/spline_routing_demo.rb +0 -235
- data/lib/elkrb/graph/layout_options.rb +0 -247
- data/sig/elkrb/constraints.rbs +0 -114
- data/sig/elkrb/geometry.rbs +0 -61
- data/sig/elkrb/graph.rbs +0 -112
- data/sig/elkrb/layout.rbs +0 -107
- data/sig/elkrb/options.rbs +0 -81
- 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
|
-
#
|
|
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
|
-
#
|
|
10
|
-
#
|
|
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
|
-
@
|
|
15
|
-
@in_stack = {}
|
|
16
|
-
@edges_to_reverse = []
|
|
40
|
+
@index = index
|
|
17
41
|
end
|
|
18
42
|
|
|
19
43
|
def break_cycles
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
reverse_edges
|
|
29
|
-
|
|
30
|
-
@edges_to_reverse
|
|
63
|
+
reversed
|
|
31
64
|
end
|
|
32
65
|
|
|
33
66
|
private
|
|
34
67
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
125
|
+
source_ids.each do |source_id|
|
|
126
|
+
target_ids.each do |target_id|
|
|
127
|
+
next if source_id == target_id
|
|
63
128
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
129
|
+
adjacency[source_id] << [target_id, edge]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
67
132
|
end
|
|
68
133
|
|
|
69
|
-
|
|
134
|
+
adjacency
|
|
70
135
|
end
|
|
71
136
|
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
101
|
+
until stack.empty?
|
|
102
|
+
step_predecessor_stack(stack, predecessors, colors)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
66
105
|
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
next unless other_node.edges
|
|
186
|
+
@layers
|
|
187
|
+
end
|
|
84
188
|
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|