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.
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
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "port_order"
4
+ require_relative "port_spread"
5
+ require_relative "orthogonal/gap_solver"
6
+ require_relative "orthogonal/junction_points"
7
+
8
+ module Elkrb
9
+ module Layout
10
+ module Algorithms
11
+ module Layered
12
+ # Orthogonal routes for the edges between adjacent layers. Each gap
13
+ # between two layers holds the perpendicular runs of its edges, one
14
+ # per routing slot; an edge leaves the side of its node that faces
15
+ # the next layer, runs to its slot, along it, and on to its target.
16
+ #
17
+ # All geometry is read through callables, so the same router sizes
18
+ # the gaps before nodes have a layer position and routes after.
19
+ #
20
+ # Not ported: the segment splitting described on
21
+ # Orthogonal::GapSolver.
22
+ class OrthogonalRouter
23
+ # Cross-axis geometry, each a callable.
24
+ #
25
+ # `start` is where an item starts, `extent` its cross size and
26
+ # `port` the cross size of a port.
27
+ Measure = Struct.new(:start, :extent, :port, keyword_init: true)
28
+
29
+ # An edge's route: its points, source first, and the junction
30
+ # points where it meets other edges of its hyperedge segment.
31
+ Route = Struct.new(:points, :junctions)
32
+ # Bend points and junction points per edge, per gap.
33
+ class Book
34
+ attr_reader :bends, :junctions
35
+
36
+ def initialize
37
+ @bends = {}.compare_by_identity
38
+ @junctions = {}.compare_by_identity
39
+ end
40
+
41
+ def record(edge, gap, bends, junctions)
42
+ (@bends[edge] ||= {})[gap] = bends
43
+ (@junctions[edge] ||= {})[gap] = junctions
44
+ end
45
+ end
46
+ private_constant :Book
47
+
48
+ # Distance between neighbouring routing slots.
49
+ EDGE_SPACING = 10.0
50
+ # Distance between a layer and its first routing slot.
51
+ EDGE_NODE_SPACING = 10.0
52
+ TOLERANCE = Orthogonal::HyperEdgeSegment::TOLERANCE
53
+ # Node side that faces the next layer, and the side that faces the
54
+ # previous one, per direction.
55
+ FACING_SIDES = {
56
+ "RIGHT" => %w[EAST WEST], "LEFT" => %w[WEST EAST],
57
+ "DOWN" => %w[SOUTH NORTH], "UP" => %w[NORTH SOUTH]
58
+ }.freeze
59
+
60
+ # @param layers [Array<Array>] nodes and DummySlots per layer
61
+ # @param port_order [PortOrder]
62
+ # @param index [NodeIndex] resolves edge endpoints to nodes
63
+ # @param direction [String] RIGHT, LEFT, DOWN or UP
64
+ # @param measure [Measure] cross-axis geometry
65
+ def initialize(layers, port_order, index, direction:, measure:)
66
+ @layers = layers
67
+ @port_order = port_order
68
+ @index = index
69
+ @facing = FACING_SIDES.fetch(direction)
70
+ @vertical = %w[DOWN UP].include?(direction)
71
+ @sign = %w[LEFT UP].include?(direction) ? -1 : 1
72
+ @measure = measure
73
+ @items = layers.flatten.to_h { |item| [item.id, item] }
74
+ @offsets = spread_offsets
75
+ end
76
+
77
+ # Number of routing slots in each gap between two layers.
78
+ def slot_counts
79
+ gaps.map { |segments| Orthogonal::GapSolver.slot_count(segments) }
80
+ end
81
+
82
+ # @param along_range [#call] [low, high] of an item on the layer
83
+ # axis, in final coordinates
84
+ # @return [Hash{Edge=>Route}] the route of each edge that crosses
85
+ # a layer gap, source first; an edge whose declared port is not
86
+ # on the side facing its neighbour layer is left out
87
+ def routes(along_range)
88
+ @along_range = along_range
89
+ book = gap_book
90
+ grouped_segments.select { |_edge, links| facing?(links) }
91
+ .to_h { |edge, links| [edge, route(edge, links, book)] }
92
+ end
93
+
94
+ private
95
+
96
+ def spread_offsets
97
+ sizes = @items.transform_values do |item|
98
+ @measure.extent.call(item)
99
+ end
100
+ PortSpread.offsets(@layers, @port_order, sizes, @measure.port)
101
+ end
102
+
103
+ def gaps
104
+ @layers.each_cons(2).map do |layer, _next_layer|
105
+ ports = layer.flat_map do |item|
106
+ @port_order.visual(item.id, :east)
107
+ end
108
+ Orthogonal::GapSolver.new(EDGE_SPACING)
109
+ .solve(ports, method(:cross_of))
110
+ end
111
+ end
112
+
113
+ def cross_of(port)
114
+ item = @items.fetch(port.item_id)
115
+ offset = item.respond_to?(:dummy?) ? 0.0 : @offsets.fetch(port)
116
+ @measure.start.call(item) + offset
117
+ end
118
+
119
+ # Bend points and junction points per edge, per gap: lists of
120
+ # [along, cross].
121
+ def gap_book
122
+ book = Book.new
123
+ pairs = @layers.each_cons(2).zip(gaps)
124
+ pairs.each_with_index do |(pair, segments), gap|
125
+ start = layer_high(pair.first) + EDGE_NODE_SPACING
126
+ joints = Orthogonal::JunctionPoints.new
127
+ segments.each { |s| place(s, [start, gap], book, joints) }
128
+ end
129
+ book
130
+ end
131
+
132
+ def place(segment, (start, gap), book, joints)
133
+ return if segment.straight?
134
+
135
+ along = start + (segment.slot * EDGE_SPACING)
136
+ source_links(segment).each do |link|
137
+ points = bend_points(link, along) or next
138
+ book.record(link.edge, gap, points, joints.among(segment, points))
139
+ end
140
+ end
141
+
142
+ def source_links(segment)
143
+ segment.ports.select { |port| port.side == :east }
144
+ .flat_map(&:segments)
145
+ end
146
+
147
+ def bend_points(link, along)
148
+ from = cross_of(link.from_port)
149
+ to = cross_of(link.to_port)
150
+ return if (from - to).abs <= TOLERANCE
151
+
152
+ [[along, from], [along, to]]
153
+ end
154
+
155
+ def grouped_segments
156
+ groups = {}.compare_by_identity
157
+ @port_order.segments.each do |link|
158
+ (groups[link.edge] ||= []) << link
159
+ end
160
+ groups.transform_values do |links|
161
+ links.sort_by { |link| layer_index(link.from_port.item_id) }
162
+ end
163
+ end
164
+
165
+ def facing?(links)
166
+ links.all? do |link|
167
+ port_faces?(link.from_port, @facing.first) &&
168
+ port_faces?(link.to_port, @facing.last)
169
+ end
170
+ end
171
+
172
+ def port_faces?(port, side)
173
+ port.declared.nil? || port.declared.side == side
174
+ end
175
+
176
+ def layer_index(item_id)
177
+ @layer_index ||= index_layers
178
+ @layer_index.fetch(item_id)
179
+ end
180
+
181
+ def index_layers
182
+ map = {}
183
+ @layers.each_with_index do |layer, i|
184
+ layer.each { |item| map[item.id] = i }
185
+ end
186
+ map
187
+ end
188
+
189
+ def route(edge, links, book)
190
+ first = links.first.from_port
191
+ inner = gap_points(edge, links, book.bends)
192
+ points = [anchor(first, :high), *inner,
193
+ anchor(links.last.to_port, :low)]
194
+ joined = gap_points(edge, links, book.junctions)
195
+ forward = forward?(edge, first)
196
+ Route.new(oriented(points, forward), oriented(joined, forward))
197
+ end
198
+
199
+ def oriented(points, forward)
200
+ real = points.map { |along, cross| real_point(along, cross) }
201
+ forward ? real : real.reverse
202
+ end
203
+
204
+ def gap_points(edge, links, per_gap)
205
+ links.flat_map do |link|
206
+ per_gap.dig(edge, layer_index(link.from_port.item_id)) || []
207
+ end
208
+ end
209
+
210
+ def forward?(edge, first_port)
211
+ @index.node(edge.sources.first)&.id == first_port.item_id
212
+ end
213
+
214
+ # The point where an edge meets a port: on the item's border, or
215
+ # beyond it for a declared port.
216
+ def anchor(port, side)
217
+ item = @items.fetch(port.item_id)
218
+ low, high = abstract_range(item)
219
+ reach = port.declared ? declared_extent(port.declared) : 0.0
220
+ along = side == :high ? high + reach : low - reach
221
+ [along, cross_of(port)]
222
+ end
223
+
224
+ def declared_extent(declared)
225
+ (declared.public_send(@vertical ? :height : :width) || 0).to_f
226
+ end
227
+
228
+ def abstract_range(item)
229
+ low, high = @along_range.call(item)
230
+ @sign.positive? ? [low, high] : [-high, -low]
231
+ end
232
+
233
+ # The far edge of a layer, port overhang included.
234
+ def layer_high(layer)
235
+ return 0.0 if layer.empty?
236
+
237
+ layer.map do |item|
238
+ declared = @port_order.visual(item.id, :east)
239
+ .filter_map(&:declared)
240
+ reach = declared.map { |d| declared_extent(d) }.max || 0.0
241
+ abstract_range(item).last + reach
242
+ end.max
243
+ end
244
+
245
+ def real_point(along, cross)
246
+ real = @sign * along
247
+ @vertical ? [cross, real] : [real, cross]
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ module Algorithms
6
+ module Layered
7
+ # The port slots every layer item exposes toward its neighbouring
8
+ # layers, and the order they sit in.
9
+ #
10
+ # An edge crossing one layer gap is a Segment from an EAST slot of the
11
+ # item in the earlier layer to a WEST slot of the item in the later
12
+ # one. A slot is an explicit port, or the one slot a port-less
13
+ # endpoint gets per edge.
14
+ #
15
+ # A list is kept in the order ELK keeps it: EAST lists run top to
16
+ # bottom, WEST lists bottom to top. #visual always answers top to
17
+ # bottom. Sorting a WEST list ascending therefore places the lowest
18
+ # value at the bottom; the sweep in CrossingMinimizer depends on
19
+ # that.
20
+ class PortOrder
21
+ # One slot on the EAST or WEST side of a layer item.
22
+ class Port
23
+ attr_reader :item_id, :side, :declared, :sequence, :segments
24
+
25
+ def initialize(item_id:, side:, declared:, sequence:)
26
+ @item_id = item_id
27
+ @side = side
28
+ @declared = declared
29
+ @sequence = sequence
30
+ @segments = []
31
+ end
32
+
33
+ def others
34
+ segments.map { |segment| segment.other_port(self) }
35
+ end
36
+ end
37
+
38
+ # One edge crossing one layer gap, `from_port` in the earlier layer.
39
+ Segment = Struct.new(:edge, :from_port, :to_port) do
40
+ def other_port(port)
41
+ port.equal?(from_port) ? to_port : from_port
42
+ end
43
+ end
44
+
45
+ SIDES = %i[east west].freeze
46
+
47
+ attr_reader :segments
48
+
49
+ # @param layers [Array<Array>] nodes and DummySlots per layer
50
+ # @param index [NodeIndex, nil] resolves edge endpoints to nodes
51
+ def initialize(layers, index)
52
+ @layer_of = layer_map(layers)
53
+ @slots = dummy_slots_by_edge(layers)
54
+ @ports = {}
55
+ @segments = []
56
+ @index = index
57
+ (index ? index.edges : []).each_with_index do |edge, order|
58
+ add_edge(edge, order)
59
+ end
60
+ @lists = build_lists
61
+ end
62
+
63
+ # Ports of one side, top to bottom.
64
+ def visual(item_id, side)
65
+ list = list(item_id, side)
66
+ side == :west ? list.reverse : list
67
+ end
68
+
69
+ # Reorders a list ascending by the block's value. The sort is
70
+ # stable, so equal values keep their order.
71
+ def sort!(item_id, side)
72
+ list = list(item_id, side)
73
+ @lists[[item_id, side]] =
74
+ list.each_with_index.sort_by do |port, position|
75
+ [yield(port), position]
76
+ end.map(&:first)
77
+ end
78
+
79
+ # The port lists as they are now, for #restore.
80
+ def snapshot
81
+ @lists.transform_values(&:dup)
82
+ end
83
+
84
+ def restore(snapshot)
85
+ @lists = snapshot.transform_values(&:dup)
86
+ end
87
+
88
+ private
89
+
90
+ def list(item_id, side)
91
+ @lists.fetch([item_id, side], [])
92
+ end
93
+
94
+ def layer_map(layers)
95
+ layers.each_with_index.with_object({}) do |(layer, number), map|
96
+ layer.each { |item| map[item.id] = number }
97
+ end
98
+ end
99
+
100
+ def dummy_slots_by_edge(layers)
101
+ slots = {}.compare_by_identity
102
+ layers.flatten.select { |item| item.respond_to?(:layer_index) }
103
+ .sort_by(&:layer_index).each do |slot|
104
+ (slots[slot.edge] ||= []) << slot
105
+ end
106
+ slots
107
+ end
108
+
109
+ def add_edge(edge, order)
110
+ source = owner(edge.sources)
111
+ target = owner(edge.targets)
112
+ return unless placed?(source) && placed?(target)
113
+
114
+ add_chain(edge, order, *earlier_first(edge, source, target))
115
+ end
116
+
117
+ # The two nodes, earlier layer first, with the endpoint ids the
118
+ # edge names on each.
119
+ def earlier_first(edge, source, target)
120
+ named = [edge.sources.first, edge.targets.first]
121
+ if @layer_of[source.id] < @layer_of[target.id]
122
+ [[source, target], named]
123
+ else
124
+ [[target, source], named.reverse]
125
+ end
126
+ end
127
+
128
+ def placed?(node)
129
+ node && @layer_of.key?(node.id)
130
+ end
131
+
132
+ # `named` holds the endpoint ids the edge gives on the earlier and
133
+ # the later layer.
134
+ def add_chain(edge, order, ends, named)
135
+ chain = [ends.first, *Array(@slots[edge]), ends.last]
136
+ chain.each_cons(2).with_index do |pair, position|
137
+ next unless adjacent?(*pair)
138
+
139
+ add_segment(edge, order, pair,
140
+ [position.zero? && named.first,
141
+ position == chain.length - 2 && named.last])
142
+ end
143
+ end
144
+
145
+ def adjacent?(from, to)
146
+ @layer_of[to.id] - @layer_of[from.id] == 1
147
+ end
148
+
149
+ def add_segment(edge, order, items, endpoints)
150
+ segment = Segment.new(
151
+ edge,
152
+ port(items.first, :east, order, endpoints.first),
153
+ port(items.last, :west, order, endpoints.last),
154
+ )
155
+ segment.from_port.segments << segment
156
+ segment.to_port.segments << segment
157
+ @segments << segment
158
+ end
159
+
160
+ def port(item, side, order, endpoint)
161
+ declared = declared_port(item, endpoint)
162
+ key = declared ? declared.id : [:edge, order]
163
+ @ports[[item.id, side, key]] ||= Port.new(
164
+ item_id: item.id, side: side, declared: declared,
165
+ sequence: declared ? [0, item.ports.index(declared)] : [1, order]
166
+ )
167
+ end
168
+
169
+ def declared_port(item, endpoint)
170
+ return unless endpoint && item.respond_to?(:ports)
171
+
172
+ item.ports&.find { |candidate| candidate.id == endpoint }
173
+ end
174
+
175
+ def owner(endpoints)
176
+ id = (endpoints || []).first
177
+ @index.owner(id) if id
178
+ end
179
+
180
+ def build_lists
181
+ @ports.values.group_by { |port| [port.item_id, port.side] }
182
+ .transform_values { |ports| ports.sort_by(&:sequence) }
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ module Algorithms
6
+ module Layered
7
+ # Where the ports of one side sit along an item's cross axis: spread
8
+ # evenly, as ELK spaces free ports.
9
+ module PortSpread
10
+ # Cross offset of each port's centre from the start of its item.
11
+ #
12
+ # @param layers [Array<Array>] items per layer
13
+ # @param port_order [PortOrder]
14
+ # @param sizes [Hash{String=>Numeric}] cross size per item id
15
+ # @param port_size_of [#call] cross size of a port
16
+ # @return [Hash] offset per port, keyed by identity
17
+ def self.offsets(layers, port_order, sizes, port_size_of)
18
+ offsets = {}.compare_by_identity
19
+ layers.flatten.product(PortOrder::SIDES) do |item, side|
20
+ ports = port_order.visual(item.id, side)
21
+ spread(ports, sizes.fetch(item.id), port_size_of)
22
+ .each { |port, mid| offsets[port] = mid }
23
+ end
24
+ offsets
25
+ end
26
+
27
+ def self.spread(ports, extent, port_size_of)
28
+ widths = ports.map { |port| port_size_of.call(port) }
29
+ ports.zip(centres(widths, extent))
30
+ end
31
+
32
+ def self.centres(widths, extent)
33
+ gap = (extent - widths.sum) / (widths.length + 1)
34
+ widths.each_with_index.map do |width, i|
35
+ widths.first(i).sum + (gap * (i + 1)) + (width / 2.0)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end