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,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
module Algorithms
|
|
6
|
+
# DISCO (Disconnected Graph Layout) algorithm
|
|
7
|
+
#
|
|
8
|
+
# Handles graphs with disconnected components by:
|
|
9
|
+
# 1. Identifying connected components
|
|
10
|
+
# 2. Laying out each component independently
|
|
11
|
+
# 3. Arranging components in a grid or row
|
|
12
|
+
class Disco < BaseAlgorithm
|
|
13
|
+
def layout_flat(graph, _options = {})
|
|
14
|
+
return graph if graph.children.empty?
|
|
15
|
+
|
|
16
|
+
# Find connected components
|
|
17
|
+
components = find_connected_components(graph)
|
|
18
|
+
|
|
19
|
+
# Layout each component independently
|
|
20
|
+
component_algo = graph.layout_options&.[]("disco.componentAlgorithm") || "layered"
|
|
21
|
+
components.each do |component|
|
|
22
|
+
layout_component(component, component_algo)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Arrange components
|
|
26
|
+
spacing = graph.layout_options&.[]("disco.componentSpacing") || 20.0
|
|
27
|
+
arrange_components(components, graph, spacing)
|
|
28
|
+
|
|
29
|
+
# Apply padding
|
|
30
|
+
apply_padding(graph)
|
|
31
|
+
|
|
32
|
+
graph
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def find_connected_components(graph)
|
|
38
|
+
visited = Set.new
|
|
39
|
+
components = []
|
|
40
|
+
|
|
41
|
+
graph.children.each do |node|
|
|
42
|
+
next if visited.include?(node)
|
|
43
|
+
|
|
44
|
+
component = {
|
|
45
|
+
nodes: [],
|
|
46
|
+
edges: [],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# BFS to find all connected nodes
|
|
50
|
+
queue = [node]
|
|
51
|
+
while queue.any?
|
|
52
|
+
current = queue.shift
|
|
53
|
+
next if visited.include?(current)
|
|
54
|
+
|
|
55
|
+
visited.add(current)
|
|
56
|
+
component[:nodes] << current
|
|
57
|
+
|
|
58
|
+
# Find connected nodes through edges
|
|
59
|
+
connected_edges = graph.edges.select do |edge|
|
|
60
|
+
edge_nodes = []
|
|
61
|
+
edge.sources&.each do |port|
|
|
62
|
+
edge_nodes << (port.respond_to?(:node) ? port.node : port)
|
|
63
|
+
end
|
|
64
|
+
edge.targets&.each do |port|
|
|
65
|
+
edge_nodes << (port.respond_to?(:node) ? port.node : port)
|
|
66
|
+
end
|
|
67
|
+
edge_nodes.include?(current)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
component[:edges].concat(connected_edges)
|
|
71
|
+
|
|
72
|
+
connected_edges.each do |edge|
|
|
73
|
+
nodes = []
|
|
74
|
+
edge.sources&.each do |port|
|
|
75
|
+
nodes << (port.respond_to?(:node) ? port.node : port)
|
|
76
|
+
end
|
|
77
|
+
edge.targets&.each do |port|
|
|
78
|
+
nodes << (port.respond_to?(:node) ? port.node : port)
|
|
79
|
+
end
|
|
80
|
+
nodes.each { |n| queue << n unless visited.include?(n) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
components << component
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
components
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def layout_component(component, algorithm_name)
|
|
91
|
+
return if component[:nodes].empty?
|
|
92
|
+
|
|
93
|
+
# Create a temporary graph for this component
|
|
94
|
+
temp_graph = Graph::Graph.new
|
|
95
|
+
temp_graph.children = component[:nodes]
|
|
96
|
+
temp_graph.edges = component[:edges]
|
|
97
|
+
|
|
98
|
+
# Get algorithm from registry
|
|
99
|
+
algorithm_class = Layout::AlgorithmRegistry.get(algorithm_name)
|
|
100
|
+
return unless algorithm_class
|
|
101
|
+
|
|
102
|
+
# Apply layout algorithm to component
|
|
103
|
+
algorithm = algorithm_class.new
|
|
104
|
+
algorithm.layout(temp_graph)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def arrange_components(components, graph, spacing)
|
|
108
|
+
return if components.empty?
|
|
109
|
+
|
|
110
|
+
arrangement = graph.layout_options&.[]("disco.componentArrangement") || "row"
|
|
111
|
+
|
|
112
|
+
case arrangement
|
|
113
|
+
when "grid"
|
|
114
|
+
arrange_in_grid(components, spacing)
|
|
115
|
+
when "column"
|
|
116
|
+
arrange_in_column(components, spacing)
|
|
117
|
+
else
|
|
118
|
+
arrange_in_row(components, spacing)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def arrange_in_row(components, spacing)
|
|
123
|
+
x_offset = 0.0
|
|
124
|
+
|
|
125
|
+
components.each do |component|
|
|
126
|
+
# Calculate component bounds
|
|
127
|
+
min_x = component[:nodes].map(&:x).min || 0.0
|
|
128
|
+
max_x = component[:nodes].map { |n| n.x + n.width }.max || 0.0
|
|
129
|
+
component_width = max_x - min_x
|
|
130
|
+
|
|
131
|
+
# Offset all nodes in component
|
|
132
|
+
offset_x = x_offset - min_x
|
|
133
|
+
component[:nodes].each do |node|
|
|
134
|
+
node.x += offset_x
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
x_offset += component_width + spacing
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def arrange_in_column(components, spacing)
|
|
142
|
+
y_offset = 0.0
|
|
143
|
+
|
|
144
|
+
components.each do |component|
|
|
145
|
+
# Calculate component bounds
|
|
146
|
+
min_y = component[:nodes].map(&:y).min || 0.0
|
|
147
|
+
max_y = component[:nodes].map { |n| n.y + n.height }.max || 0.0
|
|
148
|
+
component_height = max_y - min_y
|
|
149
|
+
|
|
150
|
+
# Offset all nodes in component
|
|
151
|
+
offset_y = y_offset - min_y
|
|
152
|
+
component[:nodes].each do |node|
|
|
153
|
+
node.y += offset_y
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
y_offset += component_height + spacing
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def arrange_in_grid(components, spacing)
|
|
161
|
+
return if components.empty?
|
|
162
|
+
|
|
163
|
+
# Calculate grid dimensions
|
|
164
|
+
cols = Math.sqrt(components.size).ceil
|
|
165
|
+
rows = (components.size.to_f / cols).ceil
|
|
166
|
+
|
|
167
|
+
y_offset = 0.0
|
|
168
|
+
row_heights = []
|
|
169
|
+
|
|
170
|
+
rows.times do |row|
|
|
171
|
+
x_offset = 0.0
|
|
172
|
+
max_row_height = 0.0
|
|
173
|
+
|
|
174
|
+
cols.times do |col|
|
|
175
|
+
index = (row * cols) + col
|
|
176
|
+
break if index >= components.size
|
|
177
|
+
|
|
178
|
+
component = components[index]
|
|
179
|
+
|
|
180
|
+
# Calculate component bounds
|
|
181
|
+
min_x = component[:nodes].map(&:x).min || 0.0
|
|
182
|
+
min_y = component[:nodes].map(&:y).min || 0.0
|
|
183
|
+
max_x = component[:nodes].map { |n| n.x + n.width }.max || 0.0
|
|
184
|
+
max_y = component[:nodes].map { |n| n.y + n.height }.max || 0.0
|
|
185
|
+
|
|
186
|
+
component_width = max_x - min_x
|
|
187
|
+
component_height = max_y - min_y
|
|
188
|
+
|
|
189
|
+
# Offset nodes
|
|
190
|
+
component[:nodes].each do |node|
|
|
191
|
+
node.x += x_offset - min_x
|
|
192
|
+
node.y += y_offset - min_y
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
x_offset += component_width + spacing
|
|
196
|
+
max_row_height = [max_row_height, component_height].max
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
row_heights << max_row_height
|
|
200
|
+
y_offset += max_row_height + spacing
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_algorithm"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Layout
|
|
7
|
+
module Algorithms
|
|
8
|
+
# Fixed layout algorithm
|
|
9
|
+
#
|
|
10
|
+
# Keeps nodes at their current positions. Only applies padding
|
|
11
|
+
# and calculates graph dimensions based on existing node positions.
|
|
12
|
+
# Useful when node positions are pre-determined or manually set.
|
|
13
|
+
class Fixed < BaseAlgorithm
|
|
14
|
+
def layout_flat(graph, _options = {})
|
|
15
|
+
return graph if graph.children.nil? || graph.children.empty?
|
|
16
|
+
|
|
17
|
+
# Ensure all nodes have positions
|
|
18
|
+
graph.children.each do |node|
|
|
19
|
+
node.x ||= 0.0
|
|
20
|
+
node.y ||= 0.0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Simply apply padding and calculate dimensions
|
|
24
|
+
# Nodes keep their existing x, y positions
|
|
25
|
+
apply_padding(graph)
|
|
26
|
+
|
|
27
|
+
graph
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_algorithm"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Layout
|
|
7
|
+
module Algorithms
|
|
8
|
+
# Force-directed layout algorithm
|
|
9
|
+
#
|
|
10
|
+
# Creates organic, symmetric layouts using force simulation.
|
|
11
|
+
# Nodes repel each other while edges act as springs pulling
|
|
12
|
+
# connected nodes together.
|
|
13
|
+
#
|
|
14
|
+
# Ideal for:
|
|
15
|
+
# - Network diagrams
|
|
16
|
+
# - Social graphs
|
|
17
|
+
# - Mind maps
|
|
18
|
+
# - General undirected graphs
|
|
19
|
+
class Force < BaseAlgorithm
|
|
20
|
+
DEFAULT_ITERATIONS = 300
|
|
21
|
+
DEFAULT_REPULSION = 5.0
|
|
22
|
+
DEFAULT_TEMPERATURE = 0.001
|
|
23
|
+
|
|
24
|
+
def layout_flat(graph, _options = {})
|
|
25
|
+
return graph if graph.children.nil? || graph.children.empty?
|
|
26
|
+
|
|
27
|
+
# Get configuration
|
|
28
|
+
iterations = option("iterations", DEFAULT_ITERATIONS).to_i
|
|
29
|
+
repulsion = option("repulsion", DEFAULT_REPULSION).to_f
|
|
30
|
+
temperature = option("temperature", DEFAULT_TEMPERATURE).to_f
|
|
31
|
+
|
|
32
|
+
# Initialize positions randomly if not set
|
|
33
|
+
initialize_positions(graph)
|
|
34
|
+
|
|
35
|
+
# Run force simulation
|
|
36
|
+
iterations.times do |i|
|
|
37
|
+
apply_forces(graph, repulsion, temperature, i, iterations)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Apply padding and set graph dimensions
|
|
41
|
+
apply_padding(graph)
|
|
42
|
+
|
|
43
|
+
graph
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def initialize_positions(graph)
|
|
49
|
+
# Calculate approximate area needed
|
|
50
|
+
total_area = graph.children.sum do |n|
|
|
51
|
+
(n.width + 20) * (n.height + 20)
|
|
52
|
+
end
|
|
53
|
+
side = Math.sqrt(total_area)
|
|
54
|
+
|
|
55
|
+
graph.children.each do |node|
|
|
56
|
+
# Set random position if not already set
|
|
57
|
+
unless node.x && node.y
|
|
58
|
+
node.x = rand * side
|
|
59
|
+
node.y = rand * side
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def apply_forces(graph, repulsion, temperature, iteration,
|
|
65
|
+
max_iterations)
|
|
66
|
+
# Calculate temperature decay
|
|
67
|
+
temp = temperature * (1.0 - (iteration.to_f / max_iterations))
|
|
68
|
+
|
|
69
|
+
# Calculate forces for each node
|
|
70
|
+
forces = calculate_forces(graph, repulsion)
|
|
71
|
+
|
|
72
|
+
# Apply forces with temperature
|
|
73
|
+
graph.children.each_with_index do |node, i|
|
|
74
|
+
force = forces[i]
|
|
75
|
+
magnitude = Math.sqrt((force[:x]**2) + (force[:y]**2))
|
|
76
|
+
|
|
77
|
+
next if magnitude.zero?
|
|
78
|
+
|
|
79
|
+
# Apply displacement with temperature
|
|
80
|
+
displacement = [magnitude, temp].min
|
|
81
|
+
node.x += (force[:x] / magnitude) * displacement
|
|
82
|
+
node.y += (force[:y] / magnitude) * displacement
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def calculate_forces(graph, repulsion)
|
|
87
|
+
forces = graph.children.map { { x: 0.0, y: 0.0 } }
|
|
88
|
+
|
|
89
|
+
# Repulsive forces between all pairs
|
|
90
|
+
graph.children.each_with_index do |node1, i|
|
|
91
|
+
graph.children.each_with_index do |node2, j|
|
|
92
|
+
next if i >= j
|
|
93
|
+
|
|
94
|
+
apply_repulsive_force(node1, node2, forces[i], forces[j],
|
|
95
|
+
repulsion)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Attractive forces for edges
|
|
100
|
+
all_edges = collect_all_edges(graph)
|
|
101
|
+
all_edges.each do |edge|
|
|
102
|
+
source_id = edge.sources&.first
|
|
103
|
+
target_id = edge.targets&.first
|
|
104
|
+
|
|
105
|
+
next unless source_id && target_id
|
|
106
|
+
|
|
107
|
+
source_idx = graph.children.index { |n| n.id == source_id }
|
|
108
|
+
target_idx = graph.children.index { |n| n.id == target_id }
|
|
109
|
+
|
|
110
|
+
next unless source_idx && target_idx
|
|
111
|
+
|
|
112
|
+
apply_attractive_force(
|
|
113
|
+
graph.children[source_idx],
|
|
114
|
+
graph.children[target_idx],
|
|
115
|
+
forces[source_idx],
|
|
116
|
+
forces[target_idx],
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
forces
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def apply_repulsive_force(node1, node2, force1, force2, repulsion)
|
|
124
|
+
dx = node2.x - node1.x
|
|
125
|
+
dy = node2.y - node1.y
|
|
126
|
+
distance_sq = (dx**2) + (dy**2)
|
|
127
|
+
|
|
128
|
+
# Avoid division by zero
|
|
129
|
+
return if distance_sq < 0.01
|
|
130
|
+
|
|
131
|
+
# Repulsive force inversely proportional to distance
|
|
132
|
+
force = repulsion / distance_sq
|
|
133
|
+
force1[:x] -= (dx / Math.sqrt(distance_sq)) * force
|
|
134
|
+
force1[:y] -= (dy / Math.sqrt(distance_sq)) * force
|
|
135
|
+
force2[:x] += (dx / Math.sqrt(distance_sq)) * force
|
|
136
|
+
force2[:y] += (dy / Math.sqrt(distance_sq)) * force
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def apply_attractive_force(node1, node2, force1, force2)
|
|
140
|
+
dx = node2.x - node1.x
|
|
141
|
+
dy = node2.y - node1.y
|
|
142
|
+
distance = Math.sqrt((dx**2) + (dy**2))
|
|
143
|
+
|
|
144
|
+
return if distance.zero?
|
|
145
|
+
|
|
146
|
+
# Spring force proportional to distance
|
|
147
|
+
force = distance / 10.0
|
|
148
|
+
force1[:x] += (dx / distance) * force
|
|
149
|
+
force1[:y] += (dy / distance) * force
|
|
150
|
+
force2[:x] -= (dx / distance) * force
|
|
151
|
+
force2[:y] -= (dy / distance) * force
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def collect_all_edges(graph)
|
|
155
|
+
edges = []
|
|
156
|
+
edges.concat(graph.edges) if graph.edges
|
|
157
|
+
graph.children&.each do |node|
|
|
158
|
+
edges.concat(node.edges) if node.edges
|
|
159
|
+
end
|
|
160
|
+
edges
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
module Algorithms
|
|
6
|
+
module Layered
|
|
7
|
+
# Breaks cycles in the graph to make it acyclic
|
|
8
|
+
#
|
|
9
|
+
# This is the first phase of the Sugiyama framework.
|
|
10
|
+
# Uses a greedy approach to reverse edges that create cycles.
|
|
11
|
+
class CycleBreaker
|
|
12
|
+
def initialize(graph)
|
|
13
|
+
@graph = graph
|
|
14
|
+
@visited = {}
|
|
15
|
+
@in_stack = {}
|
|
16
|
+
@edges_to_reverse = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def break_cycles
|
|
20
|
+
return unless @graph.children
|
|
21
|
+
|
|
22
|
+
# Find all edges that create cycles using DFS
|
|
23
|
+
@graph.children.each do |node|
|
|
24
|
+
dfs(node) unless @visited[node.id]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Reverse the problematic edges
|
|
28
|
+
reverse_edges
|
|
29
|
+
|
|
30
|
+
@edges_to_reverse
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def dfs(node)
|
|
36
|
+
@visited[node.id] = true
|
|
37
|
+
@in_stack[node.id] = true
|
|
38
|
+
|
|
39
|
+
# Process outgoing edges
|
|
40
|
+
get_outgoing_edges(node).each do |edge|
|
|
41
|
+
target_id = edge.targets.first
|
|
42
|
+
next unless target_id
|
|
43
|
+
|
|
44
|
+
target = @graph.find_node(target_id)
|
|
45
|
+
next unless target
|
|
46
|
+
|
|
47
|
+
if @in_stack[target.id]
|
|
48
|
+
# Found a cycle - mark this edge for reversal
|
|
49
|
+
@edges_to_reverse << edge
|
|
50
|
+
elsif !@visited[target.id]
|
|
51
|
+
dfs(target)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@in_stack[node.id] = false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def get_outgoing_edges(node)
|
|
59
|
+
edges = []
|
|
60
|
+
|
|
61
|
+
# Get edges from the node itself
|
|
62
|
+
edges.concat(node.edges) if node.edges
|
|
63
|
+
|
|
64
|
+
# Get edges from the graph that have this node as source
|
|
65
|
+
@graph.edges&.each do |edge|
|
|
66
|
+
edges << edge if edge.sources&.include?(node.id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
edges
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reverse_edges
|
|
73
|
+
@edges_to_reverse.each do |edge|
|
|
74
|
+
# Swap sources and targets
|
|
75
|
+
edge.sources, edge.targets = edge.targets, edge.sources
|
|
76
|
+
|
|
77
|
+
# Mark as reversed for later processing
|
|
78
|
+
edge.properties ||= {}
|
|
79
|
+
edge.properties["reversed"] = true
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
module Algorithms
|
|
6
|
+
module Layered
|
|
7
|
+
# Assigns nodes to layers in the graph
|
|
8
|
+
#
|
|
9
|
+
# This is the second phase of the Sugiyama framework.
|
|
10
|
+
# Uses longest path layering to create a balanced layout.
|
|
11
|
+
class LayerAssigner
|
|
12
|
+
attr_reader :layers
|
|
13
|
+
|
|
14
|
+
def initialize(graph)
|
|
15
|
+
@graph = graph
|
|
16
|
+
@layers = []
|
|
17
|
+
@node_layers = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def assign_layers
|
|
21
|
+
return [] unless @graph.children
|
|
22
|
+
|
|
23
|
+
# Calculate layer for each node
|
|
24
|
+
@graph.children.each do |node|
|
|
25
|
+
calculate_layer(node)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Group nodes by layer
|
|
29
|
+
max_layer = @node_layers.values.max || 0
|
|
30
|
+
@layers = Array.new(max_layer + 1) { [] }
|
|
31
|
+
|
|
32
|
+
@node_layers.each do |node_id, layer|
|
|
33
|
+
node = @graph.find_node(node_id)
|
|
34
|
+
@layers[layer] << node if node
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@layers
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def get_layer(node_id)
|
|
41
|
+
@node_layers[node_id]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def calculate_layer(node)
|
|
47
|
+
return @node_layers[node.id] if @node_layers.key?(node.id)
|
|
48
|
+
|
|
49
|
+
# Find incoming edges
|
|
50
|
+
incoming = get_incoming_edges(node)
|
|
51
|
+
|
|
52
|
+
if incoming.empty?
|
|
53
|
+
# Root node - assign to layer 0
|
|
54
|
+
@node_layers[node.id] = 0
|
|
55
|
+
else
|
|
56
|
+
# Assign to one layer below the maximum of predecessors
|
|
57
|
+
max_pred_layer = incoming.filter_map do |edge|
|
|
58
|
+
source_id = edge.sources.first
|
|
59
|
+
next 0 unless source_id
|
|
60
|
+
|
|
61
|
+
source = @graph.find_node(source_id)
|
|
62
|
+
next 0 unless source
|
|
63
|
+
|
|
64
|
+
calculate_layer(source)
|
|
65
|
+
end.max || 0
|
|
66
|
+
|
|
67
|
+
@node_layers[node.id] = max_pred_layer + 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
@node_layers[node.id]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def get_incoming_edges(node)
|
|
74
|
+
edges = []
|
|
75
|
+
|
|
76
|
+
# Get all edges that target this node
|
|
77
|
+
@graph.edges&.each do |edge|
|
|
78
|
+
edges << edge if edge.targets&.include?(node.id)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Also check edges from other nodes
|
|
82
|
+
@graph.children&.each do |other_node|
|
|
83
|
+
next unless other_node.edges
|
|
84
|
+
|
|
85
|
+
other_node.edges.each do |edge|
|
|
86
|
+
edges << edge if edge.targets&.include?(node.id)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
edges
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
module Algorithms
|
|
6
|
+
module Layered
|
|
7
|
+
# Places nodes within their assigned layers
|
|
8
|
+
#
|
|
9
|
+
# This phase calculates the x and y coordinates for each node
|
|
10
|
+
# based on their layer assignment and spacing requirements.
|
|
11
|
+
class NodePlacer
|
|
12
|
+
def initialize(graph, layers, options = {})
|
|
13
|
+
@graph = graph
|
|
14
|
+
@layers = layers
|
|
15
|
+
@options = options
|
|
16
|
+
@layer_spacing = options[:layer_spacing] || 60.0
|
|
17
|
+
@node_spacing = options[:spacing_node_node] || 20.0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def place_nodes
|
|
21
|
+
return unless @layers && !@layers.empty?
|
|
22
|
+
|
|
23
|
+
# Calculate layer widths
|
|
24
|
+
layer_widths = calculate_layer_widths
|
|
25
|
+
|
|
26
|
+
# Calculate y positions for each layer
|
|
27
|
+
y_positions = calculate_layer_y_positions
|
|
28
|
+
|
|
29
|
+
# Place nodes in each layer
|
|
30
|
+
@layers.each_with_index do |layer_nodes, layer_index|
|
|
31
|
+
place_layer(layer_nodes, layer_index, layer_widths[layer_index],
|
|
32
|
+
y_positions[layer_index])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def calculate_layer_widths
|
|
39
|
+
@layers.map do |layer_nodes|
|
|
40
|
+
return 0 if layer_nodes.empty?
|
|
41
|
+
|
|
42
|
+
total_width = layer_nodes.sum { |n| n.width || 0 }
|
|
43
|
+
total_spacing = (layer_nodes.length - 1) * @node_spacing
|
|
44
|
+
total_width + total_spacing
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def calculate_layer_y_positions
|
|
49
|
+
y = 0
|
|
50
|
+
positions = []
|
|
51
|
+
|
|
52
|
+
@layers.each do |layer_nodes|
|
|
53
|
+
positions << y
|
|
54
|
+
max_height = layer_nodes.map { |n| n.height || 0 }.max || 0
|
|
55
|
+
y += max_height + @layer_spacing
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
positions
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def place_layer(nodes, _layer_index, _layer_width, y_pos)
|
|
62
|
+
return if nodes.empty?
|
|
63
|
+
|
|
64
|
+
# Center the layer horizontally
|
|
65
|
+
x = 0
|
|
66
|
+
|
|
67
|
+
nodes.each do |node|
|
|
68
|
+
node.x = x
|
|
69
|
+
node.y = y_pos
|
|
70
|
+
x += (node.width || 0) + @node_spacing
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_algorithm"
|
|
4
|
+
require_relative "layered/cycle_breaker"
|
|
5
|
+
require_relative "layered/layer_assigner"
|
|
6
|
+
require_relative "layered/node_placer"
|
|
7
|
+
|
|
8
|
+
module Elkrb
|
|
9
|
+
module Layout
|
|
10
|
+
module Algorithms
|
|
11
|
+
# Layered (Sugiyama) layout algorithm
|
|
12
|
+
#
|
|
13
|
+
# The flagship algorithm for hierarchical graph layout.
|
|
14
|
+
# Implements the Sugiyama framework in phases:
|
|
15
|
+
# 1. Cycle breaking - make the graph acyclic
|
|
16
|
+
# 2. Layer assignment - assign nodes to horizontal layers
|
|
17
|
+
# 3. Node placement - position nodes within layers
|
|
18
|
+
#
|
|
19
|
+
# Ideal for:
|
|
20
|
+
# - UML class diagrams
|
|
21
|
+
# - Call graphs
|
|
22
|
+
# - Data flow diagrams
|
|
23
|
+
# - Organization charts
|
|
24
|
+
# - Any directed acyclic graph
|
|
25
|
+
class LayeredAlgorithm < BaseAlgorithm
|
|
26
|
+
def layout_flat(graph, _options = {})
|
|
27
|
+
return graph if graph.children.nil? || graph.children.empty?
|
|
28
|
+
|
|
29
|
+
# Phase 1: Break cycles
|
|
30
|
+
cycle_breaker = Layered::CycleBreaker.new(graph)
|
|
31
|
+
cycle_breaker.break_cycles
|
|
32
|
+
|
|
33
|
+
# Phase 2: Assign layers
|
|
34
|
+
layer_assigner = Layered::LayerAssigner.new(graph)
|
|
35
|
+
layers = layer_assigner.assign_layers
|
|
36
|
+
|
|
37
|
+
# Phase 3: Place nodes
|
|
38
|
+
node_placer = Layered::NodePlacer.new(graph, layers, @options)
|
|
39
|
+
node_placer.place_nodes
|
|
40
|
+
|
|
41
|
+
# Apply padding and set graph dimensions
|
|
42
|
+
apply_padding(graph)
|
|
43
|
+
|
|
44
|
+
graph
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|