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,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../options/resolver"
4
+
3
5
  module Elkrb
4
6
  module Layout
5
7
  # Module to add automatic label placement to layout algorithms.
6
8
  # Handles positioning of node labels, edge labels, and port labels.
9
+ # The including class sets @resolver, as BaseAlgorithm does.
7
10
  module LabelPlacer
11
+ OPPOSITE_SIDE = { left: :right, right: :left, top: :bottom, bottom: :top }.freeze
12
+ private_constant :OPPOSITE_SIDE
13
+
8
14
  # Place all labels in the graph after layout is complete.
9
15
  #
10
16
  # @param graph [Graph::Graph] The laid out graph
@@ -16,115 +22,135 @@ module Elkrb
16
22
 
17
23
  # Place edge labels
18
24
  place_edge_labels(graph) if graph.edges
19
-
20
- # Recursively place labels in hierarchical graphs
21
- place_hierarchical_labels(graph) if graph.hierarchical?
22
25
  end
23
26
 
24
27
  private
25
28
 
26
- # Place labels for all nodes in the graph.
29
+ # ELK treats a missing width/height as 0 (compound nodes and
30
+ # deserialized labels commonly omit size).
31
+ def width_of(element)
32
+ element.width || 0.0
33
+ end
34
+
35
+ def height_of(element)
36
+ element.height || 0.0
37
+ end
38
+
39
+ # Place labels for all nodes in the graph. Node and port labels are
40
+ # owner-relative: (0, 0) is the owner's top-left corner.
27
41
  def place_node_labels(graph)
28
42
  graph.children.each do |node|
29
- next unless node.labels && !node.labels.empty?
30
-
31
- node.labels.each_with_index do |label, index|
32
- place_node_label(node, label, index)
43
+ if node.labels && !node.labels.empty?
44
+ node.labels.each_with_index do |label, index|
45
+ place_node_label(node, label, index)
46
+ end
33
47
  end
34
48
 
35
- # Place port labels if node has ports
36
49
  place_port_labels(node) if node.ports && !node.ports.empty?
37
50
  end
38
51
  end
39
52
 
40
- # Place a single node label.
53
+ # Place a single node label. Without a placement option the label is
54
+ # not moved, as in ELK: it keeps its coordinates, or (0, 0) if it has
55
+ # none.
41
56
  def place_node_label(node, label, index = 0)
42
- placement = label_placement_option(node, "node.label.placement") ||
43
- "INSIDE CENTER"
44
-
45
- case placement.upcase
46
- when /INSIDE/
47
- place_label_inside_node(node, label, placement, index)
48
- when /OUTSIDE/
49
- place_label_outside_node(node, label, placement, index)
50
- else
51
- # Default: center inside
52
- place_label_center(node, label)
57
+ placement = label_placement_option(node, "elk.nodeLabels.placement")
58
+ tokens = placement_tokens(placement)
59
+ if tokens.empty?
60
+ label.x ||= 0.0
61
+ label.y ||= 0.0
62
+ return
53
63
  end
54
- end
55
64
 
56
- # Place label inside the node bounds.
57
- def place_label_inside_node(node, label, placement, index)
58
- case placement.upcase
59
- when /TOP/
60
- place_label_inside_top(node, label, index)
61
- when /BOTTOM/
62
- place_label_inside_bottom(node, label, index)
63
- when /LEFT/
64
- place_label_inside_left(node, label, index)
65
- when /RIGHT/
66
- place_label_inside_right(node, label, index)
65
+ vertical = vertical_of(tokens)
66
+ horizontal = horizontal_of(tokens)
67
+
68
+ if tokens.include?("OUTSIDE")
69
+ place_label_outside_node(node, label, vertical, horizontal, index)
67
70
  else
68
- place_label_center(node, label)
71
+ place_label_inside_node(node, label, vertical, horizontal, index)
69
72
  end
70
73
  end
71
74
 
72
- # Place label outside the node bounds.
73
- def place_label_outside_node(node, label, placement, _index)
74
- margin = label_margin_option(node)
75
-
76
- case placement.upcase
77
- when /TOP/
78
- label.x = node.x + ((node.width - label.width) / 2.0)
79
- label.y = node.y - label.height - margin
80
- when /BOTTOM/
81
- label.x = node.x + ((node.width - label.width) / 2.0)
82
- label.y = node.y + node.height + margin
83
- when /LEFT/
84
- label.x = node.x - label.width - margin
85
- label.y = node.y + ((node.height - label.height) / 2.0)
86
- when /RIGHT/
87
- label.x = node.x + node.width + margin
88
- label.y = node.y + ((node.height - label.height) / 2.0)
89
- end
75
+ # The upcased tokens of a placement value. Accepts ELK's
76
+ # "[H_CENTER,V_CENTER,INSIDE]" and the space-separated
77
+ # "INSIDE V_CENTER H_CENTER".
78
+ def placement_tokens(placement)
79
+ placement.to_s.upcase.split(/[\s,\[\]]+/).reject(&:empty?)
90
80
  end
91
81
 
92
- # Place label at various inside positions.
93
- def place_label_inside_top(node, label, index)
94
- padding = label_padding_option(node)
95
- y_offset = index * (label.height + padding)
82
+ # :top, :center or :bottom. V_TOP is also spelled TOP.
83
+ def vertical_of(tokens)
84
+ return :top if tokens.intersect?(%w[V_TOP TOP])
85
+ return :bottom if tokens.intersect?(%w[V_BOTTOM BOTTOM])
96
86
 
97
- label.x = node.x + ((node.width - label.width) / 2.0)
98
- label.y = node.y + padding + y_offset
87
+ :center
99
88
  end
100
89
 
101
- def place_label_inside_bottom(node, label, index)
102
- padding = label_padding_option(node)
103
- y_offset = index * (label.height + padding)
90
+ # :left, :center or :right. H_LEFT is also spelled LEFT.
91
+ def horizontal_of(tokens)
92
+ return :left if tokens.intersect?(%w[H_LEFT LEFT])
93
+ return :right if tokens.intersect?(%w[H_RIGHT RIGHT])
104
94
 
105
- label.x = node.x + ((node.width - label.width) / 2.0)
106
- label.y = node.y + node.height - label.height - padding - y_offset
95
+ :center
107
96
  end
108
97
 
109
- def place_label_inside_left(node, label, index)
98
+ # Place a label inside the node, relative to the node's origin.
99
+ def place_label_inside_node(node, label, vertical, horizontal, index)
110
100
  padding = label_padding_option(node)
111
- y_offset = index * (label.height + padding)
101
+ offset = index * (height_of(label) + padding)
102
+
103
+ label.x = inside_x(node, label, horizontal, padding)
104
+ label.y =
105
+ case vertical
106
+ when :top then padding + offset
107
+ when :bottom then height_of(node) - height_of(label) - padding - offset
108
+ else (height_of(node) - height_of(label)) / 2.0
109
+ end
110
+ end
112
111
 
113
- label.x = node.x + padding
114
- label.y = node.y + padding + y_offset
112
+ def inside_x(node, label, horizontal, padding)
113
+ case horizontal
114
+ when :left then padding
115
+ when :right then width_of(node) - width_of(label) - padding
116
+ else (width_of(node) - width_of(label)) / 2.0
117
+ end
115
118
  end
116
119
 
117
- def place_label_inside_right(node, label, index)
118
- padding = label_padding_option(node)
119
- y_offset = index * (label.height + padding)
120
+ # Place a label outside the node, relative to the node's origin. Above
121
+ # or below when a vertical side is named, otherwise beside. Naming no
122
+ # side at all places it above.
123
+ def place_label_outside_node(node, label, vertical, horizontal, index)
124
+ margin = label_margin_option(node)
125
+ offset = index * (height_of(label) + margin)
120
126
 
121
- label.x = node.x + node.width - label.width - padding
122
- label.y = node.y + padding + y_offset
127
+ if vertical == :center && horizontal != :center
128
+ place_label_beside_node(node, label, horizontal, margin)
129
+ elsif vertical == :bottom
130
+ label.x = outside_x(node, label, horizontal)
131
+ label.y = height_of(node) + margin + offset
132
+ else
133
+ label.x = outside_x(node, label, horizontal)
134
+ label.y = -height_of(label) - margin - offset
135
+ end
123
136
  end
124
137
 
125
- def place_label_center(node, label)
126
- label.x = node.x + ((node.width - label.width) / 2.0)
127
- label.y = node.y + ((node.height - label.height) / 2.0)
138
+ def outside_x(node, label, horizontal)
139
+ case horizontal
140
+ when :left then 0.0
141
+ when :right then width_of(node) - width_of(label)
142
+ else (width_of(node) - width_of(label)) / 2.0
143
+ end
144
+ end
145
+
146
+ def place_label_beside_node(node, label, horizontal, margin)
147
+ label.x =
148
+ if horizontal == :left
149
+ -width_of(label) - margin
150
+ else
151
+ width_of(node) + margin
152
+ end
153
+ label.y = (height_of(node) - height_of(label)) / 2.0
128
154
  end
129
155
 
130
156
  # Place labels for all ports on a node.
@@ -138,27 +164,16 @@ module Elkrb
138
164
  end
139
165
  end
140
166
 
141
- # Place a port label.
142
- def place_port_label(node, port, label, _index)
143
- # Port position relative to node
144
- port_x = node.x + (port.x || 0)
145
- port_y = node.y + (port.y || 0)
146
-
147
- placement = label_placement_option(port, "port.label.placement") ||
167
+ # Place a port label, relative to the port's origin. OUTSIDE (the
168
+ # default) puts it beside the port away from the node, INSIDE on the
169
+ # node's side of the port.
170
+ def place_port_label(node, port, label, index)
171
+ placement = label_placement_option(port, "elk.portLabels.placement") ||
148
172
  "OUTSIDE"
173
+ side = port_side(node, port)
174
+ side = OPPOSITE_SIDE.fetch(side) if placement_tokens(placement).include?("INSIDE")
149
175
 
150
- margin = label_margin_option(port)
151
-
152
- case placement.upcase
153
- when /INSIDE/
154
- # Place inside port (if port is large enough)
155
- label.x = port_x + ((port.width - label.width) / 2.0)
156
- label.y = port_y + ((port.height - label.height) / 2.0)
157
- else
158
- # Default: outside, positioned based on port side
159
- side = port_side(node, port)
160
- place_port_label_by_side(label, port_x, port_y, port, side, margin)
161
- end
176
+ place_port_label_by_side(label, port, side, label_margin_option(port), index)
162
177
  end
163
178
 
164
179
  # Determine which side of the node the port is on.
@@ -168,9 +183,9 @@ module Elkrb
168
183
 
169
184
  # Check which edge the port is closest to
170
185
  left_dist = port_x
171
- right_dist = node.width - port_x
186
+ right_dist = width_of(node) - port_x
172
187
  top_dist = port_y
173
- bottom_dist = node.height - port_y
188
+ bottom_dist = height_of(node) - port_y
174
189
 
175
190
  min_dist = [left_dist, right_dist, top_dist, bottom_dist].min
176
191
 
@@ -182,21 +197,21 @@ module Elkrb
182
197
  end
183
198
  end
184
199
 
185
- # Place port label based on port side.
186
- def place_port_label_by_side(label, port_x, port_y, port, side, margin)
200
+ # Place a port label on the given side of the port. Stacked labels
201
+ # step along the side by their own height.
202
+ def place_port_label_by_side(label, port, side, margin, index)
203
+ step = index * (height_of(label) + margin)
204
+
187
205
  case side
188
- when :left
189
- label.x = port_x - label.width - margin
190
- label.y = port_y + ((port.height - label.height) / 2.0)
191
- when :right
192
- label.x = port_x + port.width + margin
193
- label.y = port_y + ((port.height - label.height) / 2.0)
206
+ when :left, :right
207
+ label.x = side == :left ? -width_of(label) - margin : width_of(port) + margin
208
+ label.y = ((height_of(port) - height_of(label)) / 2.0) + step
194
209
  when :top
195
- label.x = port_x + ((port.width - label.width) / 2.0)
196
- label.y = port_y - label.height - margin
210
+ label.x = (width_of(port) - width_of(label)) / 2.0
211
+ label.y = -height_of(label) - margin - step
197
212
  when :bottom
198
- label.x = port_x + ((port.width - label.width) / 2.0)
199
- label.y = port_y + port.height + margin
213
+ label.x = (width_of(port) - width_of(label)) / 2.0
214
+ label.y = height_of(port) + margin + step
200
215
  end
201
216
  end
202
217
 
@@ -215,33 +230,37 @@ module Elkrb
215
230
  def place_edge_label(edge, label, index)
216
231
  # Get edge path (sections with bend points)
217
232
  if edge.sections && !edge.sections.empty?
218
- place_edge_label_on_section(edge.sections.first, label, index)
233
+ place_edge_label_on_section(edge, edge.sections.first, label, index)
219
234
  else
220
235
  # No sections, estimate from source/target
221
236
  place_edge_label_estimated(edge, label, index)
222
237
  end
223
238
  end
224
239
 
225
- # Place edge label on an edge section.
226
- def place_edge_label_on_section(section, label, index)
227
- # Calculate center point of the edge path
228
- center = calculate_edge_center(section)
229
-
230
- placement = "CENTER" # Could be configurable
231
-
232
- offset = index * (label.height + 2) # Stack multiple labels
233
-
234
- case placement.upcase
235
- when /CENTER/
236
- label.x = center[:x] - (label.width / 2.0)
237
- label.y = center[:y] - (label.height / 2.0) + offset
238
- when /HEAD/
239
- label.x = section.end_point.x - (label.width / 2.0)
240
- label.y = section.end_point.y - label.height - 5 + offset
241
- when /TAIL/
242
- label.x = section.start_point.x - (label.width / 2.0)
243
- label.y = section.start_point.y - label.height - 5 + offset
244
- end
240
+ # Place an edge label on an edge section, in the section's frame. The
241
+ # label's own elk.edgeLabels.placement wins over the edge's.
242
+ def place_edge_label_on_section(edge, section, label, index)
243
+ tokens = placement_tokens(
244
+ @resolver.get("elk.edgeLabels.placement", label, edge),
245
+ )
246
+ offset = index * (height_of(label) + 2) # Stack multiple labels
247
+
248
+ anchor =
249
+ if tokens.include?("HEAD")
250
+ above(section.end_point, label)
251
+ elsif tokens.include?("TAIL")
252
+ above(section.start_point, label)
253
+ else
254
+ calculate_edge_center(section)
255
+ end
256
+
257
+ label.x = anchor[:x] - (width_of(label) / 2.0)
258
+ label.y = anchor[:y] - (height_of(label) / 2.0) + offset
259
+ end
260
+
261
+ # HEAD and TAIL labels sit just above the end point they name.
262
+ def above(point, label)
263
+ { x: point.x, y: point.y - (height_of(label) / 2.0) - 5 }
245
264
  end
246
265
 
247
266
  # Calculate the center point of an edge section.
@@ -262,6 +281,8 @@ module Elkrb
262
281
  total_length += length
263
282
  end
264
283
 
284
+ return { x: points.first.x, y: points.first.y } if total_length.zero?
285
+
265
286
  # Find point at half the total length
266
287
  target_length = total_length / 2.0
267
288
  current_length = 0.0
@@ -269,7 +290,7 @@ module Elkrb
269
290
  (0...lengths.length).each do |i|
270
291
  if current_length + lengths[i] >= target_length
271
292
  # Interpolate between points[i] and points[i+1]
272
- ratio = (target_length - current_length) / lengths[i]
293
+ ratio = lengths[i].zero? ? 0.0 : (target_length - current_length) / lengths[i]
273
294
  p1 = points[i]
274
295
  p2 = points[i + 1]
275
296
 
@@ -294,44 +315,31 @@ module Elkrb
294
315
  label.y = 0
295
316
  end
296
317
 
297
- # Place labels in hierarchical child nodes.
298
- def place_hierarchical_labels(graph)
299
- return unless graph.children
300
-
301
- graph.children.each do |node|
302
- next unless node.hierarchical?
303
-
304
- # Create temporary graph for children
305
- child_graph = Graph::Graph.new(
306
- children: node.children,
307
- edges: node.edges,
308
- )
309
-
310
- # Recursively place labels
311
- place_labels(child_graph)
318
+ # Get the label placement an element names, or nil. The element's own
319
+ # options come before the call's: for a port, `label.placement` is one
320
+ # of them, though the registry aliases it to nodes. Because it is that
321
+ # alias, a port that names elk.nodeLabels.placement is read the same way.
322
+ def label_placement_option(element, option_id)
323
+ [option_id, "label.placement"].each do |id|
324
+ own = own_option(id, element)
325
+ return own if own
312
326
  end
327
+ @resolver.get(option_id, element, default: nil)
313
328
  end
314
329
 
315
- # Get label placement option from node/port layout options.
316
- def label_placement_option(element, option_key)
317
- return nil unless element.layout_options
318
-
319
- element.layout_options.properties&.[](option_key) ||
320
- element.layout_options.properties&.[]("label.placement")
330
+ # An element's own option, ignoring the call's options.
331
+ def own_option(id, element)
332
+ (@own_options ||= Options::Resolver.new).get(id, element, default: nil)
321
333
  end
322
334
 
323
335
  # Get label padding option.
324
336
  def label_padding_option(element)
325
- return 5.0 unless element.layout_options
326
-
327
- element.layout_options.properties&.[]("label.padding") || 5.0
337
+ @resolver.get("label.padding", element)
328
338
  end
329
339
 
330
340
  # Get label margin option.
331
341
  def label_margin_option(element)
332
- return 5.0 unless element.layout_options
333
-
334
- element.layout_options.properties&.[]("label.margin") || 5.0
342
+ @resolver.get("label.margin", element)
335
343
  end
336
344
  end
337
345
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "algorithm_registry"
4
+ require_relative "../options/registry"
5
+ require_relative "../options/resolver"
4
6
 
5
7
  module Elkrb
6
8
  module Layout
@@ -45,11 +47,24 @@ module Elkrb
45
47
  # appropriate algorithm based on options, and computes node positions
46
48
  # and edge routes.
47
49
  #
48
- # The algorithm is selected in this order:
49
- # 1. options[:algorithm] or options["algorithm"]
50
- # 2. graph.layoutOptions["elk.algorithm"]
50
+ # The algorithm is the first of these that names one:
51
+ # 1. The graph's own selector -- layoutOptions under "elk.algorithm",
52
+ # "algorithm" or the "org.eclipse.elk.algorithm" long form, then
53
+ # properties
54
+ # 2. The :algorithm call option, under a Symbol or String key
51
55
  # 3. Default: "layered"
52
56
  #
57
+ # A graph that pins an algorithm keeps it; the caller's option is
58
+ # the fallback for graphs that pin none.
59
+ #
60
+ # With the call option strict: true, an option in the graph's
61
+ # layoutOptions that elkrb does not know, or does not fully honour,
62
+ # raises Elkrb::Error instead of logging a warning. So does a
63
+ # registered option in an element's properties or in +options+ that
64
+ # is not fully honoured; an unknown property name or key in +options+
65
+ # is not reported. An option only some algorithms read is accepted
66
+ # where the algorithm laying out its element is one of them.
67
+ #
53
68
  # @param graph [Hash, Elkrb::Graph::Graph] The graph to layout. Can be:
54
69
  # - A Hash with keys: :id, :children, :edges, :layoutOptions
55
70
  # - A Graph model object
@@ -57,7 +72,9 @@ module Elkrb
57
72
  # - :algorithm (String) - Algorithm name (layered, force, etc.)
58
73
  # - Algorithm-specific options
59
74
  # @return [Elkrb::Graph::Graph] The input graph with computed positions
60
- # @raise [Elkrb::Error] If the specified algorithm is not found
75
+ # @raise [ArgumentError] If graph is neither a Hash nor a Graph::Graph
76
+ # @raise [Elkrb::AlgorithmNotFoundError] If the algorithm is not found
77
+ # @raise [Elkrb::Error] In strict mode, for an unknown or unhonoured option
61
78
  #
62
79
  # @example With specific algorithm
63
80
  # result = Elkrb::Layout::LayoutEngine.layout(
@@ -66,27 +83,20 @@ module Elkrb
66
83
  # "elk.force.repulsion" => 5.0
67
84
  # )
68
85
  #
69
- # @example With hierarchical layout
70
- # result = Elkrb::Layout::LayoutEngine.layout(
71
- # graph,
72
- # algorithm: "layered",
73
- # hierarchical: true
74
- # )
86
+ # Hierarchy is detected from compound nodes; no call flag is needed.
75
87
  def layout(graph, options = {})
76
- # Convert hash to Graph if needed
77
- graph = convert_to_graph(graph) if graph.is_a?(Hash)
88
+ graph = graph_argument(graph)
78
89
 
79
- # Get algorithm name from options
80
- algorithm_name = options[:algorithm] ||
81
- options["algorithm"] ||
82
- "layered"
90
+ resolver = Options::Resolver.new(options)
91
+ algorithm_name = resolver.get("elk.algorithm", graph)
83
92
 
84
93
  algorithm_class = AlgorithmRegistry.get(algorithm_name)
94
+ raise AlgorithmNotFoundError, algorithm_name unless algorithm_class
85
95
 
86
- raise Error, "Unknown layout algorithm: #{algorithm_name}" unless
87
- algorithm_class
96
+ resolver.report_unhonoured(
97
+ graph, algorithm_name: AlgorithmRegistry.method(:registered_name)
98
+ )
88
99
 
89
- # Create and run algorithm with options
90
100
  algorithm = algorithm_class.new(options)
91
101
  algorithm.layout(graph)
92
102
 
@@ -104,6 +114,7 @@ module Elkrb
104
114
  # - :description (String) - Brief description
105
115
  # - :category (String, nil) - Algorithm category (e.g., "hierarchical", "force")
106
116
  # - :supports_hierarchy (Boolean, nil) - Whether it supports hierarchical graphs
117
+ # - :supported_options (Array<String>) - Canonical option ids scoped to this algorithm
107
118
  #
108
119
  # @example List all algorithms
109
120
  # algorithms = Elkrb::Layout::LayoutEngine.known_layout_algorithms
@@ -152,11 +163,10 @@ module Elkrb
152
163
 
153
164
  # Returns metadata for all supported layout options.
154
165
  #
155
- # @return [Array<Hash>] Array of option metadata
156
- # @note Currently returns an empty array. Full implementation planned.
166
+ # @return [Hash{String => Hash}] Same shape and content as
167
+ # Elkrb.known_layout_options, keyed by canonical id.
157
168
  def known_layout_options
158
- # TODO: Build from all algorithms' supported options
159
- []
169
+ Options::Registry.render_known_options(algorithm_values: AlgorithmRegistry.available_algorithms)
160
170
  end
161
171
 
162
172
  private
@@ -164,6 +174,19 @@ module Elkrb
164
174
  def convert_to_graph(hash)
165
175
  Graph::Graph.from_hash(hash)
166
176
  end
177
+
178
+ # Takes the two documented shapes and nothing else. The check lives
179
+ # here, not in Elkrb.layout, so the CLI and every other caller of the
180
+ # engine get the same clear error instead of a NoMethodError deep
181
+ # inside an algorithm.
182
+ def graph_argument(graph)
183
+ return convert_to_graph(graph) if graph.is_a?(::Hash)
184
+ return graph if graph.is_a?(Graph::Graph)
185
+
186
+ raise ArgumentError,
187
+ "graph must be a Hash or Elkrb::Graph::Graph, " \
188
+ "got #{graph.class}"
189
+ end
167
190
  end
168
191
  end
169
192
  end