elkrb 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -17,53 +17,124 @@ module Elkrb
|
|
|
17
17
|
def apply_port_constraints(graph)
|
|
18
18
|
return unless graph.children
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
end
|
|
20
|
+
placed_port_order.clear
|
|
21
|
+
process_port_level(graph.children, graph.edges, graph)
|
|
23
22
|
end
|
|
24
23
|
|
|
25
24
|
private
|
|
26
25
|
|
|
26
|
+
def process_port_level(nodes, edges, owner)
|
|
27
|
+
inferred_sides = inferred_port_sides(edges, owner)
|
|
28
|
+
nodes.each do |node|
|
|
29
|
+
process_node_ports(node, inferred_sides)
|
|
30
|
+
process_port_level(node.children, node.edges, node) if node.children
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
27
34
|
# Process ports for a single node
|
|
28
35
|
#
|
|
29
36
|
# @param node [Elkrb::Graph::Node] The node to process
|
|
30
|
-
def process_node_ports(node)
|
|
37
|
+
def process_node_ports(node, inferred_sides = {})
|
|
31
38
|
return unless node.ports && !node.ports.empty?
|
|
32
|
-
|
|
39
|
+
# Skip zero and non-finite dimensions: a zero axis has nothing to
|
|
40
|
+
# distribute along, and `[0, NaN].min` raises. Negative is fine.
|
|
41
|
+
return unless [node.width, node.height].all? { |dim| dim&.finite? && !dim.zero? }
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
detect_port_sides(node, inferred_sides)
|
|
44
|
+
constraint = @resolver.get("elk.portConstraints", node)
|
|
45
|
+
return if constraint == "FIXED_POS"
|
|
36
46
|
|
|
37
|
-
# Group ports by side
|
|
38
47
|
ports_by_side = group_ports_by_side(node.ports)
|
|
39
48
|
|
|
40
|
-
# Apply ordering within each side
|
|
41
49
|
ports_by_side.each do |side, ports|
|
|
42
|
-
order_ports_on_side(
|
|
50
|
+
order_ports_on_side(
|
|
51
|
+
node, side, ports, fixed: fixed_order?(constraint)
|
|
52
|
+
)
|
|
43
53
|
end
|
|
44
54
|
|
|
55
|
+
placed_port_order[node] = ports_by_side
|
|
56
|
+
|
|
45
57
|
# Position ports on node boundaries
|
|
46
58
|
position_ports_on_boundaries(node, ports_by_side)
|
|
47
59
|
end
|
|
48
60
|
|
|
61
|
+
# Place a node's ports again after its size changed. A node that
|
|
62
|
+
# #process_node_ports already ordered keeps that order: indices cannot
|
|
63
|
+
# be sorted a second time (an explicit and an assigned one collide)
|
|
64
|
+
# and positions cannot be read back (they tie when the size is
|
|
65
|
+
# subnormal). A node it skipped gets the one full pass instead.
|
|
66
|
+
#
|
|
67
|
+
# @param node [Elkrb::Graph::Node] The node whose ports to place again
|
|
68
|
+
def replace_node_ports(node)
|
|
69
|
+
ports_by_side = placed_port_order[node]
|
|
70
|
+
return process_node_ports(node) unless ports_by_side
|
|
71
|
+
return unless [node.width, node.height].all? { |dim| dim&.finite? && !dim.zero? }
|
|
72
|
+
|
|
73
|
+
position_ports_on_boundaries(node, ports_by_side)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Each node's ports per side, in the order #process_node_ports gave
|
|
77
|
+
# them. Keyed by identity: equal nodes would share one entry by value.
|
|
78
|
+
#
|
|
79
|
+
# @return [Hash{Elkrb::Graph::Node => Hash}]
|
|
80
|
+
def placed_port_order
|
|
81
|
+
@placed_port_order ||= {}.compare_by_identity
|
|
82
|
+
end
|
|
83
|
+
|
|
49
84
|
# Detect port sides for ports with UNDEFINED side
|
|
50
85
|
#
|
|
51
86
|
# @param node [Elkrb::Graph::Node] The node containing the ports
|
|
52
|
-
def detect_port_sides(node)
|
|
87
|
+
def detect_port_sides(node, inferred_sides = {})
|
|
53
88
|
node.ports.each do |port|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
89
|
+
configured = @resolver.get("elk.port.side", port, default: nil) ||
|
|
90
|
+
port.side
|
|
91
|
+
side = configured&.upcase
|
|
92
|
+
side = port.detect_side(node.width, node.height) if
|
|
93
|
+
side.nil? || side == Graph::Port::UNDEFINED
|
|
94
|
+
side = inferred_sides.fetch(port.id, side) if
|
|
95
|
+
side == Graph::Port::UNDEFINED
|
|
96
|
+
validate_port_side!(port, side)
|
|
97
|
+
port.side = side unless side == Graph::Port::UNDEFINED && port.side.nil?
|
|
58
98
|
end
|
|
59
99
|
end
|
|
60
100
|
|
|
101
|
+
def inferred_port_sides(edges, owner)
|
|
102
|
+
outgoing_side, incoming_side = endpoint_sides(owner)
|
|
103
|
+
(edges || []).each_with_object({}) do |edge, sides|
|
|
104
|
+
next if edge.sources == edge.targets
|
|
105
|
+
|
|
106
|
+
(edge.sources || []).each { |id| sides[id] ||= outgoing_side }
|
|
107
|
+
(edge.targets || []).each { |id| sides[id] ||= incoming_side }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def endpoint_sides(owner)
|
|
112
|
+
case @resolver.get("elk.direction", owner)
|
|
113
|
+
when "LEFT" then [Graph::Port::WEST, Graph::Port::EAST]
|
|
114
|
+
when "DOWN" then [Graph::Port::SOUTH, Graph::Port::NORTH]
|
|
115
|
+
when "UP" then [Graph::Port::NORTH, Graph::Port::SOUTH]
|
|
116
|
+
else [Graph::Port::EAST, Graph::Port::WEST]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def validate_port_side!(port, side)
|
|
121
|
+
return if port.valid_side?(side)
|
|
122
|
+
|
|
123
|
+
raise ArgumentError,
|
|
124
|
+
"Invalid port side: #{side}. Must be one of " \
|
|
125
|
+
"#{Graph::Port::SIDES.join(', ')}"
|
|
126
|
+
end
|
|
127
|
+
|
|
61
128
|
# Group ports by their side
|
|
62
129
|
#
|
|
63
130
|
# @param ports [Array<Elkrb::Graph::Port>] The ports to group
|
|
64
131
|
# @return [Hash<String, Array<Elkrb::Graph::Port>>] Ports grouped by side
|
|
65
132
|
def group_ports_by_side(ports)
|
|
66
|
-
ports.group_by
|
|
133
|
+
ports.group_by { |port| port.side || Graph::Port::UNDEFINED }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def fixed_order?(constraint)
|
|
137
|
+
%w[FIXED_SIDE FIXED_ORDER].include?(constraint)
|
|
67
138
|
end
|
|
68
139
|
|
|
69
140
|
# Order ports on a specific side
|
|
@@ -75,11 +146,14 @@ module Elkrb
|
|
|
75
146
|
# @param node [Elkrb::Graph::Node] The node containing the ports
|
|
76
147
|
# @param side [String] The side to order ports on
|
|
77
148
|
# @param ports [Array<Elkrb::Graph::Port>] The ports on this side
|
|
78
|
-
def order_ports_on_side(_node, side, ports)
|
|
149
|
+
def order_ports_on_side(_node, side, ports, fixed: false)
|
|
150
|
+
return order_fixed_ports(ports) if fixed
|
|
151
|
+
|
|
79
152
|
# Sort by index if specified, otherwise by position
|
|
80
153
|
ports.sort_by! do |port|
|
|
81
|
-
|
|
82
|
-
|
|
154
|
+
index = port.index || -1
|
|
155
|
+
if index >= 0
|
|
156
|
+
index
|
|
83
157
|
elsif [Graph::Port::NORTH, Graph::Port::SOUTH].include?(side)
|
|
84
158
|
# Horizontal sides: sort by x position
|
|
85
159
|
port.x || 0
|
|
@@ -91,49 +165,53 @@ module Elkrb
|
|
|
91
165
|
|
|
92
166
|
# Assign sequential indices to ports without explicit index
|
|
93
167
|
ports.each_with_index do |port, idx|
|
|
94
|
-
port.index = idx if port.index.negative?
|
|
168
|
+
port.index = idx if (port.index || -1).negative?
|
|
95
169
|
end
|
|
96
170
|
end
|
|
97
171
|
|
|
172
|
+
def order_fixed_ports(ports)
|
|
173
|
+
ordered = ports.each_with_index.sort_by do |(port, position)|
|
|
174
|
+
resolved_port_index(port) || position
|
|
175
|
+
end
|
|
176
|
+
ports.replace(ordered.map(&:first))
|
|
177
|
+
ports.each_with_index do |port, position|
|
|
178
|
+
port.index = resolved_port_index(port) || position
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def resolved_port_index(port)
|
|
183
|
+
@resolver.get("elk.port.index", port, default: nil) || port.index
|
|
184
|
+
end
|
|
185
|
+
|
|
98
186
|
# Position ports on node boundaries
|
|
99
187
|
#
|
|
100
188
|
# @param node [Elkrb::Graph::Node] The node containing the ports
|
|
101
189
|
# @param ports_by_side [Hash<String, Array<Elkrb::Graph::Port>>] Ports grouped by side
|
|
102
190
|
def position_ports_on_boundaries(node, ports_by_side)
|
|
191
|
+
# The real top/bottom and left/right local offsets -- for a negative
|
|
192
|
+
# height/width, local 0 is the real BOTTOM/RIGHT edge, so NORTH and
|
|
193
|
+
# SOUTH (and WEST/EAST) must pick whichever of 0/height (0/width) is
|
|
194
|
+
# actually the min/max, not always 0 for the "near" side.
|
|
195
|
+
top, bottom, left, right = [0, node.height].minmax + [0, node.width].minmax
|
|
196
|
+
|
|
103
197
|
# NORTH: top edge, distributed horizontally
|
|
104
198
|
if ports_by_side[Graph::Port::NORTH]
|
|
105
|
-
distribute_ports_horizontally(
|
|
106
|
-
node,
|
|
107
|
-
ports_by_side[Graph::Port::NORTH],
|
|
108
|
-
0,
|
|
109
|
-
)
|
|
199
|
+
distribute_ports_horizontally(node, ports_by_side[Graph::Port::NORTH], top)
|
|
110
200
|
end
|
|
111
201
|
|
|
112
202
|
# SOUTH: bottom edge, distributed horizontally
|
|
113
203
|
if ports_by_side[Graph::Port::SOUTH]
|
|
114
|
-
distribute_ports_horizontally(
|
|
115
|
-
node,
|
|
116
|
-
ports_by_side[Graph::Port::SOUTH],
|
|
117
|
-
node.height,
|
|
118
|
-
)
|
|
204
|
+
distribute_ports_horizontally(node, ports_by_side[Graph::Port::SOUTH], bottom)
|
|
119
205
|
end
|
|
120
206
|
|
|
121
207
|
# WEST: left edge, distributed vertically
|
|
122
208
|
if ports_by_side[Graph::Port::WEST]
|
|
123
|
-
distribute_ports_vertically(
|
|
124
|
-
node,
|
|
125
|
-
ports_by_side[Graph::Port::WEST],
|
|
126
|
-
0,
|
|
127
|
-
)
|
|
209
|
+
distribute_ports_vertically(node, ports_by_side[Graph::Port::WEST], left)
|
|
128
210
|
end
|
|
129
211
|
|
|
130
212
|
# EAST: right edge, distributed vertically
|
|
131
213
|
if ports_by_side[Graph::Port::EAST]
|
|
132
|
-
distribute_ports_vertically(
|
|
133
|
-
node,
|
|
134
|
-
ports_by_side[Graph::Port::EAST],
|
|
135
|
-
node.width,
|
|
136
|
-
)
|
|
214
|
+
distribute_ports_vertically(node, ports_by_side[Graph::Port::EAST], right)
|
|
137
215
|
end
|
|
138
216
|
end
|
|
139
217
|
|
|
@@ -144,10 +222,15 @@ module Elkrb
|
|
|
144
222
|
# @param y_pos [Float] The y position of the edge
|
|
145
223
|
def distribute_ports_horizontally(node, ports, y_pos)
|
|
146
224
|
count = ports.length
|
|
147
|
-
|
|
225
|
+
# Walk from the real left edge rightwards, so the ascending order
|
|
226
|
+
# #order_ports_on_side just sorted into is the order they land in --
|
|
227
|
+
# starting at local 0 reverses it when node.width is negative.
|
|
228
|
+
width = node.width
|
|
229
|
+
left = [0, width].min
|
|
230
|
+
spacing = (width.abs / (count + 1).to_f)
|
|
148
231
|
|
|
149
232
|
ports.each_with_index do |port, idx|
|
|
150
|
-
port.x = spacing * (idx + 1)
|
|
233
|
+
port.x = left + (spacing * (idx + 1)) - ((port.width || 0.0) / 2.0)
|
|
151
234
|
port.y = y_pos
|
|
152
235
|
port.offset = port.x
|
|
153
236
|
end
|
|
@@ -160,11 +243,15 @@ module Elkrb
|
|
|
160
243
|
# @param x_pos [Float] The x position of the edge
|
|
161
244
|
def distribute_ports_vertically(node, ports, x_pos)
|
|
162
245
|
count = ports.length
|
|
163
|
-
|
|
246
|
+
# Same reason as #distribute_ports_horizontally: start at the real
|
|
247
|
+
# top edge so a negative node.height does not reverse the order.
|
|
248
|
+
height = node.height
|
|
249
|
+
top = [0, height].min
|
|
250
|
+
spacing = (height.abs / (count + 1).to_f)
|
|
164
251
|
|
|
165
252
|
ports.each_with_index do |port, idx|
|
|
166
253
|
port.x = x_pos
|
|
167
|
-
port.y = spacing * (idx + 1)
|
|
254
|
+
port.y = top + (spacing * (idx + 1)) - ((port.height || 0.0) / 2.0)
|
|
168
255
|
port.offset = port.y
|
|
169
256
|
end
|
|
170
257
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
module Spore
|
|
6
|
+
# A node as SPOrE sees it: the node's box grown by the node spacing
|
|
7
|
+
# (the rectangle), its moving centre, and the centre it had when the
|
|
8
|
+
# current pass started (the key that edges and tree vertices use).
|
|
9
|
+
class Body
|
|
10
|
+
attr_reader :source, :x, :y, :width, :height, :center_x, :center_y,
|
|
11
|
+
:origin_x, :origin_y
|
|
12
|
+
|
|
13
|
+
def initialize(source, spacing)
|
|
14
|
+
@source = source
|
|
15
|
+
@width = source.width.to_f + spacing
|
|
16
|
+
@height = source.height.to_f + spacing
|
|
17
|
+
reposition(middle(source.x, source.width),
|
|
18
|
+
middle(source.y, source.height))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def origin
|
|
22
|
+
[origin_x, origin_y]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def translate(shift_x, shift_y)
|
|
26
|
+
@center_x += shift_x
|
|
27
|
+
@center_y += shift_y
|
|
28
|
+
@x += shift_x
|
|
29
|
+
@y += shift_y
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def center_at(new_x, new_y)
|
|
33
|
+
translate(new_x - center_x, new_y - center_y)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Starts the next pass from where the last one left the node.
|
|
37
|
+
def rebase
|
|
38
|
+
@origin_x = x + (width / 2.0)
|
|
39
|
+
@origin_y = y + (height / 2.0)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Moves the node a hair off its spot, so that two nodes that started
|
|
43
|
+
# on the same point get distinct centres and every tree edge has a
|
|
44
|
+
# direction to stretch along.
|
|
45
|
+
def nudge(rng)
|
|
46
|
+
reposition(center_x + ((rng.rand - 0.5) * 0.001),
|
|
47
|
+
center_y + ((rng.rand - 0.5) * 0.001))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def middle(low, size)
|
|
53
|
+
low.to_f + (size.to_f / 2.0)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reposition(new_x, new_y)
|
|
57
|
+
@center_x = @origin_x = new_x
|
|
58
|
+
@center_y = @origin_y = new_y
|
|
59
|
+
@x = new_x - (width / 2.0)
|
|
60
|
+
@y = new_y - (height / 2.0)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "gap"
|
|
4
|
+
require_relative "geometry"
|
|
5
|
+
|
|
6
|
+
module Elkrb
|
|
7
|
+
module Layout
|
|
8
|
+
module Spore
|
|
9
|
+
# Compacts a spanning tree depth first: once a node's subtrees are
|
|
10
|
+
# compacted, each is slid as one piece toward the node until it would
|
|
11
|
+
# touch something, so the gaps between tree neighbours close.
|
|
12
|
+
class DepthFirstCompaction
|
|
13
|
+
# :free slides along the line between the two centres; the others
|
|
14
|
+
# slide along one axis only. :orthogonal picks the axis the centres
|
|
15
|
+
# are further apart on.
|
|
16
|
+
MODES = %i[free orthogonal horizontal vertical].freeze
|
|
17
|
+
|
|
18
|
+
# @param tree [SpanningTree::Branch] the tree, rooted at the branch
|
|
19
|
+
# that stays put
|
|
20
|
+
# @param mode [Symbol] one of MODES
|
|
21
|
+
def self.compact(tree, mode: :free)
|
|
22
|
+
new(tree, mode).compact
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(tree, mode)
|
|
26
|
+
@root = tree
|
|
27
|
+
@mode = mode
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def compact
|
|
31
|
+
compact_branch(@root)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def compact_branch(branch)
|
|
37
|
+
branch.children.each { |child| compact_branch(child) }
|
|
38
|
+
close_gaps(branch)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def close_gaps(branch)
|
|
42
|
+
branch.children.each { |child| close_gap(branch.body, child) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def close_gap(parent, child)
|
|
46
|
+
slide = slide_toward(parent, child.body)
|
|
47
|
+
limit = limit_by_others(@root, child, slide, length(slide))
|
|
48
|
+
translate(child, scale_to(slide, limit))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The way and the distance the child would slide to meet its parent.
|
|
52
|
+
def slide_toward(parent, child)
|
|
53
|
+
slide = [parent.center_x - child.center_x,
|
|
54
|
+
parent.center_y - child.center_y]
|
|
55
|
+
return scale_to(slide, Gap.underlap(parent, child)) if @mode == :free
|
|
56
|
+
|
|
57
|
+
slide_along(along_x?(slide) ? 0 : 1, parent, child, slide)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def along_x?(slide)
|
|
61
|
+
case @mode
|
|
62
|
+
when :horizontal then true
|
|
63
|
+
when :vertical then false
|
|
64
|
+
else slide[0].abs >= slide[1].abs
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Slides along axis +index+ (0 = x, 1 = y) only; the child stops at
|
|
69
|
+
# the parent's side when they share a stretch of the other axis.
|
|
70
|
+
def slide_along(index, parent, child, slide)
|
|
71
|
+
line = [0.0, 0.0].tap { |vector| vector[index] = slide[index] }
|
|
72
|
+
along, across = Geometry.axes(parent, child).rotate(index)
|
|
73
|
+
return line unless shares_stretch?(across)
|
|
74
|
+
|
|
75
|
+
scale_to(line, along.separation)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def shares_stretch?(axis)
|
|
79
|
+
axis.low_b + axis.size_b > axis.low_a &&
|
|
80
|
+
axis.low_b < axis.low_a + axis.size_a
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The shortest of +limit+ and the distance from every body outside
|
|
84
|
+
# the child's own subtree to the bodies below the child.
|
|
85
|
+
def limit_by_others(branch, child, slide, limit)
|
|
86
|
+
limit = [limit, limit_by_below(branch.body, child, slide, limit)].min
|
|
87
|
+
branch.children.each do |sub|
|
|
88
|
+
next if sub.equal?(child)
|
|
89
|
+
|
|
90
|
+
limit = [limit, limit_by_others(sub, child, slide, limit)].min
|
|
91
|
+
end
|
|
92
|
+
limit
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Looks at the bodies below +branch+, not at +branch+ itself.
|
|
96
|
+
def limit_by_below(other, branch, slide, limit)
|
|
97
|
+
branch.children.each do |sub|
|
|
98
|
+
if Gap.touch?(other, sub.body)
|
|
99
|
+
return 0 if Gap.pressed?(other, sub.body, slide)
|
|
100
|
+
else
|
|
101
|
+
limit = [limit, Gap.distance(other, sub.body, slide)].min
|
|
102
|
+
end
|
|
103
|
+
limit = [limit, limit_by_below(other, sub, slide, limit)].min
|
|
104
|
+
end
|
|
105
|
+
limit
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def translate(branch, shift)
|
|
109
|
+
branch.body.translate(*shift)
|
|
110
|
+
branch.children.each { |sub| translate(sub, shift) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def length(vector)
|
|
114
|
+
Math.sqrt((vector[0] * vector[0]) + (vector[1] * vector[1]))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Same direction, new length (a zero vector stays zero).
|
|
118
|
+
def scale_to(vector, new_length)
|
|
119
|
+
size = length(vector)
|
|
120
|
+
return vector unless size.positive?
|
|
121
|
+
|
|
122
|
+
[vector[0] / size * new_length, vector[1] / size * new_length]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "gap"
|
|
4
|
+
require_relative "geometry"
|
|
5
|
+
|
|
6
|
+
module Elkrb
|
|
7
|
+
module Layout
|
|
8
|
+
module Spore
|
|
9
|
+
# The cost of a spanning-tree edge between two bodies, by the names ELK
|
|
10
|
+
# gives its cost functions. Costs read the bodies' positions from
|
|
11
|
+
# before the pass moved anything.
|
|
12
|
+
module EdgeCost
|
|
13
|
+
NAMES = %w[CENTER_DISTANCE CIRCLE_UNDERLAP RECTANGLE_UNDERLAP
|
|
14
|
+
INVERTED_OVERLAP MINIMUM_ROOT_DISTANCE].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param name [String] one of NAMES
|
|
19
|
+
# @param root [Body] the body the tree grows from
|
|
20
|
+
# @return [Proc] takes two bodies, returns their edge's cost
|
|
21
|
+
def for(name, root)
|
|
22
|
+
case name
|
|
23
|
+
when "CENTER_DISTANCE" then method(:center_distance)
|
|
24
|
+
when "CIRCLE_UNDERLAP" then method(:circle_underlap)
|
|
25
|
+
when "RECTANGLE_UNDERLAP" then Gap.method(:underlap)
|
|
26
|
+
when "INVERTED_OVERLAP" then method(:inverted_overlap)
|
|
27
|
+
when "MINIMUM_ROOT_DISTANCE" then root_distance(root)
|
|
28
|
+
else raise ArgumentError, "unknown cost function #{name.inspect}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def center_distance(first, second)
|
|
33
|
+
Geometry.distance(first.origin_x, first.origin_y,
|
|
34
|
+
second.origin_x, second.origin_y)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The distance between the circles around the two rectangles, each
|
|
38
|
+
# reaching from its centre to its top left corner.
|
|
39
|
+
def circle_underlap(first, second)
|
|
40
|
+
center_distance(first, second) - reach(first) - reach(second)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reach(body)
|
|
44
|
+
Geometry.distance(body.origin_x, body.origin_y, body.x, body.y)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def root_distance(root)
|
|
48
|
+
lambda do |first, second|
|
|
49
|
+
[first, second].map do |body|
|
|
50
|
+
Geometry.distance(body.origin_x, body.origin_y,
|
|
51
|
+
root.center_x, root.center_y)
|
|
52
|
+
end.min
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Negative and growing with the overlap when the rectangles overlap,
|
|
57
|
+
# so the most overlapped pairs join the tree first.
|
|
58
|
+
def inverted_overlap(first, second)
|
|
59
|
+
distance = Geometry.shortest_distance(first, second)
|
|
60
|
+
return distance if distance >= 0
|
|
61
|
+
|
|
62
|
+
between = Geometry.distance(first.center_x, first.center_y,
|
|
63
|
+
second.center_x, second.center_y)
|
|
64
|
+
-(Geometry.overlap(first, second) - 1) * between
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "geometry"
|
|
4
|
+
require_relative "rays"
|
|
5
|
+
|
|
6
|
+
module Elkrb
|
|
7
|
+
module Layout
|
|
8
|
+
module Spore
|
|
9
|
+
# How far apart two bodies' rectangles are, in the three senses the
|
|
10
|
+
# tree compaction needs.
|
|
11
|
+
module Gap
|
|
12
|
+
# Corner pairs that make up a rectangle's four sides, indexing
|
|
13
|
+
# [top left, bottom left, top right, bottom right].
|
|
14
|
+
EDGE_CORNERS = [[0, 2], [0, 1], [3, 2], [3, 1]].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# How far the rectangles can be pushed together along the line
|
|
19
|
+
# between their centres before they touch (0 when their reach
|
|
20
|
+
# overlaps on both axes).
|
|
21
|
+
def underlap(first, second)
|
|
22
|
+
axes = Geometry.axes(first, second)
|
|
23
|
+
scale = axes.map { |axis| reach_scale(axis) }.min
|
|
24
|
+
centres = Math.sqrt(axes.sum { |axis| axis.center_distance**2 })
|
|
25
|
+
(1 - scale) * centres
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# 1 when the rectangles' reach overlaps on this axis, else the share
|
|
29
|
+
# of the centre distance left once they touch.
|
|
30
|
+
def reach_scale(axis)
|
|
31
|
+
axis.within_reach? ? 1 : 1 - axis.ratio
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Whether the rectangles share a point, give or take rounding noise.
|
|
35
|
+
def touch?(first, second)
|
|
36
|
+
Geometry.axes(first, second).all? do |axis|
|
|
37
|
+
Geometry.fuzzy_compare(axis.separation, 0.0,
|
|
38
|
+
Geometry::FUZZINESS) <= 0
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether first's side lies exactly on second's, on the side that a
|
|
43
|
+
# push along +direction+ would drive them into each other.
|
|
44
|
+
def pressed?(first, second, direction)
|
|
45
|
+
Geometry.axes(first, second).zip(direction).any? do |axis, push|
|
|
46
|
+
pressed_on?(axis, push)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def pressed_on?(axis, push)
|
|
51
|
+
(push.negative? && flush?(axis.low_b, axis.low_a + axis.size_a)) ||
|
|
52
|
+
(push.positive? && flush?(axis.low_b + axis.size_b, axis.low_a))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def flush?(left, right)
|
|
56
|
+
Geometry.fuzzy_compare(left, right, Geometry::FUZZINESS).zero?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# How far +second+ can move along +direction+ before it hits +first+
|
|
60
|
+
# (Infinity when it never does).
|
|
61
|
+
def distance(first, second, direction)
|
|
62
|
+
edges(first).product(edges(second)).map do |one, other|
|
|
63
|
+
Rays.distance(*one, *other, direction)
|
|
64
|
+
end.min
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def edges(body)
|
|
68
|
+
xs = [body.x, body.x + body.width]
|
|
69
|
+
ys = [body.y, body.y + body.height]
|
|
70
|
+
corners = xs.product(ys)
|
|
71
|
+
EDGE_CORNERS.map { |from, to| [corners[from], corners[to]] }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|