elkrb 1.0.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.adoc +364 -0
  3. data/LICENSE +25 -0
  4. data/README.adoc +695 -557
  5. data/exe/elkrb +36 -2
  6. data/lib/elkrb/atomic_destination.rb +53 -0
  7. data/lib/elkrb/best_effort_write.rb +49 -0
  8. data/lib/elkrb/cli/layout_flag_options.rb +54 -0
  9. data/lib/elkrb/cli.rb +155 -82
  10. data/lib/elkrb/command_resolver.rb +186 -0
  11. data/lib/elkrb/commands/batch_command.rb +28 -5
  12. data/lib/elkrb/commands/convert_command.rb +22 -52
  13. data/lib/elkrb/commands/diagram_command.rb +234 -102
  14. data/lib/elkrb/commands/options_command.rb +87 -0
  15. data/lib/elkrb/commands/render_command.rb +6 -1
  16. data/lib/elkrb/commands/validate_command.rb +146 -91
  17. data/lib/elkrb/errors.rb +20 -1
  18. data/lib/elkrb/format_sniffer.rb +362 -0
  19. data/lib/elkrb/geometry/bezier.rb +14 -4
  20. data/lib/elkrb/geometry/point.rb +5 -6
  21. data/lib/elkrb/geometry/rectangle.rb +2 -2
  22. data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
  23. data/lib/elkrb/graph/edge.rb +89 -3
  24. data/lib/elkrb/graph/graph.rb +43 -15
  25. data/lib/elkrb/graph/label.rb +16 -2
  26. data/lib/elkrb/graph/node.rb +13 -2
  27. data/lib/elkrb/graph/node_constraints.rb +139 -8
  28. data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
  29. data/lib/elkrb/graph/port.rb +29 -21
  30. data/lib/elkrb/graph/read_only_mapping.rb +21 -0
  31. data/lib/elkrb/graphviz_wrapper.rb +152 -44
  32. data/lib/elkrb/layout/algorithm_registry.rb +65 -8
  33. data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
  34. data/lib/elkrb/layout/algorithms/box.rb +47 -20
  35. data/lib/elkrb/layout/algorithms/disco.rb +150 -61
  36. data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
  37. data/lib/elkrb/layout/algorithms/force.rb +145 -86
  38. data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
  39. data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
  40. data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
  41. data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
  42. data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
  43. data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
  44. data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
  45. data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
  46. data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
  47. data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
  48. data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
  49. data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
  50. data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
  51. data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
  52. data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
  53. data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
  54. data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
  55. data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
  56. data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
  57. data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
  58. data/lib/elkrb/layout/algorithms/layered.rb +548 -13
  59. data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
  60. data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
  61. data/lib/elkrb/layout/algorithms/radial.rb +188 -29
  62. data/lib/elkrb/layout/algorithms/random.rb +131 -17
  63. data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
  64. data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
  65. data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
  66. data/lib/elkrb/layout/algorithms/stress.rb +391 -84
  67. data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
  68. data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
  69. data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
  70. data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
  71. data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
  72. data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
  73. data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
  74. data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
  75. data/lib/elkrb/layout/edge_router.rb +533 -337
  76. data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
  77. data/lib/elkrb/layout/java_random.rb +59 -0
  78. data/lib/elkrb/layout/label_placer.rb +165 -157
  79. data/lib/elkrb/layout/layout_engine.rb +46 -23
  80. data/lib/elkrb/layout/node_index.rb +149 -0
  81. data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
  82. data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
  83. data/lib/elkrb/layout/polyomino/grid.rb +106 -0
  84. data/lib/elkrb/layout/polyomino/packer.rb +95 -0
  85. data/lib/elkrb/layout/polyomino/piece.rb +52 -0
  86. data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
  87. data/lib/elkrb/layout/spore/body.rb +65 -0
  88. data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
  89. data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
  90. data/lib/elkrb/layout/spore/gap.rb +76 -0
  91. data/lib/elkrb/layout/spore/geometry.rb +104 -0
  92. data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
  93. data/lib/elkrb/layout/spore/placement.rb +57 -0
  94. data/lib/elkrb/layout/spore/rays.rb +119 -0
  95. data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
  96. data/lib/elkrb/layout/spore/triangulation.rb +159 -0
  97. data/lib/elkrb/layout_flags.rb +69 -0
  98. data/lib/elkrb/options/decimal.rb +50 -0
  99. data/lib/elkrb/options/element_options.rb +43 -0
  100. data/lib/elkrb/options/elk_padding.rb +20 -14
  101. data/lib/elkrb/options/k_vector.rb +6 -7
  102. data/lib/elkrb/options/k_vector_chain.rb +12 -20
  103. data/lib/elkrb/options/option_map.rb +85 -0
  104. data/lib/elkrb/options/registry.rb +335 -0
  105. data/lib/elkrb/options/resolver.rb +151 -0
  106. data/lib/elkrb/options/spellings.rb +62 -0
  107. data/lib/elkrb/options/unhonoured_report.rb +194 -0
  108. data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
  109. data/lib/elkrb/parsers/elkt/parser.rb +424 -0
  110. data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
  111. data/lib/elkrb/parsers/elkt/token.rb +40 -0
  112. data/lib/elkrb/parsers/elkt_parser.rb +32 -230
  113. data/lib/elkrb/serializers/dot_serializer.rb +342 -54
  114. data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
  115. data/lib/elkrb/version.rb +1 -1
  116. data/lib/elkrb.rb +60 -61
  117. metadata +71 -49
  118. data/.rspec +0 -3
  119. data/.rubocop.yml +0 -11
  120. data/Gemfile +0 -13
  121. data/Rakefile +0 -64
  122. data/benchmarks/README.md +0 -172
  123. data/benchmarks/elkjs_benchmark.js +0 -140
  124. data/benchmarks/elkrb_benchmark.rb +0 -145
  125. data/benchmarks/fixtures/graphs.json +0 -10777
  126. data/benchmarks/generate_report.rb +0 -241
  127. data/benchmarks/generate_test_graphs.rb +0 -154
  128. data/benchmarks/results/elkrb_results.json +0 -280
  129. data/benchmarks/results/elkrb_summary.json +0 -285
  130. data/elkrb.gemspec +0 -39
  131. data/examples/dot_export_demo.rb +0 -133
  132. data/examples/hierarchical_graph.rb +0 -19
  133. data/examples/layout_constraints_demo.rb +0 -272
  134. data/examples/port_constraints_demo.rb +0 -291
  135. data/examples/self_loop_demo.rb +0 -391
  136. data/examples/simple_graph.rb +0 -50
  137. data/examples/spline_routing_demo.rb +0 -235
  138. data/lib/elkrb/graph/layout_options.rb +0 -247
  139. data/sig/elkrb/constraints.rbs +0 -114
  140. data/sig/elkrb/geometry.rbs +0 -61
  141. data/sig/elkrb/graph.rbs +0 -112
  142. data/sig/elkrb/layout.rbs +0 -107
  143. data/sig/elkrb/options.rbs +0 -81
  144. data/sig/elkrb.rbs +0 -32
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base_algorithm"
4
+ require_relative "../node_index"
4
5
  require_relative "layered/cycle_breaker"
6
+ require_relative "layered/components"
7
+ require_relative "layered/crossing_minimizer"
5
8
  require_relative "layered/layer_assigner"
6
9
  require_relative "layered/node_placer"
10
+ require_relative "layered/orthogonal_router"
7
11
 
8
12
  module Elkrb
9
13
  module Layout
@@ -12,9 +16,13 @@ module Elkrb
12
16
  #
13
17
  # The flagship algorithm for hierarchical graph layout.
14
18
  # Implements the Sugiyama framework in phases:
15
- # 1. Cycle breaking - make the graph acyclic
16
- # 2. Layer assignment - assign nodes to horizontal layers
17
- # 3. Node placement - position nodes within layers
19
+ # 1. Cycle breaking - find the back edges, WITHOUT making the graph
20
+ # acyclic. The caller's edges are handed back exactly as written;
21
+ # the reversal is a private orientation the next phase borrows.
22
+ # 2. Layer assignment - assign nodes to horizontal layers, reading
23
+ # each back edge in its reversed direction
24
+ # 3. Crossing minimization - reorder nodes within assigned layers
25
+ # 4. Node placement - position nodes within layers
18
26
  #
19
27
  # Ideal for:
20
28
  # - UML class diagrams
@@ -22,26 +30,553 @@ module Elkrb
22
30
  # - Data flow diagrams
23
31
  # - Organization charts
24
32
  # - Any directed acyclic graph
33
+ #
34
+ # The three bang validators below (raise_hyperedge!,
35
+ # raise_missing_endpoint!, validate_simple_edge!) are argument
36
+ # validators, not the dangerous/safe pair MissingSafeMethod expects --
37
+ # same reasoning as GraphvizWrapper's own exemption, see that file's
38
+ # comment. No nested class here to inherit this and need its own reset.
39
+ # rubocop:disable Layout/LineLength
40
+ # :reek:MissingSafeMethod { exclude: [ raise_hyperedge!, raise_missing_endpoint!, validate_simple_edge! ] }
41
+ # rubocop:enable Layout/LineLength
25
42
  class LayeredAlgorithm < BaseAlgorithm
43
+ # BaseAlgorithm intentionally skips layout_flat when a deserialized
44
+ # graph omits `children`. Validate that public entry point here so an
45
+ # unsupported edge cannot silently pass through untouched.
46
+ def layout(graph)
47
+ validate_edges(NodeIndex.build(graph)) unless graph.children
48
+ super
49
+ end
50
+
26
51
  def layout_flat(graph, _options = {})
52
+ @routed_layers = nil
53
+ @parts = []
54
+ @level_size = graph.children.to_a.length
55
+ index = NodeIndex.build(graph)
56
+ validate_edges(index)
27
57
  return graph if graph.children.nil? || graph.children.empty?
28
58
 
29
- # Phase 1: Break cycles
30
- cycle_breaker = Layered::CycleBreaker.new(graph)
31
- cycle_breaker.break_cycles
59
+ @parts = lay_out_components(graph, index)
60
+ @dummy_slots = @parts.flat_map(&:slots)
61
+ @routed_layers = [@parts.flat_map(&:layers), index]
62
+ graph
63
+ end
64
+
65
+ private
66
+
67
+ # A graph with several connected components is laid out one component
68
+ # at a time and packed afterwards, as ELK does.
69
+ def lay_out_components(graph, index)
70
+ parts = []
71
+ if option("elk.separateConnectedComponents")
72
+ parts = Layered::Components::Splitter.new(graph, index).graphs
73
+ end
74
+ return [lay_out_part(graph, index)] if parts.length < 2
75
+
76
+ laid = parts.map { |part| lay_out_part(part, NodeIndex.build(part)) }
77
+ pack_components(graph, laid)
78
+ laid
79
+ end
80
+
81
+ def lay_out_part(graph, index)
82
+ layers = assign_layers(graph, index)
32
83
 
33
- # Phase 2: Assign layers
34
- layer_assigner = Layered::LayerAssigner.new(graph)
35
- layers = layer_assigner.assign_layers
84
+ # Phase 3: Minimize crossings within the assigned layers
85
+ layers = minimize_crossings(graph, layers, index)
86
+ @dummy_slots = dummy_slots(layers)
36
87
 
37
- # Phase 3: Place nodes
38
- node_placer = Layered::NodePlacer.new(graph, layers, @options)
39
- node_placer.place_nodes
88
+ # Phase 4: Place nodes
89
+ place_nodes(graph, layers, index)
40
90
 
41
91
  # Apply padding and set graph dimensions
92
+ apply_padding_with_dummies(graph)
93
+ Layered::Components::Part.new(
94
+ graph, layers, index, @port_order, @dummy_slots
95
+ )
96
+ end
97
+
98
+ def pack_components(graph, parts)
99
+ packer = row_packer(parts)
100
+ offsets = packer.offsets
101
+ parts.zip(offsets) { |part, (left, top)| move_part(part, left, top) }
102
+ size_packed(graph, *packer.size(offsets))
103
+ end
104
+
105
+ def size_packed(graph, width, height)
106
+ graph.width = width + padding[:left] + padding[:right]
107
+ graph.height = height + padding[:top] + padding[:bottom]
108
+ end
109
+
110
+ def row_packer(parts)
111
+ Layered::Components::RowPacker.new(
112
+ parts.map { |part| content_size(part.graph) },
113
+ spacing: option("elk.spacing.componentComponent").to_f,
114
+ aspect_ratio: option("elk.aspectRatio").to_f,
115
+ )
116
+ end
117
+
118
+ def content_size(part_graph)
119
+ [part_graph.width - padding[:left] - padding[:right],
120
+ part_graph.height - padding[:top] - padding[:bottom]]
121
+ end
122
+
123
+ def move_part(part, shift_x, shift_y)
124
+ [*part.graph.children, *part.slots].each do |item|
125
+ item.x += shift_x
126
+ item.y += shift_y
127
+ end
128
+ end
129
+
130
+ def assign_layers(graph, index)
131
+ # Phase 1: Find the back edges
132
+ reversed_edges = Layered::CycleBreaker.new(graph, index).break_cycles
133
+
134
+ # Phase 2: Assign layers and insert long-edge dummy slots
135
+ Layered::LayerAssigner.new(
136
+ graph, index, reversed_edges, resolver: @resolver
137
+ ).assign_layers
138
+ end
139
+
140
+ def apply_edge_routing(graph)
141
+ super
142
+ routed = route_orthogonal_edges(graph)
143
+ add_long_edge_bends(graph, routed)
144
+ end
145
+
146
+ # Replaces the generic route of every edge that crosses a layer gap
147
+ # with the layer-gap route, when its style is ORTHOGONAL.
148
+ #
149
+ # @return [Array<Graph::Edge>] the edges it routed
150
+ def route_orthogonal_edges(graph)
151
+ return [] unless @routed_layers
152
+
153
+ routes = orthogonal_routes
154
+ graph.edges.to_a.select do |edge|
155
+ route = routes[edge]
156
+ next false unless route && level_edge?(edge)
157
+ next false unless orthogonal_edge?(graph, edge)
158
+
159
+ apply_route(edge, route, graph)
160
+ true
161
+ end
162
+ end
163
+
164
+ def orthogonal_routes
165
+ @parts.map { |part| orthogonal_router(part) }
166
+ .map { |router| router.routes(method(:along_range)) }
167
+ .reduce({}, :merge)
168
+ end
169
+
170
+ # A cross-hierarchy edge names a nested node; its route is not this
171
+ # level's to draw.
172
+ def level_edge?(edge)
173
+ index = @routed_layers.last
174
+ [edge.sources.first, edge.targets.first].all? { |id| index.node(id) }
175
+ end
176
+
177
+ def orthogonal_edge?(graph, edge)
178
+ get_edge_routing_style(graph, edge) == "ORTHOGONAL"
179
+ end
180
+
181
+ def apply_route(edge, route, graph)
182
+ place_junctions(edge, route.junctions)
183
+ section = reset_section(edge, graph)
184
+ start, *bends, finish = route.points
185
+ section.start_point = point_at(start)
186
+ section.end_point = point_at(finish)
187
+ section.bend_points = []
188
+ bends.each { |x, y| section.add_bend_point(x, y) }
189
+ end
190
+
191
+ def place_junctions(edge, junctions)
192
+ edge.junction_points = junctions.map { |point| point_at(point) }
193
+ end
194
+
195
+ def point_at(coordinates)
196
+ Geometry::Point.new(x: coordinates[0], y: coordinates[1])
197
+ end
198
+
199
+ def orthogonal_router(part)
200
+ Layered::OrthogonalRouter.new(
201
+ part.layers, part.port_order, part.index,
202
+ direction: layer_direction, measure: router_measure
203
+ )
204
+ end
205
+
206
+ def router_measure
207
+ Layered::OrthogonalRouter::Measure.new(
208
+ start: method(:cross_start), extent: method(:cross_extent),
209
+ port: method(:port_cross_extent)
210
+ )
211
+ end
212
+
213
+ def layer_direction
214
+ direction = option("elk.direction")
215
+ direction == "UNDEFINED" ? "RIGHT" : direction
216
+ end
217
+
218
+ def vertical_layers?
219
+ %w[DOWN UP].include?(layer_direction)
220
+ end
221
+
222
+ def along_range(item)
223
+ start, size = along_of(item)
224
+ start ||= 0.0
225
+ [start, start + (size || 0.0)]
226
+ end
227
+
228
+ def along_of(item)
229
+ vertical_layers? ? [item.y, item.height] : [item.x, item.width]
230
+ end
231
+
232
+ # The generic router bends midway between the two nodes. Nodes lean
233
+ # toward one side of their layer, so a bend at a node's own edge can
234
+ # land inside a layer neighbour; bend midway between the layers.
235
+ def horizontal_gap(source, target, *)
236
+ layer_gap(source, target, :x, :width) || super
237
+ end
238
+
239
+ def vertical_gap(source, target, *)
240
+ layer_gap(source, target, :y, :height) || super
241
+ end
242
+
243
+ def layer_gap(source, target, coordinate, size)
244
+ return unless @routed_layers && vertical_layers? == (coordinate == :y)
245
+
246
+ bounds = layer_bounds
247
+ held = [source, target].map do |rectangle|
248
+ layer_holding(bounds, rectangle, coordinate, size)
249
+ end
250
+ midway(bounds, *held)
251
+ end
252
+
253
+ def midway(bounds, from, to)
254
+ return unless from && to && (from - to).abs == 1
255
+
256
+ earlier, later = [from, to].minmax
257
+ (bounds[earlier].last + bounds[later].first) / 2.0
258
+ end
259
+
260
+ def layer_bounds
261
+ @routed_layers.first.map do |items|
262
+ ranges = items.map { |item| along_range(item) }
263
+ [ranges.map(&:first).min, ranges.map(&:last).max]
264
+ end
265
+ end
266
+
267
+ def layer_holding(bounds, rectangle, coordinate, size)
268
+ low = rectangle.public_send(coordinate)
269
+ high = low + rectangle.public_send(size)
270
+ bounds.index do |first, last|
271
+ first && low >= first - 1e-6 && high <= last + 1e-6
272
+ end
273
+ end
274
+
275
+ def cross_extent(item)
276
+ thickness = Layered::NodePlacer::EDGE_THICKNESS
277
+ return thickness if item.respond_to?(:dummy?)
278
+
279
+ (vertical_layers? ? item.width : item.height) || 0
280
+ end
281
+
282
+ def cross_start(item)
283
+ start = (vertical_layers? ? item.x : item.y) || 0.0
284
+ return start unless item.respond_to?(:dummy?)
285
+
286
+ spare = cross_slot_size(item) - Layered::NodePlacer::EDGE_THICKNESS
287
+ start + (spare / 2.0)
288
+ end
289
+
290
+ def cross_slot_size(slot)
291
+ vertical_layers? ? slot.width : slot.height
292
+ end
293
+
294
+ def port_cross_extent(port)
295
+ return 0 unless port.declared
296
+
297
+ (vertical_layers? ? port.declared.width : port.declared.height) || 0
298
+ end
299
+
300
+ def apply_padding_with_dummies(graph)
301
+ shift_x, shift_y = padding_shift(graph)
302
+ shift_dummy_slots(shift_x, shift_y)
303
+ include_long_edges(graph)
304
+ end
305
+
306
+ # A long edge's lane can run beyond the nodes; the padding is
307
+ # measured from the lane, not from the nearest node.
308
+ def include_long_edges(graph)
309
+ return if @dummy_slots.empty?
310
+
311
+ vertical = %w[DOWN UP].include?(option("elk.direction"))
312
+ before, after = lane_overhang(graph, vertical)
313
+ .map { |amount| [amount, 0].max }
314
+ shift_cross(graph, vertical, before)
315
+ grow_cross(graph, vertical, before + after)
316
+ end
317
+
318
+ # How far the lanes reach past the padded content on each end of
319
+ # the cross axis; negative when they stay inside.
320
+ def lane_overhang(graph, vertical)
321
+ low, high = @dummy_slots.map { |slot| lane_centre(slot, vertical) }
322
+ .minmax
323
+ reach = Layered::NodePlacer::EDGE_THICKNESS / 2.0
324
+ start, stop = content_range(graph, vertical)
325
+ [start - (low - reach), high + reach - stop]
326
+ end
327
+
328
+ def content_range(graph, vertical)
329
+ lead, trail = padding.values_at(*cross_sides(vertical))
330
+ [lead, (vertical ? graph.width : graph.height) - trail]
331
+ end
332
+
333
+ def cross_sides(vertical)
334
+ vertical ? %i[left right] : %i[top bottom]
335
+ end
336
+
337
+ def lane_centre(slot, vertical)
338
+ vertical ? slot.x + (slot.width / 2.0) : slot.y + (slot.height / 2.0)
339
+ end
340
+
341
+ def shift_cross(graph, vertical, amount)
342
+ items = [*graph.children, *@dummy_slots]
343
+ items.each do |item|
344
+ if vertical
345
+ item.x += amount
346
+ else
347
+ item.y += amount
348
+ end
349
+ end
350
+ end
351
+
352
+ def grow_cross(graph, vertical, amount)
353
+ return unless amount.positive?
354
+
355
+ if vertical
356
+ graph.width += amount
357
+ else
358
+ graph.height += amount
359
+ end
360
+ end
361
+
362
+ def padding_shift(graph)
363
+ first = graph.children.first
364
+ before = first && [first.x, first.y]
42
365
  apply_padding(graph)
366
+ return [0.0, 0.0] unless before
43
367
 
44
- graph
368
+ [first.x - before[0], first.y - before[1]]
369
+ end
370
+
371
+ def shift_dummy_slots(shift_x, shift_y)
372
+ @dummy_slots.each do |slot|
373
+ slot.x += shift_x
374
+ slot.y += shift_y
375
+ end
376
+ end
377
+
378
+ def dummy_slots(layers)
379
+ layers.flatten.select { |item| item.respond_to?(:dummy?) }
380
+ end
381
+
382
+ def add_long_edge_bends(graph, routed)
383
+ slots_by_edge = {}.compare_by_identity
384
+ @dummy_slots.to_a.each do |slot|
385
+ (slots_by_edge[slot.edge] ||= []) << slot
386
+ end
387
+ graph.edges.to_a.each do |edge|
388
+ next if routed.any? { |done| done.equal?(edge) }
389
+
390
+ add_edge_dummy_bends(edge, slots_by_edge[edge], graph)
391
+ end
392
+ end
393
+
394
+ def add_edge_dummy_bends(edge, slots, graph)
395
+ return if slots.nil? || slots.empty? || edge.sections.to_a.empty?
396
+
397
+ section = edge.sections.first
398
+ section.bend_points = ordered_dummy_anchors(
399
+ section.start_point, slots, graph
400
+ )
401
+ end
402
+
403
+ # A lone dummy is entered and left at its layer's edges; a single
404
+ # bend at its centre would cut across the nodes beside it.
405
+ def ordered_dummy_anchors(start_point, slots, graph)
406
+ return lone_dummy_anchors(start_point, slots.first, graph) if
407
+ slots.length == 1
408
+
409
+ ordered = slots.sort_by { |slot| squared_distance(start_point, slot) }
410
+ ordered.each_with_index.map do |slot, index|
411
+ dummy_anchor(slot, graph, anchor_for(index, ordered.length))
412
+ end
413
+ end
414
+
415
+ # A reversed edge runs against the layer order, so the anchor nearer
416
+ # its start comes first.
417
+ def lone_dummy_anchors(start_point, slot, graph)
418
+ points = %i[leading trailing].map do |anchor|
419
+ dummy_anchor(slot, graph, anchor)
420
+ end
421
+ points.sort_by do |point|
422
+ ((point.x - start_point.x)**2) + ((point.y - start_point.y)**2)
423
+ end
424
+ end
425
+
426
+ def anchor_for(index, length)
427
+ return :leading if index.zero?
428
+ return :trailing if index == length - 1
429
+
430
+ :center
431
+ end
432
+
433
+ def squared_distance(point, slot)
434
+ ((slot.x + (slot.width / 2.0) - point.x)**2) +
435
+ ((slot.y + (slot.height / 2.0) - point.y)**2)
436
+ end
437
+
438
+ def dummy_anchor(slot, graph, anchor)
439
+ direction = resolver.get("elk.direction", graph)
440
+ return dummy_center(slot) if anchor == :center
441
+ return vertical_dummy_anchor(slot, direction, anchor) if
442
+ %w[DOWN UP].include?(direction)
443
+
444
+ Geometry::Point.new(
445
+ x: horizontal_anchor(slot, direction, anchor),
446
+ y: slot.y + (slot.height / 2.0),
447
+ )
448
+ end
449
+
450
+ def vertical_dummy_anchor(slot, direction, anchor)
451
+ Geometry::Point.new(
452
+ x: slot.x + (slot.width / 2.0),
453
+ y: vertical_anchor(slot, direction, anchor),
454
+ )
455
+ end
456
+
457
+ def dummy_center(slot)
458
+ Geometry::Point.new(
459
+ x: slot.x + (slot.width / 2.0),
460
+ y: slot.y + (slot.height / 2.0),
461
+ )
462
+ end
463
+
464
+ def horizontal_anchor(slot, direction, anchor)
465
+ leading, trailing = if direction == "LEFT"
466
+ [slot.x + slot.width, slot.x]
467
+ else
468
+ [slot.x, slot.x + slot.width]
469
+ end
470
+ anchor == :leading ? leading : trailing
471
+ end
472
+
473
+ def vertical_anchor(slot, direction, anchor)
474
+ leading, trailing = if direction == "UP"
475
+ [slot.y + slot.height, slot.y]
476
+ else
477
+ [slot.y, slot.y + slot.height]
478
+ end
479
+ anchor == :leading ? leading : trailing
480
+ end
481
+
482
+ def minimize_crossings(graph, layers, index)
483
+ minimizer = Layered::Components::Minimizer.new(
484
+ graph, layers, index, resolver, @level_size
485
+ )
486
+ minimizer.minimize.tap { @port_order = minimizer.port_order }
487
+ end
488
+
489
+ def place_nodes(graph, layers, index)
490
+ direction = option("elk.direction")
491
+ direction = "RIGHT" if direction == "UNDEFINED"
492
+
493
+ placer = Layered::NodePlacer.new(
494
+ graph, layers,
495
+ direction: direction,
496
+ layer_spacing: option("elk.layered.spacing.nodeNodeBetweenLayers"),
497
+ node_spacing: node_spacing
498
+ )
499
+ placer.index = index
500
+ placer.port_order = @port_order
501
+ placer.orthogonal = get_edge_routing_style(graph) == "ORTHOGONAL"
502
+ placer.place_nodes
503
+ end
504
+
505
+ def validate_edges(index)
506
+ index.edges.each do |edge|
507
+ validate_simple_edge!(edge)
508
+ end
509
+ end
510
+
511
+ # An id is optional on an edge. An edge without one is ANONYMOUS: it
512
+ # carries no handle, so `edge_label` falls back to its endpoints.
513
+ # `""` counts as no id, and this is the ONE place that decides it.
514
+ def anonymous?(edge)
515
+ edge.id.to_s.empty?
516
+ end
517
+
518
+ def validate_simple_edge!(edge)
519
+ sources = edge.sources || []
520
+ targets = edge.targets || []
521
+
522
+ raise_missing_endpoint!(edge) if missing_endpoint?(sources, targets)
523
+
524
+ return if sources.length == 1 && targets.length == 1
525
+
526
+ raise_hyperedge!(edge)
527
+ end
528
+
529
+ def missing_endpoint?(sources, targets)
530
+ [sources, targets].any? do |endpoints|
531
+ endpoints.empty? || !endpoint_present?(endpoints.first)
532
+ end
533
+ end
534
+
535
+ def endpoint_present?(endpoint)
536
+ !endpoint.nil?
537
+ end
538
+
539
+ # An edge id is optional in ELK, so every message below could read
540
+ # "(edge )" with nothing after it. The endpoints are the fallback
541
+ # handle -- not because an edge always has them (the very next
542
+ # method is raised when it does not, and `endpoint_list` answers
543
+ # "(no endpoints)" for that case), but because they are the only
544
+ # other thing a reader can use to find the edge in their input.
545
+ # Keep the three messages using one helper: fixing only some of
546
+ # them makes the rest look deliberate.
547
+ def edge_label(edge)
548
+ # `""` is truthy in Ruby, so a plain `if edge.id` here puts the
549
+ # empty message straight back. `anonymous?` is the single
550
+ # definition of "no id".
551
+ return edge.id unless anonymous?(edge)
552
+
553
+ "(none), #{endpoint_list(edge.sources)} -> " \
554
+ "#{endpoint_list(edge.targets)}"
555
+ end
556
+
557
+ def endpoint_list(endpoints)
558
+ named = (endpoints || []).compact
559
+ return "(no endpoints)" if named.empty?
560
+
561
+ named.map(&:inspect).join(", ")
562
+ end
563
+
564
+ def raise_missing_endpoint!(edge)
565
+ raise Elkrb::UnsupportedConfigurationException.new(
566
+ "layered requires non-empty edge endpoints " \
567
+ "(edge #{edge_label(edge)})",
568
+ option: "edge",
569
+ value: edge.id,
570
+ )
571
+ end
572
+
573
+ def raise_hyperedge!(edge)
574
+ raise Elkrb::UnsupportedConfigurationException.new(
575
+ "layered does not support hyperedges " \
576
+ "(edge #{edge_label(edge)})",
577
+ option: "edge",
578
+ value: edge.id,
579
+ )
45
580
  end
46
581
  end
47
582
  end