elkrb 1.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +11 -0
- data/Gemfile +13 -0
- data/README.adoc +1028 -0
- data/Rakefile +64 -0
- data/benchmarks/README.md +172 -0
- data/benchmarks/elkjs_benchmark.js +140 -0
- data/benchmarks/elkrb_benchmark.rb +145 -0
- data/benchmarks/fixtures/graphs.json +10777 -0
- data/benchmarks/generate_report.rb +241 -0
- data/benchmarks/generate_test_graphs.rb +154 -0
- data/benchmarks/results/elkrb_results.json +280 -0
- data/benchmarks/results/elkrb_summary.json +285 -0
- data/elkrb.gemspec +39 -0
- data/examples/dot_export_demo.rb +133 -0
- data/examples/hierarchical_graph.rb +19 -0
- data/examples/layout_constraints_demo.rb +272 -0
- data/examples/port_constraints_demo.rb +291 -0
- data/examples/self_loop_demo.rb +391 -0
- data/examples/simple_graph.rb +50 -0
- data/examples/spline_routing_demo.rb +235 -0
- data/exe/elkrb +8 -0
- data/lib/elkrb/cli.rb +224 -0
- data/lib/elkrb/commands/batch_command.rb +66 -0
- data/lib/elkrb/commands/convert_command.rb +130 -0
- data/lib/elkrb/commands/diagram_command.rb +208 -0
- data/lib/elkrb/commands/render_command.rb +52 -0
- data/lib/elkrb/commands/validate_command.rb +241 -0
- data/lib/elkrb/errors.rb +30 -0
- data/lib/elkrb/geometry/bezier.rb +163 -0
- data/lib/elkrb/geometry/dimension.rb +32 -0
- data/lib/elkrb/geometry/point.rb +68 -0
- data/lib/elkrb/geometry/rectangle.rb +86 -0
- data/lib/elkrb/geometry/vector.rb +67 -0
- data/lib/elkrb/graph/edge.rb +95 -0
- data/lib/elkrb/graph/graph.rb +90 -0
- data/lib/elkrb/graph/label.rb +45 -0
- data/lib/elkrb/graph/layout_options.rb +247 -0
- data/lib/elkrb/graph/node.rb +79 -0
- data/lib/elkrb/graph/node_constraints.rb +107 -0
- data/lib/elkrb/graph/port.rb +104 -0
- data/lib/elkrb/graphviz_wrapper.rb +133 -0
- data/lib/elkrb/layout/algorithm_registry.rb +57 -0
- data/lib/elkrb/layout/algorithms/base_algorithm.rb +208 -0
- data/lib/elkrb/layout/algorithms/box.rb +47 -0
- data/lib/elkrb/layout/algorithms/disco.rb +206 -0
- data/lib/elkrb/layout/algorithms/fixed.rb +32 -0
- data/lib/elkrb/layout/algorithms/force.rb +165 -0
- data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +86 -0
- data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +96 -0
- data/lib/elkrb/layout/algorithms/layered/node_placer.rb +77 -0
- data/lib/elkrb/layout/algorithms/layered.rb +49 -0
- data/lib/elkrb/layout/algorithms/libavoid.rb +389 -0
- data/lib/elkrb/layout/algorithms/mrtree.rb +144 -0
- data/lib/elkrb/layout/algorithms/radial.rb +64 -0
- data/lib/elkrb/layout/algorithms/random.rb +43 -0
- data/lib/elkrb/layout/algorithms/rectpacking.rb +93 -0
- data/lib/elkrb/layout/algorithms/spore_compaction.rb +139 -0
- data/lib/elkrb/layout/algorithms/spore_overlap.rb +117 -0
- data/lib/elkrb/layout/algorithms/stress.rb +176 -0
- data/lib/elkrb/layout/algorithms/topdown_packing.rb +183 -0
- data/lib/elkrb/layout/algorithms/vertiflex.rb +174 -0
- data/lib/elkrb/layout/constraints/alignment_constraint.rb +150 -0
- data/lib/elkrb/layout/constraints/base_constraint.rb +72 -0
- data/lib/elkrb/layout/constraints/constraint_processor.rb +134 -0
- data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +87 -0
- data/lib/elkrb/layout/constraints/layer_constraint.rb +71 -0
- data/lib/elkrb/layout/constraints/relative_position_constraint.rb +110 -0
- data/lib/elkrb/layout/edge_router.rb +935 -0
- data/lib/elkrb/layout/hierarchical_processor.rb +299 -0
- data/lib/elkrb/layout/label_placer.rb +338 -0
- data/lib/elkrb/layout/layout_engine.rb +170 -0
- data/lib/elkrb/layout/port_constraint_processor.rb +173 -0
- data/lib/elkrb/options/elk_padding.rb +94 -0
- data/lib/elkrb/options/k_vector.rb +100 -0
- data/lib/elkrb/options/k_vector_chain.rb +135 -0
- data/lib/elkrb/parsers/elkt_parser.rb +248 -0
- data/lib/elkrb/serializers/dot_serializer.rb +339 -0
- data/lib/elkrb/serializers/elkt_serializer.rb +236 -0
- data/lib/elkrb/version.rb +5 -0
- data/lib/elkrb.rb +509 -0
- data/sig/elkrb/constraints.rbs +114 -0
- data/sig/elkrb/geometry.rbs +61 -0
- data/sig/elkrb/graph.rbs +112 -0
- data/sig/elkrb/layout.rbs +107 -0
- data/sig/elkrb/options.rbs +81 -0
- data/sig/elkrb.rbs +32 -0
- metadata +179 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_algorithm"
|
|
4
|
+
require_relative "../../geometry/point"
|
|
5
|
+
require_relative "../../geometry/rectangle"
|
|
6
|
+
|
|
7
|
+
module Elkrb
|
|
8
|
+
module Layout
|
|
9
|
+
module Algorithms
|
|
10
|
+
# Libavoid connector routing algorithm
|
|
11
|
+
#
|
|
12
|
+
# Routes orthogonal connectors around obstacles (nodes) using A* pathfinding.
|
|
13
|
+
# Minimizes connector length and bends while avoiding overlaps with nodes.
|
|
14
|
+
# Based on the concepts from the libavoid C++ library.
|
|
15
|
+
#
|
|
16
|
+
# Features:
|
|
17
|
+
# - Orthogonal (90-degree) routing
|
|
18
|
+
# - Obstacle avoidance
|
|
19
|
+
# - Bend minimization
|
|
20
|
+
# - Configurable routing padding
|
|
21
|
+
#
|
|
22
|
+
# Options:
|
|
23
|
+
# - libavoid.routingPadding: Padding around obstacles (default: 10)
|
|
24
|
+
# - libavoid.segmentPenalty: Penalty for additional segments (default: 1.0)
|
|
25
|
+
# - libavoid.bendPenalty: Penalty for bends (default: 2.0)
|
|
26
|
+
class Libavoid < BaseAlgorithm
|
|
27
|
+
# Priority queue node for A* algorithm
|
|
28
|
+
class PathNode
|
|
29
|
+
attr_accessor :point, :parent, :g_score, :f_score, :direction
|
|
30
|
+
|
|
31
|
+
def initialize(point, parent = nil, g_score = Float::INFINITY,
|
|
32
|
+
f_score = Float::INFINITY, direction = nil)
|
|
33
|
+
@point = point
|
|
34
|
+
@parent = parent
|
|
35
|
+
@g_score = g_score
|
|
36
|
+
@f_score = f_score
|
|
37
|
+
@direction = direction # :horizontal or :vertical
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ==(other)
|
|
41
|
+
other.is_a?(PathNode) && point.x == other.point.x && point.y == other.point.y
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def hash
|
|
45
|
+
[point.x, point.y].hash
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def eql?(other)
|
|
49
|
+
self == other
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def layout_flat(graph, _options = {})
|
|
54
|
+
return graph if graph.children.nil? || graph.children.empty?
|
|
55
|
+
|
|
56
|
+
# Position nodes if not already positioned
|
|
57
|
+
position_nodes_if_needed(graph)
|
|
58
|
+
|
|
59
|
+
# Build obstacle map from nodes
|
|
60
|
+
obstacles = build_obstacle_map(graph.children)
|
|
61
|
+
|
|
62
|
+
# Route each edge
|
|
63
|
+
route_edges_with_obstacles(graph, obstacles)
|
|
64
|
+
|
|
65
|
+
# Apply padding and set graph dimensions
|
|
66
|
+
apply_padding(graph)
|
|
67
|
+
|
|
68
|
+
graph
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Position nodes if they don't have positions
|
|
74
|
+
def position_nodes_if_needed(graph)
|
|
75
|
+
return if graph.children.all? { |n| n.x && n.y }
|
|
76
|
+
|
|
77
|
+
# Use simple box layout for positioning
|
|
78
|
+
spacing = node_spacing
|
|
79
|
+
max_width = graph.children.map(&:width).max
|
|
80
|
+
max_height = graph.children.map(&:height).max
|
|
81
|
+
|
|
82
|
+
cols = Math.sqrt(graph.children.length * 1.6).ceil
|
|
83
|
+
cols = [cols, 1].max
|
|
84
|
+
|
|
85
|
+
graph.children.each_with_index do |node, i|
|
|
86
|
+
row = i / cols
|
|
87
|
+
col = i % cols
|
|
88
|
+
node.x = col * (max_width + spacing)
|
|
89
|
+
node.y = row * (max_height + spacing)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Build obstacle map from nodes
|
|
94
|
+
def build_obstacle_map(nodes)
|
|
95
|
+
padding = option("libavoid.routingPadding", 10).to_f
|
|
96
|
+
|
|
97
|
+
nodes.map do |node|
|
|
98
|
+
Geometry::Rectangle.new(
|
|
99
|
+
(node.x || 0) - padding,
|
|
100
|
+
(node.y || 0) - padding,
|
|
101
|
+
(node.width || 0) + (2 * padding),
|
|
102
|
+
(node.height || 0) + (2 * padding),
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Route all edges with obstacle avoidance
|
|
108
|
+
def route_edges_with_obstacles(graph, obstacles)
|
|
109
|
+
return unless graph.edges&.any?
|
|
110
|
+
|
|
111
|
+
node_map = build_node_map(graph)
|
|
112
|
+
|
|
113
|
+
graph.edges.each do |edge|
|
|
114
|
+
route_single_edge(edge, node_map, obstacles)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Route a single edge around obstacles
|
|
119
|
+
def route_single_edge(edge, node_map, obstacles)
|
|
120
|
+
return unless edge.sources&.any? && edge.targets&.any?
|
|
121
|
+
|
|
122
|
+
source_id = edge.sources.first
|
|
123
|
+
target_id = edge.targets.first
|
|
124
|
+
|
|
125
|
+
source_node = node_map[source_id]
|
|
126
|
+
target_node = node_map[target_id]
|
|
127
|
+
|
|
128
|
+
return unless source_node && target_node
|
|
129
|
+
|
|
130
|
+
# Get start and end points (node centers)
|
|
131
|
+
start_point = get_node_center(source_node)
|
|
132
|
+
end_point = get_node_center(target_node)
|
|
133
|
+
|
|
134
|
+
# Find path using A* algorithm
|
|
135
|
+
path = find_path(start_point, end_point, obstacles)
|
|
136
|
+
|
|
137
|
+
# Create orthogonal segments from path
|
|
138
|
+
bend_points = create_orthogonal_segments(path)
|
|
139
|
+
|
|
140
|
+
# Minimize bends
|
|
141
|
+
bend_points = minimize_bends(bend_points, start_point, end_point,
|
|
142
|
+
obstacles)
|
|
143
|
+
|
|
144
|
+
# Apply to edge section
|
|
145
|
+
edge.sections ||= []
|
|
146
|
+
if edge.sections.empty?
|
|
147
|
+
edge.sections << Graph::EdgeSection.new(id: "#{edge.id}_section_0")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
section = edge.sections.first
|
|
151
|
+
section.start_point = start_point
|
|
152
|
+
section.end_point = end_point
|
|
153
|
+
section.bend_points = bend_points
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# A* pathfinding algorithm
|
|
157
|
+
def find_path(start, goal, obstacles)
|
|
158
|
+
segment_penalty = option("libavoid.segmentPenalty", 1.0).to_f
|
|
159
|
+
bend_penalty = option("libavoid.bendPenalty", 2.0).to_f
|
|
160
|
+
|
|
161
|
+
start_node = PathNode.new(start, nil, 0, heuristic(start, goal))
|
|
162
|
+
open_set = [start_node]
|
|
163
|
+
closed_set = {}
|
|
164
|
+
g_scores = { point_key(start) => 0 }
|
|
165
|
+
|
|
166
|
+
while open_set.any?
|
|
167
|
+
# Get node with lowest f_score
|
|
168
|
+
current = open_set.min_by(&:f_score)
|
|
169
|
+
|
|
170
|
+
# Goal reached
|
|
171
|
+
if points_equal?(current.point, goal)
|
|
172
|
+
return reconstruct_path(current)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
open_set.delete(current)
|
|
176
|
+
closed_set[point_key(current.point)] = true
|
|
177
|
+
|
|
178
|
+
# Explore neighbors (orthogonal directions)
|
|
179
|
+
neighbors = get_orthogonal_neighbors(current.point, goal, obstacles)
|
|
180
|
+
|
|
181
|
+
neighbors.each do |neighbor_point, direction|
|
|
182
|
+
key = point_key(neighbor_point)
|
|
183
|
+
next if closed_set[key]
|
|
184
|
+
|
|
185
|
+
# Calculate cost with penalties for segments and direction changes
|
|
186
|
+
distance = euclidean_distance(current.point, neighbor_point)
|
|
187
|
+
direction_change_penalty = current.direction && current.direction != direction ? bend_penalty : 0
|
|
188
|
+
tentative_g = current.g_score + distance + segment_penalty + direction_change_penalty
|
|
189
|
+
|
|
190
|
+
if !g_scores[key] || tentative_g < g_scores[key]
|
|
191
|
+
g_scores[key] = tentative_g
|
|
192
|
+
f_score = tentative_g + heuristic(neighbor_point, goal)
|
|
193
|
+
|
|
194
|
+
neighbor_node = PathNode.new(neighbor_point, current,
|
|
195
|
+
tentative_g, f_score, direction)
|
|
196
|
+
|
|
197
|
+
# Add or update in open set
|
|
198
|
+
existing = open_set.find do |n|
|
|
199
|
+
points_equal?(n.point, neighbor_point)
|
|
200
|
+
end
|
|
201
|
+
if existing
|
|
202
|
+
open_set.delete(existing)
|
|
203
|
+
end
|
|
204
|
+
open_set << neighbor_node
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# No path found, return direct path
|
|
210
|
+
[start, goal]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Get orthogonal neighbors (4-directional)
|
|
214
|
+
def get_orthogonal_neighbors(point, _goal, obstacles)
|
|
215
|
+
step_size = option("libavoid.routingPadding", 10).to_f
|
|
216
|
+
neighbors = []
|
|
217
|
+
|
|
218
|
+
# Four orthogonal directions
|
|
219
|
+
[
|
|
220
|
+
[step_size, 0, :horizontal], # right
|
|
221
|
+
[-step_size, 0, :horizontal], # left
|
|
222
|
+
[0, step_size, :vertical], # down
|
|
223
|
+
[0, -step_size, :vertical], # up
|
|
224
|
+
].each do |dx, dy, direction|
|
|
225
|
+
neighbor = Geometry::Point.new(x: point.x + dx, y: point.y + dy)
|
|
226
|
+
|
|
227
|
+
# Skip if it collides with obstacles
|
|
228
|
+
unless collides_with_obstacles?(point, neighbor, obstacles)
|
|
229
|
+
neighbors << [neighbor, direction]
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
neighbors
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Check if line segment collides with obstacles
|
|
237
|
+
def collides_with_obstacles?(p1, p2, obstacles)
|
|
238
|
+
obstacles.any? do |obstacle|
|
|
239
|
+
line_intersects_rectangle?(p1, p2, obstacle)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Check if line segment intersects rectangle
|
|
244
|
+
def line_intersects_rectangle?(p1, p2, rect)
|
|
245
|
+
# Check if either endpoint is inside rectangle
|
|
246
|
+
return true if point_in_rectangle?(p1,
|
|
247
|
+
rect) || point_in_rectangle?(p2,
|
|
248
|
+
rect)
|
|
249
|
+
|
|
250
|
+
# Check if line intersects any edge of rectangle
|
|
251
|
+
rect_edges = [
|
|
252
|
+
[Geometry::Point.new(x: rect.x, y: rect.y),
|
|
253
|
+
Geometry::Point.new(x: rect.x + rect.width, y: rect.y)],
|
|
254
|
+
[Geometry::Point.new(x: rect.x + rect.width, y: rect.y),
|
|
255
|
+
Geometry::Point.new(x: rect.x + rect.width,
|
|
256
|
+
y: rect.y + rect.height)],
|
|
257
|
+
[Geometry::Point.new(x: rect.x + rect.width, y: rect.y + rect.height),
|
|
258
|
+
Geometry::Point.new(x: rect.x, y: rect.y + rect.height)],
|
|
259
|
+
[Geometry::Point.new(x: rect.x, y: rect.y + rect.height),
|
|
260
|
+
Geometry::Point.new(x: rect.x, y: rect.y)],
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
rect_edges.any? do |edge_p1, edge_p2|
|
|
264
|
+
segments_intersect?(p1, p2, edge_p1, edge_p2)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Check if point is inside rectangle
|
|
269
|
+
def point_in_rectangle?(point, rect)
|
|
270
|
+
point.x.between?(rect.x, rect.x + rect.width) &&
|
|
271
|
+
point.y >= rect.y &&
|
|
272
|
+
point.y <= rect.y + rect.height
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Check if two line segments intersect
|
|
276
|
+
def segments_intersect?(p1, p2, p3, p4)
|
|
277
|
+
d1 = direction(p3, p4, p1)
|
|
278
|
+
d2 = direction(p3, p4, p2)
|
|
279
|
+
d3 = direction(p1, p2, p3)
|
|
280
|
+
d4 = direction(p1, p2, p4)
|
|
281
|
+
|
|
282
|
+
((d1.positive? && d2.negative?) || (d1.negative? && d2.positive?)) &&
|
|
283
|
+
((d3.positive? && d4.negative?) || (d3.negative? && d4.positive?))
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Calculate direction for line segment intersection
|
|
287
|
+
def direction(p1, p2, p3)
|
|
288
|
+
((p3.x - p1.x) * (p2.y - p1.y)) - ((p2.x - p1.x) * (p3.y - p1.y))
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Reconstruct path from A* result
|
|
292
|
+
def reconstruct_path(node)
|
|
293
|
+
path = []
|
|
294
|
+
current = node
|
|
295
|
+
|
|
296
|
+
while current
|
|
297
|
+
path.unshift(current.point)
|
|
298
|
+
current = current.parent
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
path
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Manhattan distance heuristic for A*
|
|
305
|
+
def heuristic(point, goal)
|
|
306
|
+
(point.x - goal.x).abs + (point.y - goal.y).abs
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Euclidean distance
|
|
310
|
+
def euclidean_distance(p1, p2)
|
|
311
|
+
dx = p2.x - p1.x
|
|
312
|
+
dy = p2.y - p1.y
|
|
313
|
+
Math.sqrt((dx * dx) + (dy * dy))
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Create orthogonal segments from path
|
|
317
|
+
def create_orthogonal_segments(path)
|
|
318
|
+
return [] if path.length < 3
|
|
319
|
+
|
|
320
|
+
# Path already contains waypoints from A*, convert to bend points
|
|
321
|
+
# (excluding start and end points)
|
|
322
|
+
path[1..-2].map do |point|
|
|
323
|
+
Geometry::Point.new(x: point.x, y: point.y)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Minimize bends in path
|
|
328
|
+
def minimize_bends(bend_points, start_point, end_point, obstacles)
|
|
329
|
+
return bend_points if bend_points.empty?
|
|
330
|
+
|
|
331
|
+
# Try to remove unnecessary bend points
|
|
332
|
+
all_points = [start_point] + bend_points + [end_point]
|
|
333
|
+
simplified = [all_points.first]
|
|
334
|
+
|
|
335
|
+
i = 0
|
|
336
|
+
while i < all_points.length - 1
|
|
337
|
+
j = all_points.length - 1
|
|
338
|
+
|
|
339
|
+
# Try to connect point i to furthest visible point
|
|
340
|
+
while j > i + 1
|
|
341
|
+
unless collides_with_obstacles?(all_points[i], all_points[j],
|
|
342
|
+
obstacles)
|
|
343
|
+
simplified << all_points[j]
|
|
344
|
+
i = j
|
|
345
|
+
break
|
|
346
|
+
end
|
|
347
|
+
j -= 1
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# If no direct path found, use next point
|
|
351
|
+
if j == i + 1
|
|
352
|
+
simplified << all_points[i + 1]
|
|
353
|
+
i += 1
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Remove start and end points from result
|
|
358
|
+
simplified[1..-2] || []
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Helper: create unique key for point
|
|
362
|
+
def point_key(point)
|
|
363
|
+
"#{point.x.round(2)},#{point.y.round(2)}"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Helper: check if two points are equal (within tolerance)
|
|
367
|
+
def points_equal?(p1, p2, tolerance = 0.1)
|
|
368
|
+
(p1.x - p2.x).abs < tolerance && (p1.y - p2.y).abs < tolerance
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Build node map from graph
|
|
372
|
+
def build_node_map(graph)
|
|
373
|
+
map = {}
|
|
374
|
+
graph.children&.each do |node|
|
|
375
|
+
map[node.id] = node
|
|
376
|
+
end
|
|
377
|
+
map
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Get center point of a node
|
|
381
|
+
def get_node_center(node)
|
|
382
|
+
x = (node.x || 0.0) + ((node.width || 0.0) / 2.0)
|
|
383
|
+
y = (node.y || 0.0) + ((node.height || 0.0) / 2.0)
|
|
384
|
+
Geometry::Point.new(x: x, y: y)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require_relative "base_algorithm"
|
|
5
|
+
|
|
6
|
+
module Elkrb
|
|
7
|
+
module Layout
|
|
8
|
+
module Algorithms
|
|
9
|
+
# MRTree (Multi-Rooted Tree) layout algorithm
|
|
10
|
+
#
|
|
11
|
+
# Arranges nodes in a tree structure that can handle multiple root nodes.
|
|
12
|
+
class MRTree < BaseAlgorithm
|
|
13
|
+
def layout_flat(graph, _options = {})
|
|
14
|
+
return graph if graph.children.empty?
|
|
15
|
+
|
|
16
|
+
# Identify root nodes (nodes with no incoming edges)
|
|
17
|
+
roots = find_root_nodes(graph)
|
|
18
|
+
|
|
19
|
+
# If no roots found, treat all nodes as roots
|
|
20
|
+
roots = graph.children if roots.empty?
|
|
21
|
+
|
|
22
|
+
# Build tree structure from roots
|
|
23
|
+
trees = roots.map { |root| build_tree(root, graph) }
|
|
24
|
+
|
|
25
|
+
# Calculate positions for each tree
|
|
26
|
+
spacing_val = node_spacing
|
|
27
|
+
x_offset = 0
|
|
28
|
+
trees.each do |tree|
|
|
29
|
+
layout_tree(tree, x_offset, 0)
|
|
30
|
+
tree_width = calculate_tree_width(tree)
|
|
31
|
+
x_offset += tree_width + spacing_val
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Apply padding and set graph dimensions
|
|
35
|
+
apply_padding(graph)
|
|
36
|
+
|
|
37
|
+
graph
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def find_root_nodes(graph)
|
|
43
|
+
nodes_with_incoming = Set.new
|
|
44
|
+
|
|
45
|
+
graph.edges&.each do |edge|
|
|
46
|
+
targets = edge.targets || []
|
|
47
|
+
targets.each do |target_id|
|
|
48
|
+
nodes_with_incoming.add(target_id)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
graph.children.reject { |node| nodes_with_incoming.include?(node.id) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_tree(root, graph)
|
|
56
|
+
tree = {
|
|
57
|
+
node: root,
|
|
58
|
+
children: [],
|
|
59
|
+
level: 0,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Find children (nodes connected by outgoing edges)
|
|
63
|
+
children = find_children(root, graph)
|
|
64
|
+
tree[:children] = children.map do |child|
|
|
65
|
+
build_subtree(child, graph, 1)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
tree
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_subtree(node, graph, level)
|
|
72
|
+
tree = {
|
|
73
|
+
node: node,
|
|
74
|
+
children: [],
|
|
75
|
+
level: level,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
children = find_children(node, graph)
|
|
79
|
+
tree[:children] = children.map do |child|
|
|
80
|
+
build_subtree(child, graph, level + 1)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
tree
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def find_children(node, graph)
|
|
87
|
+
children = []
|
|
88
|
+
edges = graph.edges || []
|
|
89
|
+
|
|
90
|
+
edges.each do |edge|
|
|
91
|
+
sources = edge.sources || []
|
|
92
|
+
targets = edge.targets || []
|
|
93
|
+
|
|
94
|
+
next unless sources.include?(node.id)
|
|
95
|
+
|
|
96
|
+
targets.each do |target_id|
|
|
97
|
+
child = graph.children.find { |n| n.id == target_id }
|
|
98
|
+
children << child if child
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
children
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def layout_tree(tree, x_offset, y_offset)
|
|
106
|
+
node = tree[:node]
|
|
107
|
+
spacing_val = node_spacing
|
|
108
|
+
level_height = 80.0
|
|
109
|
+
|
|
110
|
+
if tree[:children].empty?
|
|
111
|
+
# Leaf node
|
|
112
|
+
node.x = x_offset
|
|
113
|
+
node.y = y_offset + (tree[:level] * level_height)
|
|
114
|
+
return node.width + spacing_val
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Layout children first
|
|
118
|
+
child_x = x_offset
|
|
119
|
+
tree[:children].each do |child_tree|
|
|
120
|
+
width = layout_tree(child_tree, child_x, y_offset)
|
|
121
|
+
child_x += width
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Position this node centered above children
|
|
125
|
+
first_child = tree[:children].first[:node]
|
|
126
|
+
last_child = tree[:children].last[:node]
|
|
127
|
+
|
|
128
|
+
center_x = (first_child.x + last_child.x + last_child.width) / 2.0
|
|
129
|
+
node.x = center_x - (node.width / 2.0)
|
|
130
|
+
node.y = y_offset + (tree[:level] * level_height)
|
|
131
|
+
|
|
132
|
+
# Return total width
|
|
133
|
+
last_child.x + last_child.width - x_offset + spacing_val
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def calculate_tree_width(tree)
|
|
137
|
+
return tree[:node].width if tree[:children].empty?
|
|
138
|
+
|
|
139
|
+
tree[:children].sum { |child| calculate_tree_width(child) }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_algorithm"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Layout
|
|
7
|
+
module Algorithms
|
|
8
|
+
# Radial layout algorithm
|
|
9
|
+
#
|
|
10
|
+
# Arranges nodes in a circular/radial pattern around a center point.
|
|
11
|
+
class Radial < BaseAlgorithm
|
|
12
|
+
def layout_flat(graph, _options = {})
|
|
13
|
+
return graph if graph.children.empty?
|
|
14
|
+
|
|
15
|
+
if graph.children.size == 1
|
|
16
|
+
# Single node - place at origin
|
|
17
|
+
graph.children.first.x = 0.0
|
|
18
|
+
graph.children.first.y = 0.0
|
|
19
|
+
apply_padding(graph)
|
|
20
|
+
return graph
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Calculate radius based on node count and sizes
|
|
24
|
+
radius = calculate_radius(graph.children)
|
|
25
|
+
|
|
26
|
+
# Calculate center point
|
|
27
|
+
center_x = radius
|
|
28
|
+
center_y = radius
|
|
29
|
+
|
|
30
|
+
# Arrange nodes in a circle
|
|
31
|
+
angle_step = (2 * Math::PI) / graph.children.size
|
|
32
|
+
|
|
33
|
+
graph.children.each_with_index do |node, index|
|
|
34
|
+
angle = index * angle_step
|
|
35
|
+
|
|
36
|
+
# Calculate position on circle
|
|
37
|
+
# Adjust for node size to center the node
|
|
38
|
+
node.x = center_x + (radius * Math.cos(angle)) - (node.width / 2.0)
|
|
39
|
+
node.y = center_y + (radius * Math.sin(angle)) - (node.height / 2.0)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
apply_padding(graph)
|
|
43
|
+
|
|
44
|
+
graph
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def calculate_radius(nodes)
|
|
50
|
+
# Base radius on number of nodes and their average size
|
|
51
|
+
avg_width = nodes.sum(&:width) / nodes.size.to_f
|
|
52
|
+
avg_height = nodes.sum(&:height) / nodes.size.to_f
|
|
53
|
+
avg_size = (avg_width + avg_height) / 2.0
|
|
54
|
+
|
|
55
|
+
# Ensure enough space for all nodes
|
|
56
|
+
min_radius = (nodes.size * avg_size) / (2 * Math::PI)
|
|
57
|
+
|
|
58
|
+
# Add some extra spacing
|
|
59
|
+
[min_radius * 1.2, 100.0].max
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_algorithm"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Layout
|
|
7
|
+
module Algorithms
|
|
8
|
+
# Random layout algorithm
|
|
9
|
+
#
|
|
10
|
+
# Places nodes at random positions within a bounded area.
|
|
11
|
+
# Useful for initial layouts or testing.
|
|
12
|
+
class Random < BaseAlgorithm
|
|
13
|
+
def layout_flat(graph, _options = {})
|
|
14
|
+
return graph if graph.children.nil? || graph.children.empty?
|
|
15
|
+
|
|
16
|
+
# Get configuration
|
|
17
|
+
aspect_ratio = option("aspect_ratio", 1.6).to_f
|
|
18
|
+
spacing = node_spacing
|
|
19
|
+
|
|
20
|
+
# Calculate total area needed
|
|
21
|
+
total_area = graph.children.sum do |node|
|
|
22
|
+
(node.width + spacing) * (node.height + spacing)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Calculate bounds
|
|
26
|
+
width = Math.sqrt(total_area * aspect_ratio)
|
|
27
|
+
height = width / aspect_ratio
|
|
28
|
+
|
|
29
|
+
# Position nodes randomly
|
|
30
|
+
graph.children.each do |node|
|
|
31
|
+
node.x = rand * (width - node.width)
|
|
32
|
+
node.y = rand * (height - node.height)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Apply padding and set graph dimensions
|
|
36
|
+
apply_padding(graph)
|
|
37
|
+
|
|
38
|
+
graph
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|