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.
- checksums.yaml +4 -4
- data/CHANGELOG.adoc +364 -0
- data/LICENSE +25 -0
- data/README.adoc +695 -557
- data/exe/elkrb +36 -2
- data/lib/elkrb/atomic_destination.rb +53 -0
- data/lib/elkrb/best_effort_write.rb +49 -0
- data/lib/elkrb/cli/layout_flag_options.rb +54 -0
- data/lib/elkrb/cli.rb +155 -82
- data/lib/elkrb/command_resolver.rb +186 -0
- data/lib/elkrb/commands/batch_command.rb +28 -5
- data/lib/elkrb/commands/convert_command.rb +22 -52
- data/lib/elkrb/commands/diagram_command.rb +234 -102
- data/lib/elkrb/commands/options_command.rb +87 -0
- data/lib/elkrb/commands/render_command.rb +6 -1
- data/lib/elkrb/commands/validate_command.rb +146 -91
- data/lib/elkrb/errors.rb +20 -1
- data/lib/elkrb/format_sniffer.rb +362 -0
- data/lib/elkrb/geometry/bezier.rb +14 -4
- data/lib/elkrb/geometry/point.rb +5 -6
- data/lib/elkrb/geometry/rectangle.rb +2 -2
- data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
- data/lib/elkrb/graph/edge.rb +89 -3
- data/lib/elkrb/graph/graph.rb +43 -15
- data/lib/elkrb/graph/label.rb +16 -2
- data/lib/elkrb/graph/node.rb +13 -2
- data/lib/elkrb/graph/node_constraints.rb +139 -8
- data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
- data/lib/elkrb/graph/port.rb +29 -21
- data/lib/elkrb/graph/read_only_mapping.rb +21 -0
- data/lib/elkrb/graphviz_wrapper.rb +152 -44
- data/lib/elkrb/layout/algorithm_registry.rb +65 -8
- data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
- data/lib/elkrb/layout/algorithms/box.rb +47 -20
- data/lib/elkrb/layout/algorithms/disco.rb +150 -61
- data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
- data/lib/elkrb/layout/algorithms/force.rb +145 -86
- data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
- data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
- data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
- data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
- data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
- data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
- data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
- data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
- data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
- data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
- data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
- data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
- data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
- data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
- data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
- data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
- data/lib/elkrb/layout/algorithms/layered.rb +548 -13
- data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
- data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
- data/lib/elkrb/layout/algorithms/radial.rb +188 -29
- data/lib/elkrb/layout/algorithms/random.rb +131 -17
- data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
- data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
- data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
- data/lib/elkrb/layout/algorithms/stress.rb +391 -84
- data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
- data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
- data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
- data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
- data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
- data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
- data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
- data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
- data/lib/elkrb/layout/edge_router.rb +533 -337
- data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
- data/lib/elkrb/layout/java_random.rb +59 -0
- data/lib/elkrb/layout/label_placer.rb +165 -157
- data/lib/elkrb/layout/layout_engine.rb +46 -23
- data/lib/elkrb/layout/node_index.rb +149 -0
- data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
- data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
- data/lib/elkrb/layout/polyomino/grid.rb +106 -0
- data/lib/elkrb/layout/polyomino/packer.rb +95 -0
- data/lib/elkrb/layout/polyomino/piece.rb +52 -0
- data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
- data/lib/elkrb/layout/spore/body.rb +65 -0
- data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
- data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
- data/lib/elkrb/layout/spore/gap.rb +76 -0
- data/lib/elkrb/layout/spore/geometry.rb +104 -0
- data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
- data/lib/elkrb/layout/spore/placement.rb +57 -0
- data/lib/elkrb/layout/spore/rays.rb +119 -0
- data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
- data/lib/elkrb/layout/spore/triangulation.rb +159 -0
- data/lib/elkrb/layout_flags.rb +69 -0
- data/lib/elkrb/options/decimal.rb +50 -0
- data/lib/elkrb/options/element_options.rb +43 -0
- data/lib/elkrb/options/elk_padding.rb +20 -14
- data/lib/elkrb/options/k_vector.rb +6 -7
- data/lib/elkrb/options/k_vector_chain.rb +12 -20
- data/lib/elkrb/options/option_map.rb +85 -0
- data/lib/elkrb/options/registry.rb +335 -0
- data/lib/elkrb/options/resolver.rb +151 -0
- data/lib/elkrb/options/spellings.rb +62 -0
- data/lib/elkrb/options/unhonoured_report.rb +194 -0
- data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
- data/lib/elkrb/parsers/elkt/parser.rb +424 -0
- data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
- data/lib/elkrb/parsers/elkt/token.rb +40 -0
- data/lib/elkrb/parsers/elkt_parser.rb +32 -230
- data/lib/elkrb/serializers/dot_serializer.rb +342 -54
- data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
- data/lib/elkrb/version.rb +1 -1
- data/lib/elkrb.rb +60 -61
- metadata +71 -49
- data/.rspec +0 -3
- data/.rubocop.yml +0 -11
- data/Gemfile +0 -13
- data/Rakefile +0 -64
- data/benchmarks/README.md +0 -172
- data/benchmarks/elkjs_benchmark.js +0 -140
- data/benchmarks/elkrb_benchmark.rb +0 -145
- data/benchmarks/fixtures/graphs.json +0 -10777
- data/benchmarks/generate_report.rb +0 -241
- data/benchmarks/generate_test_graphs.rb +0 -154
- data/benchmarks/results/elkrb_results.json +0 -280
- data/benchmarks/results/elkrb_summary.json +0 -285
- data/elkrb.gemspec +0 -39
- data/examples/dot_export_demo.rb +0 -133
- data/examples/hierarchical_graph.rb +0 -19
- data/examples/layout_constraints_demo.rb +0 -272
- data/examples/port_constraints_demo.rb +0 -291
- data/examples/self_loop_demo.rb +0 -391
- data/examples/simple_graph.rb +0 -50
- data/examples/spline_routing_demo.rb +0 -235
- data/lib/elkrb/graph/layout_options.rb +0 -247
- data/sig/elkrb/constraints.rbs +0 -114
- data/sig/elkrb/geometry.rbs +0 -61
- data/sig/elkrb/graph.rbs +0 -112
- data/sig/elkrb/layout.rbs +0 -107
- data/sig/elkrb/options.rbs +0 -81
- 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
|
-
|
|
21
|
-
|
|
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 =
|
|
28
|
-
epsilon = option("epsilon"
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
284
|
+
adjacency = Array.new(n) { [] }
|
|
69
285
|
|
|
70
|
-
#
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
305
|
+
adjacency[i] << j
|
|
306
|
+
adjacency[j] << i
|
|
87
307
|
end
|
|
88
308
|
|
|
89
|
-
|
|
90
|
-
n
|
|
91
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
321
|
+
while cursor < queue.length
|
|
322
|
+
current = queue[cursor]
|
|
323
|
+
cursor += 1
|
|
116
324
|
|
|
117
|
-
|
|
118
|
-
next
|
|
325
|
+
adjacency[current].each do |neighbor|
|
|
326
|
+
next unless distances[neighbor] == Float::INFINITY
|
|
119
327
|
|
|
120
|
-
|
|
121
|
-
|
|
328
|
+
distances[neighbor] = distances[current] + edge_length
|
|
329
|
+
queue << neighbor
|
|
122
330
|
end
|
|
123
331
|
end
|
|
124
332
|
|
|
125
|
-
|
|
333
|
+
distances
|
|
126
334
|
end
|
|
127
335
|
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
373
|
+
farthest = [i, j, distance]
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
farthest if farthest&.last&.positive?
|
|
377
|
+
end
|
|
139
378
|
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
477
|
+
|
|
478
|
+
[sum_x, sum_y, sum_weight]
|
|
172
479
|
end
|
|
173
480
|
end
|
|
174
481
|
end
|