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
|
@@ -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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
72
|
-
|
|
357
|
+
def subtree_node(node, levels, floor)
|
|
358
|
+
{
|
|
73
359
|
node: node,
|
|
74
360
|
children: [],
|
|
75
|
-
level:
|
|
361
|
+
level: [levels.fetch(node.id), floor].max,
|
|
76
362
|
}
|
|
363
|
+
end
|
|
77
364
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
406
|
+
def advance_layout(frame, stack, placed, offsets)
|
|
407
|
+
child = frame.tree[:children][frame.index]
|
|
408
|
+
frame.index += 1
|
|
93
409
|
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
461
|
+
[right - x_offset + node_spacing, left, right]
|
|
103
462
|
end
|
|
104
463
|
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
last_child.x + last_child.width - x_offset + spacing_val
|
|
568
|
+
[edge, source, target]
|
|
134
569
|
end
|
|
135
570
|
|
|
136
|
-
def
|
|
137
|
-
|
|
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
|
-
|
|
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
|