elkrb 1.0.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.adoc +364 -0
  3. data/LICENSE +25 -0
  4. data/README.adoc +695 -557
  5. data/exe/elkrb +36 -2
  6. data/lib/elkrb/atomic_destination.rb +53 -0
  7. data/lib/elkrb/best_effort_write.rb +49 -0
  8. data/lib/elkrb/cli/layout_flag_options.rb +54 -0
  9. data/lib/elkrb/cli.rb +155 -82
  10. data/lib/elkrb/command_resolver.rb +186 -0
  11. data/lib/elkrb/commands/batch_command.rb +28 -5
  12. data/lib/elkrb/commands/convert_command.rb +22 -52
  13. data/lib/elkrb/commands/diagram_command.rb +234 -102
  14. data/lib/elkrb/commands/options_command.rb +87 -0
  15. data/lib/elkrb/commands/render_command.rb +6 -1
  16. data/lib/elkrb/commands/validate_command.rb +146 -91
  17. data/lib/elkrb/errors.rb +20 -1
  18. data/lib/elkrb/format_sniffer.rb +362 -0
  19. data/lib/elkrb/geometry/bezier.rb +14 -4
  20. data/lib/elkrb/geometry/point.rb +5 -6
  21. data/lib/elkrb/geometry/rectangle.rb +2 -2
  22. data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
  23. data/lib/elkrb/graph/edge.rb +89 -3
  24. data/lib/elkrb/graph/graph.rb +43 -15
  25. data/lib/elkrb/graph/label.rb +16 -2
  26. data/lib/elkrb/graph/node.rb +13 -2
  27. data/lib/elkrb/graph/node_constraints.rb +139 -8
  28. data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
  29. data/lib/elkrb/graph/port.rb +29 -21
  30. data/lib/elkrb/graph/read_only_mapping.rb +21 -0
  31. data/lib/elkrb/graphviz_wrapper.rb +152 -44
  32. data/lib/elkrb/layout/algorithm_registry.rb +65 -8
  33. data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
  34. data/lib/elkrb/layout/algorithms/box.rb +47 -20
  35. data/lib/elkrb/layout/algorithms/disco.rb +150 -61
  36. data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
  37. data/lib/elkrb/layout/algorithms/force.rb +145 -86
  38. data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
  39. data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
  40. data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
  41. data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
  42. data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
  43. data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
  44. data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
  45. data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
  46. data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
  47. data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
  48. data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
  49. data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
  50. data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
  51. data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
  52. data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
  53. data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
  54. data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
  55. data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
  56. data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
  57. data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
  58. data/lib/elkrb/layout/algorithms/layered.rb +548 -13
  59. data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
  60. data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
  61. data/lib/elkrb/layout/algorithms/radial.rb +188 -29
  62. data/lib/elkrb/layout/algorithms/random.rb +131 -17
  63. data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
  64. data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
  65. data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
  66. data/lib/elkrb/layout/algorithms/stress.rb +391 -84
  67. data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
  68. data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
  69. data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
  70. data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
  71. data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
  72. data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
  73. data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
  74. data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
  75. data/lib/elkrb/layout/edge_router.rb +533 -337
  76. data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
  77. data/lib/elkrb/layout/java_random.rb +59 -0
  78. data/lib/elkrb/layout/label_placer.rb +165 -157
  79. data/lib/elkrb/layout/layout_engine.rb +46 -23
  80. data/lib/elkrb/layout/node_index.rb +149 -0
  81. data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
  82. data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
  83. data/lib/elkrb/layout/polyomino/grid.rb +106 -0
  84. data/lib/elkrb/layout/polyomino/packer.rb +95 -0
  85. data/lib/elkrb/layout/polyomino/piece.rb +52 -0
  86. data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
  87. data/lib/elkrb/layout/spore/body.rb +65 -0
  88. data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
  89. data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
  90. data/lib/elkrb/layout/spore/gap.rb +76 -0
  91. data/lib/elkrb/layout/spore/geometry.rb +104 -0
  92. data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
  93. data/lib/elkrb/layout/spore/placement.rb +57 -0
  94. data/lib/elkrb/layout/spore/rays.rb +119 -0
  95. data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
  96. data/lib/elkrb/layout/spore/triangulation.rb +159 -0
  97. data/lib/elkrb/layout_flags.rb +69 -0
  98. data/lib/elkrb/options/decimal.rb +50 -0
  99. data/lib/elkrb/options/element_options.rb +43 -0
  100. data/lib/elkrb/options/elk_padding.rb +20 -14
  101. data/lib/elkrb/options/k_vector.rb +6 -7
  102. data/lib/elkrb/options/k_vector_chain.rb +12 -20
  103. data/lib/elkrb/options/option_map.rb +85 -0
  104. data/lib/elkrb/options/registry.rb +335 -0
  105. data/lib/elkrb/options/resolver.rb +151 -0
  106. data/lib/elkrb/options/spellings.rb +62 -0
  107. data/lib/elkrb/options/unhonoured_report.rb +194 -0
  108. data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
  109. data/lib/elkrb/parsers/elkt/parser.rb +424 -0
  110. data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
  111. data/lib/elkrb/parsers/elkt/token.rb +40 -0
  112. data/lib/elkrb/parsers/elkt_parser.rb +32 -230
  113. data/lib/elkrb/serializers/dot_serializer.rb +342 -54
  114. data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
  115. data/lib/elkrb/version.rb +1 -1
  116. data/lib/elkrb.rb +60 -61
  117. metadata +71 -49
  118. data/.rspec +0 -3
  119. data/.rubocop.yml +0 -11
  120. data/Gemfile +0 -13
  121. data/Rakefile +0 -64
  122. data/benchmarks/README.md +0 -172
  123. data/benchmarks/elkjs_benchmark.js +0 -140
  124. data/benchmarks/elkrb_benchmark.rb +0 -145
  125. data/benchmarks/fixtures/graphs.json +0 -10777
  126. data/benchmarks/generate_report.rb +0 -241
  127. data/benchmarks/generate_test_graphs.rb +0 -154
  128. data/benchmarks/results/elkrb_results.json +0 -280
  129. data/benchmarks/results/elkrb_summary.json +0 -285
  130. data/elkrb.gemspec +0 -39
  131. data/examples/dot_export_demo.rb +0 -133
  132. data/examples/hierarchical_graph.rb +0 -19
  133. data/examples/layout_constraints_demo.rb +0 -272
  134. data/examples/port_constraints_demo.rb +0 -291
  135. data/examples/self_loop_demo.rb +0 -391
  136. data/examples/simple_graph.rb +0 -50
  137. data/examples/spline_routing_demo.rb +0 -235
  138. data/lib/elkrb/graph/layout_options.rb +0 -247
  139. data/sig/elkrb/constraints.rbs +0 -114
  140. data/sig/elkrb/geometry.rbs +0 -61
  141. data/sig/elkrb/graph.rbs +0 -112
  142. data/sig/elkrb/layout.rbs +0 -107
  143. data/sig/elkrb/options.rbs +0 -81
  144. data/sig/elkrb.rbs +0 -32
@@ -17,53 +17,124 @@ module Elkrb
17
17
  def apply_port_constraints(graph)
18
18
  return unless graph.children
19
19
 
20
- graph.children.each do |node|
21
- process_node_ports(node)
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
- return unless node.width && node.height && node.width.positive? && node.height.positive?
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
- # Detect sides if not specified
35
- detect_port_sides(node)
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(node, side, ports)
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
- if port.side == Graph::Port::UNDEFINED
55
- detected_side = port.detect_side(node.width, node.height)
56
- port.side = detected_side
57
- end
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(&:side)
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
- if port.index >= 0
82
- port.index
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
- spacing = node.width / (count + 1).to_f
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
- spacing = node.height / (count + 1).to_f
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