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
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base_algorithm"
4
+ require_relative "../java_random"
5
+ require_relative "../node_index"
4
6
 
5
7
  module Elkrb
6
8
  module Layout
@@ -17,15 +19,26 @@ module Elkrb
17
19
  # - Publication-ready layouts
18
20
  # - Small to medium-sized graphs
19
21
  class Stress < BaseAlgorithm
20
- DEFAULT_ITERATIONS = 500
21
- DEFAULT_EPSILON = 0.0001
22
+ # The registry owns these; the constants stay for existing callers.
23
+ DEFAULT_ITERATIONS =
24
+ Options::Registry.default("elk.stress.iterationLimit")
25
+ DEFAULT_EPSILON = Options::Registry.default("elk.stress.epsilon")
26
+ ITERATION_LIMIT = "elk.stress.iterationLimit"
27
+ FORCE_ITERATIONS = 300
28
+ FORCE_TEMPERATURE = 0.001
29
+ FORCE_SPACING = 80.0
30
+ FORCE_PADDING = { left: 50.0, top: 50.0, right: 50.0,
31
+ bottom: 50.0 }.freeze
32
+ FORCE_ZERO_FACTOR = 100.0
33
+ FORCE_DISPLACEMENT_FACTOR = 16
34
+ FORCE_PAIR_INTERACTION_BUDGET = 250_000
22
35
 
23
36
  def layout_flat(graph, _options = {})
24
37
  return graph if graph.children.nil? || graph.children.empty?
25
38
 
26
39
  # Get configuration
27
- iterations = option("iterations", DEFAULT_ITERATIONS).to_i
28
- epsilon = option("epsilon", DEFAULT_EPSILON).to_f
40
+ iterations = call_iterations
41
+ epsilon = option("elk.stress.epsilon")
29
42
 
30
43
  # Initialize positions
31
44
  initialize_positions(graph)
@@ -34,13 +47,35 @@ module Elkrb
34
47
  distances = calculate_distances(graph)
35
48
 
36
49
  # Iteratively minimize stress
37
- iterations.times do |_i|
38
- old_stress = calculate_stress(graph, distances)
39
- optimize_positions(graph, distances)
40
- new_stress = calculate_stress(graph, distances)
50
+ xpos = graph.children.map(&:x)
51
+ ypos = graph.children.map(&:y)
52
+ # Keep the Java-parity path for small graphs; the exact solver
53
+ # replaces iterative work only where the force prepass is bounded.
54
+ if iterations == DEFAULT_ITERATIONS && epsilon.positive? &&
55
+ force_iterations(xpos.length) < FORCE_ITERATIONS
56
+ project_line_metric(xpos, ypos, distances)
57
+ end
58
+ weights = pair_weights(distances)
59
+ reachable = reachable_pairs(distances)
60
+ old_stress = calculate_stress(
61
+ xpos, ypos, distances, weights, reachable
62
+ )
63
+ # Java ELK's do/while checks the limit after an iteration has run,
64
+ # so a limit of N allows N + 1 iterations.
65
+ (iterations.clamp(0..) + 1).times do
66
+ optimize_positions(xpos, ypos, distances, weights, reachable)
67
+ new_stress = calculate_stress(
68
+ xpos, ypos, distances, weights, reachable
69
+ )
70
+
71
+ break if converged?(old_stress, new_stress, epsilon)
72
+
73
+ old_stress = new_stress
74
+ end
41
75
 
42
- # Stop if converged
43
- break if (old_stress - new_stress).abs < epsilon
76
+ graph.children.each_with_index do |node, i|
77
+ node.x = xpos[i]
78
+ node.y = ypos[i]
44
79
  end
45
80
 
46
81
  # Apply padding and set graph dimensions
@@ -51,124 +86,396 @@ module Elkrb
51
86
 
52
87
  private
53
88
 
89
+ # Same stop test as Java ELK: the stress improvement relative to the
90
+ # previous stress, so graphs of any size stop at the same quality.
91
+ # The default iteration limit is Java's Integer.MAX_VALUE, so this is
92
+ # what ends the loop: a non-finite stress or an iteration that made
93
+ # no progress also stops, whatever the epsilon, or the loop would
94
+ # never end for an epsilon of zero or less.
95
+ def converged?(old_stress, new_stress, epsilon)
96
+ return true unless old_stress.finite? && new_stress.finite?
97
+ return true if old_stress.zero?
98
+
99
+ improvement = (old_stress - new_stress) / old_stress
100
+ improvement <= 0 || improvement < epsilon
101
+ end
102
+
103
+ # The registry reads "iterations" as force's elk.force.iterations, so
104
+ # the resolver alone would let force's option change a stress layout.
105
+ # The call's own "iterations" key still wins, validated as stress's
106
+ # option; otherwise elk.stress.iterationLimit is resolved.
107
+ def call_iterations
108
+ call = options.is_a?(Hash) ? options : {}
109
+ given = call.fetch("iterations", nil) || call.fetch(:iterations, nil)
110
+ unless given
111
+ return option(ITERATION_LIMIT, default: DEFAULT_ITERATIONS)
112
+ end
113
+
114
+ Options::Resolver.new(ITERATION_LIMIT => given).get(ITERATION_LIMIT)
115
+ end
116
+
54
117
  def initialize_positions(graph)
55
- # Use circular initial layout
56
- n = graph.children.length
57
- radius = n * 10.0
118
+ nodes = graph.children
119
+ random = JavaRandom.new(option("elk.randomSeed").to_i)
120
+ positions = force_seed_positions(nodes.length, random)
121
+ connections, edge_count = force_connections(graph)
58
122
 
59
- graph.children.each_with_index do |node, i|
60
- angle = 2 * Math::PI * i / n
61
- node.x = (radius * Math.cos(angle)) + radius
62
- node.y = (radius * Math.sin(angle)) + radius
123
+ run_force_prepass(nodes, positions, connections, edge_count, random)
124
+ apply_force_positions(nodes, positions)
125
+ end
126
+
127
+ def force_seed_positions(count, random)
128
+ x = Array.new(count)
129
+ y = Array.new(count)
130
+ count.times do |i|
131
+ x[i] = random.next_double * count
132
+ y[i] = random.next_double * count
133
+ end
134
+ [x, y]
135
+ end
136
+
137
+ def force_connections(graph)
138
+ nodes = graph.children
139
+ count = nodes.length
140
+ matrix = Array.new(count * count, 0)
141
+ positions = nodes.each_with_index.to_h { |node, i| [node.id, i] }
142
+ index = NodeIndex.build(graph)
143
+ edge_count = 0
144
+
145
+ (graph.edges || []).each do |edge|
146
+ source = index.node(edge.sources&.first)
147
+ target = index.node(edge.targets&.first)
148
+ next unless source && target
149
+
150
+ source_slot = positions[source.id]
151
+ target_slot = positions[target.id]
152
+ next unless source_slot && target_slot
153
+ next if source_slot == target_slot
154
+
155
+ matrix[(source_slot * count) + target_slot] += 1
156
+ edge_count += 1
157
+ end
158
+
159
+ [matrix, edge_count]
160
+ end
161
+
162
+ def run_force_prepass(nodes, positions, connections, edge_count,
163
+ random)
164
+ count = nodes.length
165
+ widths = nodes.map { |node| [node.width.to_f, 1.0].max }
166
+ heights = nodes.map { |node| [node.height.to_f, 1.0].max }
167
+ radii = widths.zip(heights).map do |width, height|
168
+ Math.hypot(width, height) / 2.0
169
+ end
170
+ area_constant = Math.sqrt(
171
+ (widths.sum * heights.sum) / (2.0 * count),
172
+ )
173
+ force_constant = (area_constant * FORCE_SPACING) * 0.01
174
+ bound = [(count * FORCE_DISPLACEMENT_FACTOR) + edge_count,
175
+ FORCE_DISPLACEMENT_FACTOR**2].max
176
+ iterations = force_iterations(count)
177
+ temperature = FORCE_TEMPERATURE
178
+ threshold = temperature / iterations
179
+ state = {
180
+ positions: positions, radii: radii, connections: connections,
181
+ force_constant: force_constant, bound: bound, random: random
182
+ }
183
+
184
+ while temperature.positive?
185
+ temperature -= threshold
186
+ force_iteration(state, temperature)
187
+ end
188
+ end
189
+
190
+ # The exact 300-iteration prepass is cheap for the small graphs it was
191
+ # designed for. On large graphs, bound its quadratic work and preserve
192
+ # the same cooling curve across the deterministic reduced iteration
193
+ # count so Stress keeps its documented performance guarantee.
194
+ def force_iterations(node_count)
195
+ pair_count = node_count * (node_count - 1) / 2
196
+ return FORCE_ITERATIONS if pair_count.zero?
197
+
198
+ budgeted = [FORCE_PAIR_INTERACTION_BUDGET / pair_count, 1].max
199
+ [FORCE_ITERATIONS, budgeted].min
200
+ end
201
+
202
+ def force_iteration(state, temperature)
203
+ positions = state[:positions]
204
+ radii = state[:radii]
205
+ connections = state[:connections]
206
+ force_constant = state[:force_constant]
207
+ bound = state[:bound]
208
+ random = state[:random]
209
+ x, y = positions
210
+ count = x.length
211
+ dx_sum = Array.new(count, 0.0)
212
+ dy_sum = Array.new(count, 0.0)
213
+ i = 0
214
+
215
+ while i < count
216
+ j = i + 1
217
+ while j < count
218
+ separate_coincident_positions(positions, i, j, random)
219
+ dx = x[i] - x[j]
220
+ dy = y[i] - y[j]
221
+ length = Math.sqrt((dx * dx) + (dy * dy))
222
+ border_distance = [length - radii[i] - radii[j], 0.0].max
223
+ force = if border_distance.positive?
224
+ force_constant * force_constant / border_distance
225
+ else
226
+ force_constant * force_constant * FORCE_ZERO_FACTOR
227
+ end
228
+ connection = connections[(i * count) + j]
229
+ connection += connections[(j * count) + i]
230
+ attraction = border_distance * border_distance / force_constant
231
+ force -= attraction * connection
232
+ scale = force * temperature / length
233
+ force_x = dx * scale
234
+ force_y = dy * scale
235
+ dx_sum[i] += force_x
236
+ dy_sum[i] += force_y
237
+ dx_sum[j] -= force_x
238
+ dy_sum[j] -= force_y
239
+ j += 1
240
+ end
241
+ i += 1
242
+ end
243
+
244
+ count.times do |slot|
245
+ x[slot] += dx_sum[slot].clamp(-bound, bound)
246
+ y[slot] += dy_sum[slot].clamp(-bound, bound)
247
+ end
248
+ end
249
+
250
+ def separate_coincident_positions(positions, first, second, random)
251
+ x, y = positions
252
+ while x[first] == x[second] && y[first] == y[second]
253
+ x[second] += random.next_double - 0.5
254
+ y[second] += random.next_double - 0.5
255
+ x[first] += random.next_double - 0.5
256
+ y[first] += random.next_double - 0.5
257
+ end
258
+ end
259
+
260
+ def apply_force_positions(nodes, positions)
261
+ x, y = positions
262
+ left = nodes.each_index.map do |i|
263
+ x[i] - ([nodes[i].width.to_f, 1.0].max / 2.0)
264
+ end.min
265
+ top = nodes.each_index.map do |i|
266
+ y[i] - ([nodes[i].height.to_f, 1.0].max / 2.0)
267
+ end.min
268
+ pad = padding
269
+
270
+ nodes.each_with_index do |node, i|
271
+ node.x = x[i] + pad[:left] - left - (node.width.to_f / 2.0)
272
+ node.y = y[i] + pad[:top] - top - (node.height.to_f / 2.0)
63
273
  end
64
274
  end
65
275
 
276
+ # ELK Stress uses Force's 50px padding for its noninteractive seed
277
+ # pass, and keeps that padding for the final stress result.
278
+ def padding
279
+ option("elk.padding", default: FORCE_PADDING).to_h
280
+ end
281
+
66
282
  def calculate_distances(graph)
67
283
  n = graph.children.length
68
- distances = Array.new(n) { Array.new(n, Float::INFINITY) }
284
+ adjacency = Array.new(n) { [] }
69
285
 
70
- # Initialize distances
71
- n.times { |i| distances[i][i] = 0 }
286
+ # Build adjacency from endpoints resolved to their owning nodes.
287
+ index = NodeIndex.build(graph)
288
+ positions = graph.children.each_with_index.to_h do |node, i|
289
+ [node.id, i]
290
+ end
72
291
 
73
- # Set edge distances
74
- all_edges = collect_all_edges(graph)
75
- all_edges.each do |edge|
292
+ index.edges.each do |edge|
76
293
  source_id = edge.sources&.first
77
294
  target_id = edge.targets&.first
78
295
  next unless source_id && target_id
79
296
 
80
- i = graph.children.index { |n| n.id == source_id }
81
- j = graph.children.index { |n| n.id == target_id }
297
+ source_node = index.node(source_id)
298
+ target_node = index.node(target_id)
299
+ next unless source_node && target_node
82
300
 
301
+ i = positions[source_node.id]
302
+ j = positions[target_node.id]
83
303
  next unless i && j
84
304
 
85
- distances[i][j] = 1.0
86
- distances[j][i] = 1.0
305
+ adjacency[i] << j
306
+ adjacency[j] << i
87
307
  end
88
308
 
89
- # Floyd-Warshall for shortest paths
90
- n.times do |k|
91
- n.times do |i|
92
- n.times do |j|
93
- distances[i][j] = [
94
- distances[i][j],
95
- distances[i][k] + distances[k][j],
96
- ].min
97
- end
98
- end
309
+ edge_length = option("elk.stress.desiredEdgeLength").to_f
310
+ Array.new(n) do |source|
311
+ bfs_distances(adjacency, source, edge_length)
99
312
  end
100
-
101
- distances
102
313
  end
103
314
 
104
- def calculate_stress(graph, ideal_distances)
105
- stress = 0.0
106
- n = graph.children.length
107
-
108
- n.times do |i|
109
- (i + 1).upto(n - 1) do |j|
110
- node_i = graph.children[i]
111
- node_j = graph.children[j]
315
+ def bfs_distances(adjacency, source, edge_length)
316
+ distances = Array.new(adjacency.length, Float::INFINITY)
317
+ distances[source] = 0.0
318
+ queue = [source]
319
+ cursor = 0
112
320
 
113
- dx = node_j.x - node_i.x
114
- dy = node_j.y - node_i.y
115
- actual_dist = Math.sqrt((dx**2) + (dy**2))
321
+ while cursor < queue.length
322
+ current = queue[cursor]
323
+ cursor += 1
116
324
 
117
- ideal_dist = ideal_distances[i][j]
118
- next if ideal_dist == Float::INFINITY
325
+ adjacency[current].each do |neighbor|
326
+ next unless distances[neighbor] == Float::INFINITY
119
327
 
120
- diff = actual_dist - ideal_dist
121
- stress += diff * diff
328
+ distances[neighbor] = distances[current] + edge_length
329
+ queue << neighbor
122
330
  end
123
331
  end
124
332
 
125
- stress
333
+ distances
126
334
  end
127
335
 
128
- def optimize_positions(graph, ideal_distances)
129
- n = graph.children.length
336
+ # A graph whose shortest-path metric lies on a line has a closed-form
337
+ # zero-stress layout. Recognize the metric itself rather than a graph
338
+ # shape, so non-line graphs keep the localized ELK iteration unchanged.
339
+ def project_line_metric(xpos, ypos, distances)
340
+ endpoints = line_metric_endpoints(distances)
341
+ return unless endpoints
342
+
343
+ first, last, diameter = endpoints
344
+ offsets = distances[first]
345
+ tolerance = [diameter * 1e-9, 1e-9].max
346
+ return unless line_metric?(offsets, distances, tolerance)
347
+
348
+ dx = xpos[last] - xpos[first]
349
+ dy = ypos[last] - ypos[first]
350
+ length = Math.hypot(dx, dy)
351
+ unit_x, unit_y = if length.positive?
352
+ [dx / length, dy / length]
353
+ else
354
+ [1.0, 0.0]
355
+ end
356
+ origin_x = xpos[first]
357
+ origin_y = ypos[first]
358
+
359
+ offsets.each_index do |i|
360
+ xpos[i] = origin_x + (unit_x * offsets[i])
361
+ ypos[i] = origin_y + (unit_y * offsets[i])
362
+ end
363
+ end
130
364
 
131
- # Calculate new positions using stress majorization
132
- graph.children.each_with_index do |node, i|
133
- sum_x = 0.0
134
- sum_y = 0.0
135
- sum_weight = 0.0
365
+ def line_metric_endpoints(distances)
366
+ farthest = nil
367
+ distances.each_with_index do |row, i|
368
+ row.each_with_index do |distance, j|
369
+ return nil unless distance.finite?
370
+ next unless j > i
371
+ next if farthest && distance <= farthest[2]
136
372
 
137
- n.times do |j|
138
- next if i == j
373
+ farthest = [i, j, distance]
374
+ end
375
+ end
376
+ farthest if farthest&.last&.positive?
377
+ end
139
378
 
140
- ideal_dist = ideal_distances[i][j]
141
- next if ideal_dist == Float::INFINITY
379
+ def line_metric?(offsets, distances, tolerance)
380
+ distances.each_with_index.all? do |row, i|
381
+ row.each_with_index.all? do |distance, j|
382
+ ((offsets[i] - offsets[j]).abs - distance).abs <= tolerance
383
+ end
384
+ end
385
+ end
142
386
 
143
- other = graph.children[j]
144
- dx = other.x - node.x
145
- dy = other.y - node.y
146
- actual_dist = Math.sqrt((dx**2) + (dy**2))
387
+ # 1 / ideal^2 per pair, computed once instead of every iteration.
388
+ def pair_weights(ideal_distances)
389
+ ideal_distances.map do |row|
390
+ row.map { |ideal| 1.0 / (ideal * ideal) }
391
+ end
392
+ end
147
393
 
148
- next if actual_dist.zero?
394
+ # For each node, the other nodes it has a finite ideal distance to.
395
+ def reachable_pairs(ideal_distances)
396
+ ideal_distances.each_with_index.map do |row, i|
397
+ row.each_index.select { |j| j != i && row[j] != Float::INFINITY }
398
+ end
399
+ end
149
400
 
150
- weight = 1.0 / (ideal_dist * ideal_dist)
151
- ratio = ideal_dist / actual_dist
401
+ # The loops below are plain whiles over precomputed index lists:
402
+ # blocks here slow a 200-node layout down several times.
403
+ def calculate_stress(xpos, ypos, ideal_distances, weights, reachable)
404
+ stress = 0.0
152
405
 
153
- sum_x += weight * (other.x - (ratio * dx))
154
- sum_y += weight * (other.y - (ratio * dy))
155
- sum_weight += weight
406
+ i = 0
407
+ n = xpos.length
408
+ while i < n
409
+ row = ideal_distances[i]
410
+ weight_row = weights[i]
411
+ node_x = xpos[i]
412
+ node_y = ypos[i]
413
+ others = reachable[i]
414
+ k = 0
415
+ count = others.length
416
+ while k < count
417
+ j = others[k]
418
+ if j > i
419
+ dx = xpos[j] - node_x
420
+ dy = ypos[j] - node_y
421
+ diff = Math.sqrt((dx * dx) + (dy * dy)) - row[j]
422
+ stress += weight_row[j] * diff * diff
423
+ end
424
+ k += 1
156
425
  end
426
+ i += 1
427
+ end
157
428
 
429
+ stress
430
+ end
431
+
432
+ # Updates xpos and ypos in place, one node at a time, so each node sees
433
+ # the already-moved positions of the nodes before it.
434
+ def optimize_positions(xpos, ypos, ideal_distances, weights, reachable)
435
+ i = 0
436
+ n = xpos.length
437
+ while i < n
438
+ sum_x, sum_y, sum_weight = pull_sums(
439
+ [xpos, ypos], i, ideal_distances[i], weights[i], reachable[i]
440
+ )
158
441
  if sum_weight.positive?
159
- node.x = sum_x / sum_weight
160
- node.y = sum_y / sum_weight
442
+ xpos[i] = sum_x / sum_weight
443
+ ypos[i] = sum_y / sum_weight
161
444
  end
445
+ i += 1
162
446
  end
163
447
  end
164
448
 
165
- def collect_all_edges(graph)
166
- edges = []
167
- edges.concat(graph.edges) if graph.edges
168
- graph.children&.each do |node|
169
- edges.concat(node.edges) if node.edges
449
+ def pull_sums(coordinates, node, row, weight_row, others)
450
+ xpos, ypos = coordinates
451
+ node_x = xpos[node]
452
+ node_y = ypos[node]
453
+ sum_x = 0.0
454
+ sum_y = 0.0
455
+ sum_weight = 0.0
456
+
457
+ k = 0
458
+ count = others.length
459
+ while k < count
460
+ j = others[k]
461
+ other_x = xpos[j]
462
+ other_y = ypos[j]
463
+ dx = other_x - node_x
464
+ dy = other_y - node_y
465
+ actual_dist = Math.sqrt((dx * dx) + (dy * dy))
466
+
467
+ unless actual_dist.zero?
468
+ weight = weight_row[j]
469
+ ratio = row[j] / actual_dist
470
+
471
+ sum_x += weight * (other_x - (ratio * dx))
472
+ sum_y += weight * (other_y - (ratio * dy))
473
+ sum_weight += weight
474
+ end
475
+ k += 1
170
476
  end
171
- edges
477
+
478
+ [sum_x, sum_y, sum_weight]
172
479
  end
173
480
  end
174
481
  end