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
@@ -17,10 +17,46 @@ module Elkrb
17
17
  # The algorithm calculates grid dimensions to approximate a square
18
18
  # aspect ratio, then places nodes from left to right, top to bottom.
19
19
  class TopdownPacking < BaseAlgorithm
20
+ # Fallback cell size when no node anywhere declares a usable width
21
+ # or height. Mirrors node_spacing's own 20.0 default rather than
22
+ # inventing a new constant.
23
+ DEFAULT_CELL_SIZE = 20.0
24
+
20
25
  def initialize(options = {})
21
26
  super
22
27
  end
23
28
 
29
+ # A declared width or height is only usable when it is finite and
30
+ # positive. NaN and Infinity are valid YAML float literals (`.nan`,
31
+ # `.inf`; JSON's parser rejects the bare form) that would otherwise
32
+ # propagate into every downstream position and crash
33
+ # calculate_bounding_box's Array#max, whose NaN-vs-NaN comparison
34
+ # raises ArgumentError. A non-positive value is rejected too --
35
+ # port_constraint_processor.rb already treats a node as size-less
36
+ # unless both dimensions are `positive?`, and letting a negative
37
+ # declared value reach this node's own box (rather than every
38
+ # node's shared, uniformly-averaged cell size, as before this
39
+ # file's declared-size-preserving change) inverts its box and
40
+ # produces a false-negative overlap check against its neighbours.
41
+ # No type check beyond nil-safety: Node's width/height are declared
42
+ # `attribute :width, :float` (node.rb), so lutaml casts every
43
+ # assignment to a Float or nil before it can ever reach here -- a
44
+ # value that responds to #finite? and is neither nil nor a Numeric
45
+ # is not a reachable input. A public class method -- it depends on
46
+ # nothing but its argument, matching the pure-helper convention
47
+ # already used elsewhere for options parsing (e.g. KVector.parse).
48
+ # Keep this as a class method with the instance delegate below --
49
+ # a plain private instance method here trips reek's UtilityFunction
50
+ # check.
51
+ #
52
+ # @param value [Float, nil] The node's declared width or height
53
+ # @return [Float, nil] The value if usable, nil otherwise
54
+ def self.finite_dimension(value)
55
+ return nil unless value&.finite? && value.positive?
56
+
57
+ value
58
+ end
59
+
24
60
  # Layout nodes in a grid using top-down packing
25
61
  #
26
62
  # @param graph [Elkrb::Graph::Graph] The graph to layout
@@ -96,24 +132,34 @@ module Elkrb
96
132
  height: 0.0 }
97
133
  end
98
134
 
99
- # Get options from graph layout options
100
- layout_opts = graph.layout_options || {}
101
-
102
- # Get target aspect ratio (default: 1.0 for square cells)
103
- target_aspect_ratio = get_option(layout_opts,
104
- "topdownpacking.aspectRatio", 1.0).to_f
135
+ # Get target aspect ratio (default: 1.0 for square cells). A
136
+ # non-positive ratio falls back to square rather than reaching the
137
+ # division below; the resolver already rejects NaN and Infinity.
138
+ target_aspect_ratio = resolver.get("topdownpacking.aspectRatio", graph)
105
139
  target_aspect_ratio = 1.0 if target_aspect_ratio <= 0.0
106
140
 
107
141
  # Calculate node dimensions based on aspect ratio
108
142
  # We can either use specified dimensions or calculate from available space
109
- node_width_opt = get_option(layout_opts, "topdownpacking.nodeWidth")
143
+ node_width_opt = finite_dimension(resolver.get("topdownpacking.nodeWidth", graph))
110
144
  if node_width_opt
111
- node_width = node_width_opt.to_f
145
+ node_width = node_width_opt
112
146
  node_height = node_width / target_aspect_ratio
113
147
  else
114
- # Calculate from node sizes to maintain proportions
115
- avg_width = nodes.sum(&:width) / nodes.size.to_f
116
- avg_height = nodes.sum(&:height) / nodes.size.to_f
148
+ # Calculate from node sizes to maintain proportions. A size-less,
149
+ # non-finite (NaN/Infinity, both valid YAML float literals) or
150
+ # non-positive node contributes 0.0 rather than crashing the
151
+ # average, matching the nil-handling convention in box.rb.
152
+ avg_width = nodes.sum { |node| finite_dimension(node.width) || 0.0 } / nodes.size.to_f
153
+ avg_height = nodes.sum { |node| finite_dimension(node.height) || 0.0 } / nodes.size.to_f
154
+
155
+ # When no node anywhere declares a usable size, both averages
156
+ # are zero and every node would silently collapse to a 0x0
157
+ # footprint. Fall back to a default cell size instead of
158
+ # discarding every node.
159
+ if avg_width.zero? && avg_height.zero?
160
+ avg_width = DEFAULT_CELL_SIZE
161
+ avg_height = DEFAULT_CELL_SIZE
162
+ end
117
163
 
118
164
  # Use the larger dimension as base
119
165
  if avg_width >= avg_height
@@ -128,20 +174,24 @@ module Elkrb
128
174
  { width: node_width, height: node_height }
129
175
  end
130
176
 
131
- # Get option value from layout options or default
177
+ # Instance-side delegate to the class method above, so callers in
178
+ # this file read as one private helper rather than repeating
179
+ # `self.class` at every call site.
132
180
  #
133
- # @param layout_opts [Hash, LayoutOptions] The layout options
134
- # @param key [String] The option key
135
- # @param default [Object] The default value
136
- # @return [Object] The option value or default
137
- def get_option(layout_opts, key, default = nil)
138
- return default unless layout_opts
139
-
140
- value = if layout_opts.respond_to?(:[])
141
- layout_opts[key]
142
- end
143
-
144
- value.nil? ? default : value
181
+ # @param value [Object] The node's declared width or height
182
+ # @return [Numeric, nil] The value if usable, nil otherwise
183
+ def finite_dimension(value)
184
+ self.class.finite_dimension(value)
185
+ end
186
+
187
+ # Ports were placed on the declared size before layout. A declared
188
+ # size that is replaced here (size-less, non-positive or non-finite)
189
+ # leaves them on edges the node no longer has, or never placed.
190
+ #
191
+ # @param node [Elkrb::Graph::Node] The node, already resized
192
+ # @param declared [Array<Numeric, nil>] Its declared width and height
193
+ def replace_ports_for_resized_node(node, declared)
194
+ replace_node_ports(node) unless declared.all? { |dim| finite_dimension(dim) }
145
195
  end
146
196
 
147
197
  # Place nodes in grid positions
@@ -155,25 +205,39 @@ module Elkrb
155
205
  current_x = 0.0
156
206
  current_y = 0.0
157
207
  current_col = 0
208
+ row_height = 0.0
158
209
 
159
210
  nodes.each do |node|
160
- # Set node dimensions
161
- node.width = node_width
162
- node.height = node_height
211
+ # Only a node with a usable (finite, positive, non-nil) declared
212
+ # width/height keeps it -- which means it may be larger than
213
+ # the cell, so advancing by the fixed cell size (rather than
214
+ # the node's own size) would let it overlap its neighbours. A
215
+ # size-less, non-finite (NaN/Infinity) or non-positive declared
216
+ # value takes the grid cell size instead of reaching position
217
+ # math as-is.
218
+ declared_width = node.width
219
+ declared_height = node.height
220
+ actual_width = (node.width = finite_dimension(declared_width) || node_width)
221
+ actual_height = (node.height = finite_dimension(declared_height) || node_height)
222
+ replace_ports_for_resized_node(node, [declared_width, declared_height])
163
223
 
164
224
  # Set node position
165
225
  node.x = current_x
166
226
  node.y = current_y
167
227
 
168
- # Advance to next position
228
+ row_height = actual_height if actual_height > row_height
229
+
230
+ # Advance to next position, past this node's own footprint
169
231
  current_col += 1
170
- current_x += node_width + spacing
232
+ current_x += actual_width + spacing
171
233
 
172
- # Move to next row if we've filled the current row
234
+ # Move to next row if we've filled the current row, past the
235
+ # tallest node actually placed in it
173
236
  if current_col >= cols
174
237
  current_x = 0.0
175
- current_y += node_height + spacing
238
+ current_y += row_height + spacing
176
239
  current_col = 0
240
+ row_height = 0.0
177
241
  end
178
242
  end
179
243
  end
@@ -7,15 +7,12 @@ module Elkrb
7
7
  module Algorithms
8
8
  # VertiFlex layout algorithm
9
9
  #
10
- # A vertical flexible layout algorithm that arranges nodes in vertical
11
- # columns with optimized spacing. Ideal for timeline-style layouts,
12
- # Kanban boards, and vertical flowcharts.
10
+ # Approximates a column-grid layout: it distributes nodes into a fixed
11
+ # number of vertical columns (round-robin or sequential) and stacks
12
+ # each column top to bottom, sizing each column to its widest node.
13
13
  #
14
- # The algorithm distributes nodes into vertical columns and positions
15
- # them with flexible column widths based on the widest node in each
16
- # column.
17
- #
18
- # This is an experimental algorithm matching the Java ELK implementation.
14
+ # It never reads the graph's edges -- node positions are identical
15
+ # whether edges are present or not.
19
16
  class VertiFlex < BaseAlgorithm
20
17
  def initialize(options = {})
21
18
  super
@@ -38,34 +35,18 @@ module Elkrb
38
35
  end
39
36
 
40
37
  nodes = graph.children
41
- layout_opts = graph.layout_options || {}
42
38
 
43
39
  # Get layout options
44
- column_count = get_option(layout_opts, "vertiflex.columnCount",
45
- 3).to_i
46
- column_count = 1 if column_count < 1
47
-
48
- column_spacing = get_option(
49
- layout_opts,
50
- "vertiflex.columnSpacing",
51
- 50.0,
52
- ).to_f
53
-
54
- vertical_spacing = get_option(
55
- layout_opts,
56
- "vertiflex.verticalSpacing",
57
- 30.0,
58
- ).to_f
40
+ column_count = [resolver.get("vertiflex.columnCount", graph), 1].max
41
+ column_spacing = resolver.get("vertiflex.columnSpacing", graph)
42
+ vertical_spacing = resolver.get("vertiflex.verticalSpacing", graph)
59
43
 
60
44
  # Override with elk.spacing.nodeNode if present
61
- node_node_spacing = get_option(layout_opts, "elk.spacing.nodeNode")
62
- vertical_spacing = node_node_spacing.to_f if node_node_spacing
45
+ node_node_spacing = resolver.get("elk.spacing.nodeNode", graph,
46
+ default: nil)
47
+ vertical_spacing = node_node_spacing if node_node_spacing
63
48
 
64
- balance_columns = get_option(
65
- layout_opts,
66
- "vertiflex.balanceColumns",
67
- true,
68
- )
49
+ balance_columns = resolver.get("vertiflex.balanceColumns", graph)
69
50
 
70
51
  # Distribute nodes into columns
71
52
  columns = distribute_nodes(nodes, column_count, balance_columns)
@@ -80,22 +61,6 @@ module Elkrb
80
61
 
81
62
  private
82
63
 
83
- # Get option value from layout options or default
84
- #
85
- # @param layout_opts [Hash, LayoutOptions] The layout options
86
- # @param key [String] The option key
87
- # @param default [Object] The default value
88
- # @return [Object] The option value or default
89
- def get_option(layout_opts, key, default = nil)
90
- return default unless layout_opts
91
-
92
- value = if layout_opts.respond_to?(:[])
93
- layout_opts[key]
94
- end
95
-
96
- value.nil? ? default : value
97
- end
98
-
99
64
  # Distribute nodes into columns
100
65
  #
101
66
  # @param nodes [Array<Elkrb::Graph::Node>] Nodes to distribute
@@ -31,7 +31,17 @@ module Elkrb
31
31
  # align_direction: "vertical"
32
32
  # )
33
33
  # # Both nodes will have same x coordinate
34
+ #
35
+ # Members that the average would stack on top of each other are moved
36
+ # apart along the other axis, in their current order.
34
37
  class AlignmentConstraint < BaseConstraint
38
+ # @param spacing [Numeric] minimum gap kept between group members
39
+ # along the axis that is not aligned
40
+ def initialize(spacing: 0.0)
41
+ super()
42
+ @spacing = spacing
43
+ end
44
+
35
45
  # Apply alignment constraint
36
46
  #
37
47
  # Groups nodes by align_group and aligns them according to
@@ -102,26 +112,53 @@ module Elkrb
102
112
  end
103
113
  end
104
114
 
105
- # Align nodes horizontally (same y)
115
+ # Align nodes horizontally (same y), then space them along x
106
116
  def align_horizontally(nodes)
107
- # Use average y position
108
- avg_y = nodes.filter_map(&:y).sum / nodes.length.to_f
109
-
110
- nodes.each do |node|
111
- node.y = avg_y
112
- end
117
+ placed = align_to_average(nodes, :y)
118
+ space_apart(placed, :x, :width)
113
119
  end
114
120
 
115
- # Align nodes vertically (same x)
121
+ # Align nodes vertically (same x), then space them along y
116
122
  def align_vertically(nodes)
117
- # Use average x position
118
- avg_x = nodes.filter_map(&:x).sum / nodes.length.to_f
123
+ placed = align_to_average(nodes, :x)
124
+ space_apart(placed, :y, :height)
125
+ end
119
126
 
120
- nodes.each do |node|
121
- node.x = avg_x
127
+ # Moves every node that has a coordinate on +axis+ to the average of
128
+ # them. A node without one is left alone: it has nothing to align.
129
+ #
130
+ # @return [Array<Graph::Node>] the nodes that were aligned
131
+ def align_to_average(nodes, axis)
132
+ placed = nodes.reject { |node| node.public_send(axis).nil? }
133
+ return placed if placed.empty?
134
+
135
+ average = placed.sum { |node| node.public_send(axis) } /
136
+ placed.length.to_f
137
+ placed.each { |node| node.public_send(:"#{axis}=", average) }
138
+ end
139
+
140
+ # Pushes members apart along +axis+ so none overlaps the one before
141
+ # it. Order is the nodes' current order along the axis; ties keep
142
+ # the order they were given in.
143
+ def space_apart(nodes, axis, size)
144
+ ordered = nodes.each_with_index.sort_by do |node, index|
145
+ [coordinate(node, axis), index]
146
+ end.map(&:first)
147
+
148
+ ordered.each_cons(2) do |previous, node|
149
+ floor = coordinate(previous, axis) +
150
+ coordinate(previous, size) + @spacing
151
+ next unless coordinate(node, axis) < floor
152
+
153
+ node.public_send(:"#{axis}=", floor)
122
154
  end
123
155
  end
124
156
 
157
+ # A coordinate or size that has not been set counts as 0.
158
+ def coordinate(node, attribute)
159
+ node.public_send(attribute) || 0.0
160
+ end
161
+
125
162
  # Validate group alignment
126
163
  def validate_group_alignment(nodes, group_name, direction)
127
164
  errors = []
@@ -32,14 +32,6 @@ module Elkrb
32
32
  []
33
33
  end
34
34
 
35
- # Check if constraint applies to this node
36
- #
37
- # @param node [Graph::Node] The node to check
38
- # @return [Boolean] True if constraint applies
39
- def applies_to?(node)
40
- node.constraints.present?
41
- end
42
-
43
35
  protected
44
36
 
45
37
  # Find node by ID in graph
@@ -50,21 +42,17 @@ module Elkrb
50
42
  def find_node(graph, node_id)
51
43
  return nil unless graph.children
52
44
 
53
- graph.children.each do |node|
54
- found = node.find_node(node_id)
55
- return found if found
56
- end
57
- nil
45
+ graph.children.find { |node| node.id == node_id }
58
46
  end
59
47
 
60
- # Get all nodes from graph (including nested)
48
+ # Get all nodes at this hierarchy level.
61
49
  #
62
50
  # @param graph [Graph::Graph] The graph
63
51
  # @return [Array<Graph::Node>] All nodes
64
52
  def all_nodes(graph)
65
53
  return [] unless graph.children
66
54
 
67
- graph.children.flat_map(&:all_nodes)
55
+ graph.children
68
56
  end
69
57
  end
70
58
  end
@@ -14,7 +14,7 @@ module Elkrb
14
14
  # Orchestrates the application and validation of all layout constraints.
15
15
  # Constraints are applied in a specific order to handle dependencies:
16
16
  # 1. Fixed position (locks nodes)
17
- # 2. Layer constraints (assigns layers)
17
+ # 2. Layer constraints (checked; the layered algorithm honours them)
18
18
  # 3. Relative position (depends on reference nodes)
19
19
  # 4. Alignment (adjusts positions)
20
20
  #
@@ -30,15 +30,14 @@ module Elkrb
30
30
  LayerConstraint,
31
31
  ].freeze
32
32
 
33
- # Post-layout constraints (enforce after algorithm runs)
34
- POST_LAYOUT_CONSTRAINTS = [
35
- RelativePositionConstraint,
36
- AlignmentConstraint,
37
- ].freeze
38
-
39
- def initialize
33
+ # @param spacing [Numeric] minimum gap alignment keeps between the
34
+ # members of one group along the axis it does not align
35
+ def initialize(spacing: 0.0)
40
36
  @pre_constraints = PRE_LAYOUT_CONSTRAINTS.map(&:new)
41
- @post_constraints = POST_LAYOUT_CONSTRAINTS.map(&:new)
37
+ @post_constraints = [
38
+ RelativePositionConstraint.new,
39
+ AlignmentConstraint.new(spacing: spacing),
40
+ ]
42
41
  @all_constraints = (@pre_constraints + @post_constraints)
43
42
  end
44
43
 
@@ -113,20 +112,7 @@ module Elkrb
113
112
  def has_constraints?(graph)
114
113
  return false unless graph.children
115
114
 
116
- graph.children.any? do |node|
117
- has_constraints_recursive?(node)
118
- end
119
- end
120
-
121
- private
122
-
123
- # Check if node or its children have constraints
124
- def has_constraints_recursive?(node)
125
- return true if node.constraints
126
-
127
- return false unless node.children
128
-
129
- node.children.any? { |child| has_constraints_recursive?(child) }
115
+ graph.children.any?(&:constraints)
130
116
  end
131
117
  end
132
118
  end
@@ -16,6 +16,11 @@ module Elkrb
16
16
  # node.constraints = NodeConstraints.new(fixed_position: true)
17
17
  # # After layout, node remains at (500, 800)
18
18
  class FixedPositionConstraint < BaseConstraint
19
+ def initialize
20
+ super
21
+ @originals = {}
22
+ end
23
+
19
24
  # Apply fixed position constraint (pre-layout)
20
25
  #
21
26
  # Stores original positions of fixed nodes so they can be
@@ -28,11 +33,7 @@ module Elkrb
28
33
  next unless node.constraints&.fixed_position
29
34
  next if node.x.nil? || node.y.nil?
30
35
 
31
- # Store original position
32
- node.properties ||= {}
33
- node.properties["_constraint_fixed"] = true
34
- node.properties["_constraint_original_x"] = node.x
35
- node.properties["_constraint_original_y"] = node.y
36
+ @originals[node.id] = [node.x, node.y]
36
37
  end
37
38
 
38
39
  graph
@@ -47,11 +48,10 @@ module Elkrb
47
48
  # @return [Graph::Graph] The modified graph
48
49
  def restore_fixed_positions(graph)
49
50
  all_nodes(graph).each do |node|
50
- next unless node.properties&.[]("_constraint_fixed")
51
+ original = fixed_original(node)
52
+ next unless original
51
53
 
52
- # Restore original position
53
- node.x = node.properties["_constraint_original_x"]
54
- node.y = node.properties["_constraint_original_y"]
54
+ node.x, node.y = original
55
55
  end
56
56
 
57
57
  graph
@@ -67,10 +67,8 @@ module Elkrb
67
67
  errors = []
68
68
 
69
69
  all_nodes(graph).each do |node|
70
- next unless node.properties&.[]("_constraint_fixed")
71
-
72
- original_x = node.properties["_constraint_original_x"]
73
- original_y = node.properties["_constraint_original_y"]
70
+ original_x, original_y = fixed_original(node)
71
+ next unless original_x
74
72
 
75
73
  if node.x != original_x || node.y != original_y
76
74
  errors << "Node '#{node.id}' has fixed_position constraint " \
@@ -81,6 +79,17 @@ module Elkrb
81
79
 
82
80
  errors
83
81
  end
82
+
83
+ private
84
+
85
+ # The recorded position of a node that is still fixed, or nil. The
86
+ # constraint is read from the node every time, so clearing
87
+ # fixed_position releases a node recorded earlier.
88
+ def fixed_original(node)
89
+ return unless node.constraints&.fixed_position
90
+
91
+ @originals[node.id]
92
+ end
84
93
  end
85
94
  end
86
95
  end
@@ -17,53 +17,30 @@ module Elkrb
17
17
  # database.constraints = NodeConstraints.new(layer: 2) # Bottom
18
18
  # # Enforces tier structure
19
19
  class LayerConstraint < BaseConstraint
20
- # Apply layer constraint
21
- #
22
- # Marks nodes with layer assignment that layered algorithm
23
- # must respect.
20
+ # Nothing to do before layout: the layered algorithm reads
21
+ # `constraints.layer` itself when it assigns layers.
24
22
  #
25
23
  # @param graph [Graph::Graph] The graph
26
- # @return [Graph::Graph] The modified graph
24
+ # @return [Graph::Graph] The graph, unchanged
27
25
  def apply(graph)
28
- all_nodes(graph).each do |node|
29
- next unless node.constraints&.layer
30
-
31
- # Mark node with its required layer
32
- node.properties ||= {}
33
- node.properties["_constraint_layer"] = node.constraints.layer
34
- end
35
-
36
26
  graph
37
27
  end
38
28
 
39
29
  # Validate layer constraints
40
30
  #
41
- # Checks that nodes assigned to layers are in correct layers.
42
- # Note: This validation only applies if the layered algorithm
43
- # was used and layer information is available.
31
+ # A layer index below 0 names no layer, so the layered algorithm
32
+ # places such a node in layer 0.
44
33
  #
45
34
  # @param graph [Graph::Graph] The graph to validate
46
35
  # @return [Array<String>] List of validation errors
47
36
  def validate(graph)
48
- errors = []
37
+ all_nodes(graph).filter_map do |node|
38
+ layer = node.constraints&.layer
39
+ next unless layer&.negative?
49
40
 
50
- # Check if layer information is available
51
- # (only present if layered algorithm was used)
52
- all_nodes(graph).each do |node|
53
- next unless node.constraints&.layer
54
- next unless node.properties&.[]("_assigned_layer")
55
-
56
- expected_layer = node.constraints.layer
57
- actual_layer = node.properties["_assigned_layer"]
58
-
59
- if expected_layer != actual_layer
60
- errors << "Node '#{node.id}' constrained to layer " \
61
- "#{expected_layer} but assigned to layer " \
62
- "#{actual_layer}"
63
- end
41
+ "Node '#{node.id}' constrained to layer #{layer}, " \
42
+ "which does not exist; layers start at 0"
64
43
  end
65
-
66
- errors
67
44
  end
68
45
  end
69
46
  end