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,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_algorithm"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Layout
|
|
7
|
+
module Algorithms
|
|
8
|
+
# Rectangle Packing layout algorithm
|
|
9
|
+
#
|
|
10
|
+
# Efficiently packs rectangular nodes using a shelf-based bin packing approach.
|
|
11
|
+
class RectPacking < BaseAlgorithm
|
|
12
|
+
def layout_flat(graph, _options = {})
|
|
13
|
+
return graph if graph.children.empty?
|
|
14
|
+
|
|
15
|
+
if graph.children.size == 1
|
|
16
|
+
# Single node 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
|
+
# Pack rectangles using shelf algorithm
|
|
24
|
+
pack_rectangles(graph.children)
|
|
25
|
+
|
|
26
|
+
apply_padding(graph)
|
|
27
|
+
|
|
28
|
+
graph
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def pack_rectangles(nodes)
|
|
34
|
+
return if nodes.empty?
|
|
35
|
+
|
|
36
|
+
spacing = node_spacing
|
|
37
|
+
|
|
38
|
+
# Sort nodes by height (tallest first) for better packing
|
|
39
|
+
sorted_nodes = nodes.sort_by { |n| -n.height }
|
|
40
|
+
|
|
41
|
+
# Initialize first shelf
|
|
42
|
+
shelves = []
|
|
43
|
+
current_shelf = {
|
|
44
|
+
y: 0.0,
|
|
45
|
+
height: 0.0,
|
|
46
|
+
width: 0.0,
|
|
47
|
+
nodes: [],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
sorted_nodes.each do |node|
|
|
51
|
+
# Try to fit on current shelf
|
|
52
|
+
unless can_fit_on_shelf?(node, current_shelf, spacing)
|
|
53
|
+
# Start a new shelf
|
|
54
|
+
shelves << current_shelf unless current_shelf[:nodes].empty?
|
|
55
|
+
|
|
56
|
+
current_shelf = {
|
|
57
|
+
y: shelves.empty? ? 0.0 : shelves.last[:y] + shelves.last[:height] + spacing,
|
|
58
|
+
height: node.height,
|
|
59
|
+
width: 0.0,
|
|
60
|
+
nodes: [],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
end
|
|
64
|
+
place_on_shelf(node, current_shelf, spacing)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Add the last shelf
|
|
68
|
+
shelves << current_shelf unless current_shelf[:nodes].empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def can_fit_on_shelf?(node, shelf, _spacing)
|
|
72
|
+
# First node always fits
|
|
73
|
+
return true if shelf[:nodes].empty?
|
|
74
|
+
|
|
75
|
+
# Check if adding this node would make the shelf too tall
|
|
76
|
+
# (we want relatively uniform shelf heights)
|
|
77
|
+
shelf[:height] >= node.height * 0.8
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def place_on_shelf(node, shelf, spacing)
|
|
81
|
+
# Place node at the end of the current shelf
|
|
82
|
+
node.x = shelf[:width]
|
|
83
|
+
node.y = shelf[:y]
|
|
84
|
+
|
|
85
|
+
# Update shelf dimensions
|
|
86
|
+
shelf[:nodes] << node
|
|
87
|
+
shelf[:width] += node.width + spacing
|
|
88
|
+
shelf[:height] = [shelf[:height], node.height].max
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
module Algorithms
|
|
6
|
+
# SPOrE Compaction algorithm
|
|
7
|
+
#
|
|
8
|
+
# Compacts the layout by removing whitespace while preserving
|
|
9
|
+
# the relative ordering and structure of nodes.
|
|
10
|
+
class SporeCompaction < BaseAlgorithm
|
|
11
|
+
def layout_flat(graph, _options = {})
|
|
12
|
+
return graph if graph.children.empty?
|
|
13
|
+
|
|
14
|
+
# Compact in both directions
|
|
15
|
+
direction = graph.layout_options&.[]("spore.compactionDirection") || "both"
|
|
16
|
+
min_spacing = graph.layout_options&.[]("spore.nodeSpacing") || 10.0
|
|
17
|
+
|
|
18
|
+
case direction
|
|
19
|
+
when "horizontal"
|
|
20
|
+
compact_horizontal(graph.children, min_spacing)
|
|
21
|
+
when "vertical"
|
|
22
|
+
compact_vertical(graph.children, min_spacing)
|
|
23
|
+
else
|
|
24
|
+
compact_horizontal(graph.children, min_spacing)
|
|
25
|
+
compact_vertical(graph.children, min_spacing)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Normalize to start at origin
|
|
29
|
+
normalize_positions(graph.children)
|
|
30
|
+
|
|
31
|
+
# Apply padding
|
|
32
|
+
apply_padding(graph)
|
|
33
|
+
|
|
34
|
+
graph
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def compact_horizontal(nodes, min_spacing)
|
|
40
|
+
# Sort nodes by x coordinate
|
|
41
|
+
sorted_nodes = nodes.sort_by(&:x)
|
|
42
|
+
|
|
43
|
+
# Compact from left to right
|
|
44
|
+
sorted_nodes.each_with_index do |node, index|
|
|
45
|
+
next if index.zero?
|
|
46
|
+
|
|
47
|
+
# Find the rightmost x position among nodes to the left
|
|
48
|
+
# that don't vertically overlap with current node
|
|
49
|
+
max_left_x = find_max_left_x(node, sorted_nodes[0...index],
|
|
50
|
+
min_spacing)
|
|
51
|
+
|
|
52
|
+
# Move node left if there's space
|
|
53
|
+
if max_left_x && max_left_x < node.x
|
|
54
|
+
node.x = max_left_x
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def find_max_left_x(node, left_nodes, min_spacing)
|
|
60
|
+
# Find nodes that vertically overlap with current node
|
|
61
|
+
overlapping = left_nodes.select do |left_node|
|
|
62
|
+
vertically_overlaps?(node, left_node)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
return 0.0 if overlapping.empty?
|
|
66
|
+
|
|
67
|
+
# Find the rightmost position among overlapping nodes
|
|
68
|
+
rightmost = overlapping.map { |n| n.x + n.width }.max
|
|
69
|
+
rightmost + min_spacing
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def compact_vertical(nodes, min_spacing)
|
|
73
|
+
# Sort nodes by y coordinate
|
|
74
|
+
sorted_nodes = nodes.sort_by(&:y)
|
|
75
|
+
|
|
76
|
+
# Compact from top to bottom
|
|
77
|
+
sorted_nodes.each_with_index do |node, index|
|
|
78
|
+
next if index.zero?
|
|
79
|
+
|
|
80
|
+
# Find the bottommost y position among nodes above
|
|
81
|
+
# that don't horizontally overlap with current node
|
|
82
|
+
max_top_y = find_max_top_y(node, sorted_nodes[0...index],
|
|
83
|
+
min_spacing)
|
|
84
|
+
|
|
85
|
+
# Move node up if there's space
|
|
86
|
+
if max_top_y && max_top_y < node.y
|
|
87
|
+
node.y = max_top_y
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def find_max_top_y(node, top_nodes, min_spacing)
|
|
93
|
+
# Find nodes that horizontally overlap with current node
|
|
94
|
+
overlapping = top_nodes.select do |top_node|
|
|
95
|
+
horizontally_overlaps?(node, top_node)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
return 0.0 if overlapping.empty?
|
|
99
|
+
|
|
100
|
+
# Find the bottommost position among overlapping nodes
|
|
101
|
+
bottommost = overlapping.map { |n| n.y + n.height }.max
|
|
102
|
+
bottommost + min_spacing
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def vertically_overlaps?(node1, node2)
|
|
106
|
+
top1 = node1.y
|
|
107
|
+
bottom1 = node1.y + node1.height
|
|
108
|
+
top2 = node2.y
|
|
109
|
+
bottom2 = node2.y + node2.height
|
|
110
|
+
|
|
111
|
+
!(bottom1 <= top2 || bottom2 <= top1)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def horizontally_overlaps?(node1, node2)
|
|
115
|
+
left1 = node1.x
|
|
116
|
+
right1 = node1.x + node1.width
|
|
117
|
+
left2 = node2.x
|
|
118
|
+
right2 = node2.x + node2.width
|
|
119
|
+
|
|
120
|
+
!(right1 <= left2 || right2 <= left1)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def normalize_positions(nodes)
|
|
124
|
+
return if nodes.empty?
|
|
125
|
+
|
|
126
|
+
# Find minimum x and y
|
|
127
|
+
min_x = nodes.map(&:x).min
|
|
128
|
+
min_y = nodes.map(&:y).min
|
|
129
|
+
|
|
130
|
+
# Shift all nodes to start at origin
|
|
131
|
+
nodes.each do |node|
|
|
132
|
+
node.x -= min_x
|
|
133
|
+
node.y -= min_y
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
module Algorithms
|
|
6
|
+
# SPOrE Overlap Removal algorithm
|
|
7
|
+
#
|
|
8
|
+
# Removes node overlaps while preserving the overall structure
|
|
9
|
+
# of the layout by applying constrained positioning.
|
|
10
|
+
class SporeOverlap < BaseAlgorithm
|
|
11
|
+
def layout_flat(graph, _options = {})
|
|
12
|
+
return graph if graph.children.empty?
|
|
13
|
+
|
|
14
|
+
# Iteratively remove overlaps
|
|
15
|
+
max_iterations = graph.layout_options&.[]("spore.maxIterations") || 50
|
|
16
|
+
min_spacing = graph.layout_options&.[]("spore.nodeSpacing") || 10.0
|
|
17
|
+
|
|
18
|
+
max_iterations.times do
|
|
19
|
+
overlaps = find_overlaps(graph.children, min_spacing)
|
|
20
|
+
break if overlaps.empty?
|
|
21
|
+
|
|
22
|
+
resolve_overlaps(overlaps, min_spacing)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Apply padding
|
|
26
|
+
apply_padding(graph)
|
|
27
|
+
|
|
28
|
+
graph
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def find_overlaps(nodes, min_spacing)
|
|
34
|
+
overlaps = []
|
|
35
|
+
|
|
36
|
+
nodes.each_with_index do |node1, i|
|
|
37
|
+
nodes[(i + 1)..].each do |node2|
|
|
38
|
+
if overlapping?(node1, node2, min_spacing)
|
|
39
|
+
overlaps << [node1, node2]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
overlaps
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def overlapping?(node1, node2, min_spacing)
|
|
48
|
+
# Calculate bounding boxes with spacing
|
|
49
|
+
left1 = node1.x - (min_spacing / 2.0)
|
|
50
|
+
right1 = node1.x + node1.width + (min_spacing / 2.0)
|
|
51
|
+
top1 = node1.y - (min_spacing / 2.0)
|
|
52
|
+
bottom1 = node1.y + node1.height + (min_spacing / 2.0)
|
|
53
|
+
|
|
54
|
+
left2 = node2.x - (min_spacing / 2.0)
|
|
55
|
+
right2 = node2.x + node2.width + (min_spacing / 2.0)
|
|
56
|
+
top2 = node2.y - (min_spacing / 2.0)
|
|
57
|
+
bottom2 = node2.y + node2.height + (min_spacing / 2.0)
|
|
58
|
+
|
|
59
|
+
# Check for overlap
|
|
60
|
+
!(right1 <= left2 || left1 >= right2 || bottom1 <= top2 || top1 >= bottom2)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resolve_overlaps(overlaps, min_spacing)
|
|
64
|
+
overlaps.each do |node1, node2|
|
|
65
|
+
# Calculate overlap amounts
|
|
66
|
+
center1_x = node1.x + (node1.width / 2.0)
|
|
67
|
+
center1_y = node1.y + (node1.height / 2.0)
|
|
68
|
+
center2_x = node2.x + (node2.width / 2.0)
|
|
69
|
+
center2_y = node2.y + (node2.height / 2.0)
|
|
70
|
+
|
|
71
|
+
dx = center2_x - center1_x
|
|
72
|
+
dy = center2_y - center1_y
|
|
73
|
+
|
|
74
|
+
# Calculate minimum required distance
|
|
75
|
+
min_dist_x = ((node1.width + node2.width) / 2.0) + min_spacing
|
|
76
|
+
min_dist_y = ((node1.height + node2.height) / 2.0) + min_spacing
|
|
77
|
+
|
|
78
|
+
# Determine movement direction
|
|
79
|
+
if dx.abs > dy.abs
|
|
80
|
+
# Move horizontally
|
|
81
|
+
if dx.positive?
|
|
82
|
+
# node2 is to the right
|
|
83
|
+
overlap = min_dist_x - dx
|
|
84
|
+
if overlap.positive?
|
|
85
|
+
node2.x += overlap / 2.0
|
|
86
|
+
node1.x -= overlap / 2.0
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
# node2 is to the left
|
|
90
|
+
overlap = min_dist_x + dx
|
|
91
|
+
if overlap.positive?
|
|
92
|
+
node2.x -= overlap / 2.0
|
|
93
|
+
node1.x += overlap / 2.0
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
elsif dy.positive?
|
|
97
|
+
# Move vertically
|
|
98
|
+
overlap = min_dist_y - dy
|
|
99
|
+
if overlap.positive?
|
|
100
|
+
node2.y += overlap / 2.0
|
|
101
|
+
node1.y -= overlap / 2.0
|
|
102
|
+
end
|
|
103
|
+
# node2 is below
|
|
104
|
+
else
|
|
105
|
+
# node2 is above
|
|
106
|
+
overlap = min_dist_y + dy
|
|
107
|
+
if overlap.positive?
|
|
108
|
+
node2.y -= overlap / 2.0
|
|
109
|
+
node1.y += overlap / 2.0
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_algorithm"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Layout
|
|
7
|
+
module Algorithms
|
|
8
|
+
# Stress minimization layout algorithm
|
|
9
|
+
#
|
|
10
|
+
# Quality-focused layout that minimizes stress by optimizing
|
|
11
|
+
# the placement of nodes to match ideal distances.
|
|
12
|
+
# Produces aesthetically pleasing layouts with good edge lengths.
|
|
13
|
+
#
|
|
14
|
+
# Ideal for:
|
|
15
|
+
# - High-quality graph visualization
|
|
16
|
+
# - Research diagrams
|
|
17
|
+
# - Publication-ready layouts
|
|
18
|
+
# - Small to medium-sized graphs
|
|
19
|
+
class Stress < BaseAlgorithm
|
|
20
|
+
DEFAULT_ITERATIONS = 500
|
|
21
|
+
DEFAULT_EPSILON = 0.0001
|
|
22
|
+
|
|
23
|
+
def layout_flat(graph, _options = {})
|
|
24
|
+
return graph if graph.children.nil? || graph.children.empty?
|
|
25
|
+
|
|
26
|
+
# Get configuration
|
|
27
|
+
iterations = option("iterations", DEFAULT_ITERATIONS).to_i
|
|
28
|
+
epsilon = option("epsilon", DEFAULT_EPSILON).to_f
|
|
29
|
+
|
|
30
|
+
# Initialize positions
|
|
31
|
+
initialize_positions(graph)
|
|
32
|
+
|
|
33
|
+
# Calculate shortest path distances
|
|
34
|
+
distances = calculate_distances(graph)
|
|
35
|
+
|
|
36
|
+
# 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)
|
|
41
|
+
|
|
42
|
+
# Stop if converged
|
|
43
|
+
break if (old_stress - new_stress).abs < epsilon
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Apply padding and set graph dimensions
|
|
47
|
+
apply_padding(graph)
|
|
48
|
+
|
|
49
|
+
graph
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def initialize_positions(graph)
|
|
55
|
+
# Use circular initial layout
|
|
56
|
+
n = graph.children.length
|
|
57
|
+
radius = n * 10.0
|
|
58
|
+
|
|
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
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def calculate_distances(graph)
|
|
67
|
+
n = graph.children.length
|
|
68
|
+
distances = Array.new(n) { Array.new(n, Float::INFINITY) }
|
|
69
|
+
|
|
70
|
+
# Initialize distances
|
|
71
|
+
n.times { |i| distances[i][i] = 0 }
|
|
72
|
+
|
|
73
|
+
# Set edge distances
|
|
74
|
+
all_edges = collect_all_edges(graph)
|
|
75
|
+
all_edges.each do |edge|
|
|
76
|
+
source_id = edge.sources&.first
|
|
77
|
+
target_id = edge.targets&.first
|
|
78
|
+
next unless source_id && target_id
|
|
79
|
+
|
|
80
|
+
i = graph.children.index { |n| n.id == source_id }
|
|
81
|
+
j = graph.children.index { |n| n.id == target_id }
|
|
82
|
+
|
|
83
|
+
next unless i && j
|
|
84
|
+
|
|
85
|
+
distances[i][j] = 1.0
|
|
86
|
+
distances[j][i] = 1.0
|
|
87
|
+
end
|
|
88
|
+
|
|
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
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
distances
|
|
102
|
+
end
|
|
103
|
+
|
|
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]
|
|
112
|
+
|
|
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))
|
|
116
|
+
|
|
117
|
+
ideal_dist = ideal_distances[i][j]
|
|
118
|
+
next if ideal_dist == Float::INFINITY
|
|
119
|
+
|
|
120
|
+
diff = actual_dist - ideal_dist
|
|
121
|
+
stress += diff * diff
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
stress
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def optimize_positions(graph, ideal_distances)
|
|
129
|
+
n = graph.children.length
|
|
130
|
+
|
|
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
|
|
136
|
+
|
|
137
|
+
n.times do |j|
|
|
138
|
+
next if i == j
|
|
139
|
+
|
|
140
|
+
ideal_dist = ideal_distances[i][j]
|
|
141
|
+
next if ideal_dist == Float::INFINITY
|
|
142
|
+
|
|
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))
|
|
147
|
+
|
|
148
|
+
next if actual_dist.zero?
|
|
149
|
+
|
|
150
|
+
weight = 1.0 / (ideal_dist * ideal_dist)
|
|
151
|
+
ratio = ideal_dist / actual_dist
|
|
152
|
+
|
|
153
|
+
sum_x += weight * (other.x - (ratio * dx))
|
|
154
|
+
sum_y += weight * (other.y - (ratio * dy))
|
|
155
|
+
sum_weight += weight
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
if sum_weight.positive?
|
|
159
|
+
node.x = sum_x / sum_weight
|
|
160
|
+
node.y = sum_y / sum_weight
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
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
|
|
170
|
+
end
|
|
171
|
+
edges
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_algorithm"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Layout
|
|
7
|
+
module Algorithms
|
|
8
|
+
# TopdownPacking layout algorithm
|
|
9
|
+
#
|
|
10
|
+
# Arranges nodes in a grid using top-down, left-right placement.
|
|
11
|
+
# Unlike RectPacking which uses shelf-based bin packing, TopdownPacking
|
|
12
|
+
# arranges nodes in a uniform grid, making it ideal for:
|
|
13
|
+
# - Treemap-style layouts
|
|
14
|
+
# - Dashboard tile arrangements
|
|
15
|
+
# - Hierarchical layouts with uniform node sizes
|
|
16
|
+
#
|
|
17
|
+
# The algorithm calculates grid dimensions to approximate a square
|
|
18
|
+
# aspect ratio, then places nodes from left to right, top to bottom.
|
|
19
|
+
class TopdownPacking < BaseAlgorithm
|
|
20
|
+
def initialize(options = {})
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Layout nodes in a grid using top-down packing
|
|
25
|
+
#
|
|
26
|
+
# @param graph [Elkrb::Graph::Graph] The graph to layout
|
|
27
|
+
# @param options [Hash] Layout options
|
|
28
|
+
# @return [Elkrb::Graph::Graph] The graph with updated positions
|
|
29
|
+
def layout_flat(graph, _options = {})
|
|
30
|
+
return graph if graph.children.empty?
|
|
31
|
+
|
|
32
|
+
if graph.children.size == 1
|
|
33
|
+
# Single node at origin
|
|
34
|
+
graph.children.first.x = 0.0
|
|
35
|
+
graph.children.first.y = 0.0
|
|
36
|
+
apply_padding(graph)
|
|
37
|
+
return graph
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Calculate grid dimensions
|
|
41
|
+
nodes = graph.children
|
|
42
|
+
grid_dims = calculate_grid_dimensions(nodes.size)
|
|
43
|
+
cols = grid_dims[:cols]
|
|
44
|
+
rows = grid_dims[:rows]
|
|
45
|
+
|
|
46
|
+
# Get node dimensions (uniform for grid layout)
|
|
47
|
+
node_dims = calculate_node_dimensions(graph, nodes, cols, rows)
|
|
48
|
+
node_width = node_dims[:width]
|
|
49
|
+
node_height = node_dims[:height]
|
|
50
|
+
|
|
51
|
+
# Place nodes in grid
|
|
52
|
+
place_nodes_in_grid(nodes, cols, node_width, node_height)
|
|
53
|
+
|
|
54
|
+
apply_padding(graph)
|
|
55
|
+
|
|
56
|
+
graph
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Calculate grid dimensions to approximate square aspect ratio
|
|
62
|
+
#
|
|
63
|
+
# Uses the formula from the Java implementation:
|
|
64
|
+
# cols = ceil(sqrt(N))
|
|
65
|
+
# rows = cols if N > cols^2 - cols, else cols - 1
|
|
66
|
+
#
|
|
67
|
+
# @param node_count [Integer] Number of nodes to arrange
|
|
68
|
+
# @return [Hash] Grid dimensions with :cols and :rows
|
|
69
|
+
def calculate_grid_dimensions(node_count)
|
|
70
|
+
return { cols: 0, rows: 0 } if node_count.zero?
|
|
71
|
+
|
|
72
|
+
# Calculate columns to approximate square
|
|
73
|
+
cols = Math.sqrt(node_count).ceil
|
|
74
|
+
|
|
75
|
+
# Calculate rows based on remaining nodes
|
|
76
|
+
# This ensures we don't have an empty last row
|
|
77
|
+
rows = if node_count > (cols * cols) - cols || cols.zero?
|
|
78
|
+
cols
|
|
79
|
+
else
|
|
80
|
+
cols - 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
{ cols: cols, rows: rows }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Calculate uniform node dimensions for grid cells
|
|
87
|
+
#
|
|
88
|
+
# @param graph [Elkrb::Graph::Graph] The graph
|
|
89
|
+
# @param nodes [Array<Elkrb::Graph::Node>] The nodes
|
|
90
|
+
# @param cols [Integer] Number of columns
|
|
91
|
+
# @param rows [Integer] Number of rows
|
|
92
|
+
# @return [Hash] Node dimensions with :width and :height
|
|
93
|
+
def calculate_node_dimensions(graph, nodes, cols, rows)
|
|
94
|
+
if nodes.empty? || cols.zero? || rows.zero?
|
|
95
|
+
return { width: 0.0,
|
|
96
|
+
height: 0.0 }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Get options from graph layout options
|
|
100
|
+
layout_opts = graph.layout_options || {}
|
|
101
|
+
|
|
102
|
+
# Get target aspect ratio (default: 1.0 for square cells)
|
|
103
|
+
target_aspect_ratio = get_option(layout_opts,
|
|
104
|
+
"topdownpacking.aspectRatio", 1.0).to_f
|
|
105
|
+
target_aspect_ratio = 1.0 if target_aspect_ratio <= 0.0
|
|
106
|
+
|
|
107
|
+
# Calculate node dimensions based on aspect ratio
|
|
108
|
+
# We can either use specified dimensions or calculate from available space
|
|
109
|
+
node_width_opt = get_option(layout_opts, "topdownpacking.nodeWidth")
|
|
110
|
+
if node_width_opt
|
|
111
|
+
node_width = node_width_opt.to_f
|
|
112
|
+
node_height = node_width / target_aspect_ratio
|
|
113
|
+
else
|
|
114
|
+
# Calculate from node sizes to maintain proportions
|
|
115
|
+
avg_width = nodes.sum(&:width) / nodes.size.to_f
|
|
116
|
+
avg_height = nodes.sum(&:height) / nodes.size.to_f
|
|
117
|
+
|
|
118
|
+
# Use the larger dimension as base
|
|
119
|
+
if avg_width >= avg_height
|
|
120
|
+
node_width = avg_width
|
|
121
|
+
node_height = node_width / target_aspect_ratio
|
|
122
|
+
else
|
|
123
|
+
node_height = avg_height
|
|
124
|
+
node_width = node_height * target_aspect_ratio
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
{ width: node_width, height: node_height }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Get option value from layout options or default
|
|
132
|
+
#
|
|
133
|
+
# @param layout_opts [Hash, LayoutOptions] The layout options
|
|
134
|
+
# @param key [String] The option key
|
|
135
|
+
# @param default [Object] The default value
|
|
136
|
+
# @return [Object] The option value or default
|
|
137
|
+
def get_option(layout_opts, key, default = nil)
|
|
138
|
+
return default unless layout_opts
|
|
139
|
+
|
|
140
|
+
value = if layout_opts.respond_to?(:[])
|
|
141
|
+
layout_opts[key]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
value.nil? ? default : value
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Place nodes in grid positions
|
|
148
|
+
#
|
|
149
|
+
# @param nodes [Array<Elkrb::Graph::Node>] The nodes to place
|
|
150
|
+
# @param cols [Integer] Number of columns
|
|
151
|
+
# @param node_width [Float] Width of each grid cell
|
|
152
|
+
# @param node_height [Float] Height of each grid cell
|
|
153
|
+
def place_nodes_in_grid(nodes, cols, node_width, node_height)
|
|
154
|
+
spacing = node_spacing
|
|
155
|
+
current_x = 0.0
|
|
156
|
+
current_y = 0.0
|
|
157
|
+
current_col = 0
|
|
158
|
+
|
|
159
|
+
nodes.each do |node|
|
|
160
|
+
# Set node dimensions
|
|
161
|
+
node.width = node_width
|
|
162
|
+
node.height = node_height
|
|
163
|
+
|
|
164
|
+
# Set node position
|
|
165
|
+
node.x = current_x
|
|
166
|
+
node.y = current_y
|
|
167
|
+
|
|
168
|
+
# Advance to next position
|
|
169
|
+
current_col += 1
|
|
170
|
+
current_x += node_width + spacing
|
|
171
|
+
|
|
172
|
+
# Move to next row if we've filled the current row
|
|
173
|
+
if current_col >= cols
|
|
174
|
+
current_x = 0.0
|
|
175
|
+
current_y += node_height + spacing
|
|
176
|
+
current_col = 0
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|