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,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_algorithm"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Layout
|
|
7
|
+
module Algorithms
|
|
8
|
+
# VertiFlex layout algorithm
|
|
9
|
+
#
|
|
10
|
+
# A vertical flexible layout algorithm that arranges nodes in vertical
|
|
11
|
+
# columns with optimized spacing. Ideal for timeline-style layouts,
|
|
12
|
+
# Kanban boards, and vertical flowcharts.
|
|
13
|
+
#
|
|
14
|
+
# The algorithm distributes nodes into vertical columns and positions
|
|
15
|
+
# them with flexible column widths based on the widest node in each
|
|
16
|
+
# column.
|
|
17
|
+
#
|
|
18
|
+
# This is an experimental algorithm matching the Java ELK implementation.
|
|
19
|
+
class VertiFlex < BaseAlgorithm
|
|
20
|
+
def initialize(options = {})
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Layout nodes in vertical columns
|
|
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
|
+
nodes = graph.children
|
|
41
|
+
layout_opts = graph.layout_options || {}
|
|
42
|
+
|
|
43
|
+
# Get layout options
|
|
44
|
+
column_count = get_option(layout_opts, "vertiflex.columnCount",
|
|
45
|
+
3).to_i
|
|
46
|
+
column_count = 1 if column_count < 1
|
|
47
|
+
|
|
48
|
+
column_spacing = get_option(
|
|
49
|
+
layout_opts,
|
|
50
|
+
"vertiflex.columnSpacing",
|
|
51
|
+
50.0,
|
|
52
|
+
).to_f
|
|
53
|
+
|
|
54
|
+
vertical_spacing = get_option(
|
|
55
|
+
layout_opts,
|
|
56
|
+
"vertiflex.verticalSpacing",
|
|
57
|
+
30.0,
|
|
58
|
+
).to_f
|
|
59
|
+
|
|
60
|
+
# Override with elk.spacing.nodeNode if present
|
|
61
|
+
node_node_spacing = get_option(layout_opts, "elk.spacing.nodeNode")
|
|
62
|
+
vertical_spacing = node_node_spacing.to_f if node_node_spacing
|
|
63
|
+
|
|
64
|
+
balance_columns = get_option(
|
|
65
|
+
layout_opts,
|
|
66
|
+
"vertiflex.balanceColumns",
|
|
67
|
+
true,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Distribute nodes into columns
|
|
71
|
+
columns = distribute_nodes(nodes, column_count, balance_columns)
|
|
72
|
+
|
|
73
|
+
# Position columns horizontally
|
|
74
|
+
position_columns(columns, column_spacing, vertical_spacing)
|
|
75
|
+
|
|
76
|
+
apply_padding(graph)
|
|
77
|
+
|
|
78
|
+
graph
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Get option value from layout options or default
|
|
84
|
+
#
|
|
85
|
+
# @param layout_opts [Hash, LayoutOptions] The layout options
|
|
86
|
+
# @param key [String] The option key
|
|
87
|
+
# @param default [Object] The default value
|
|
88
|
+
# @return [Object] The option value or default
|
|
89
|
+
def get_option(layout_opts, key, default = nil)
|
|
90
|
+
return default unless layout_opts
|
|
91
|
+
|
|
92
|
+
value = if layout_opts.respond_to?(:[])
|
|
93
|
+
layout_opts[key]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
value.nil? ? default : value
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Distribute nodes into columns
|
|
100
|
+
#
|
|
101
|
+
# @param nodes [Array<Elkrb::Graph::Node>] Nodes to distribute
|
|
102
|
+
# @param column_count [Integer] Number of columns
|
|
103
|
+
# @param balance [Boolean] Whether to balance distribution
|
|
104
|
+
# @return [Array<Array<Elkrb::Graph::Node>>] Array of columns
|
|
105
|
+
def distribute_nodes(nodes, column_count, balance)
|
|
106
|
+
# Initialize columns
|
|
107
|
+
columns = Array.new(column_count) { [] }
|
|
108
|
+
|
|
109
|
+
if balance
|
|
110
|
+
# Balanced distribution: round-robin assignment
|
|
111
|
+
nodes.each_with_index do |node, index|
|
|
112
|
+
column_index = index % column_count
|
|
113
|
+
columns[column_index] << node
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
# Sequential distribution: fill columns in order
|
|
117
|
+
nodes_per_column = (nodes.size.to_f / column_count).ceil
|
|
118
|
+
nodes.each_slice(nodes_per_column).with_index do |slice, index|
|
|
119
|
+
columns[index] = slice if index < column_count
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Remove empty columns
|
|
124
|
+
columns.reject(&:empty?)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Position columns horizontally with flexible widths
|
|
128
|
+
#
|
|
129
|
+
# @param columns [Array<Array<Elkrb::Graph::Node>>] Columns of nodes
|
|
130
|
+
# @param column_spacing [Float] Spacing between columns
|
|
131
|
+
# @param vertical_spacing [Float] Vertical spacing within columns
|
|
132
|
+
def position_columns(columns, column_spacing, vertical_spacing)
|
|
133
|
+
current_x = 0.0
|
|
134
|
+
|
|
135
|
+
columns.each do |column_nodes|
|
|
136
|
+
# Calculate column width based on widest node
|
|
137
|
+
column_width = column_nodes.map(&:width).max || 100.0
|
|
138
|
+
|
|
139
|
+
# Position nodes vertically in this column
|
|
140
|
+
position_column_nodes(
|
|
141
|
+
column_nodes,
|
|
142
|
+
current_x,
|
|
143
|
+
column_width,
|
|
144
|
+
vertical_spacing,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Advance to next column position
|
|
148
|
+
current_x += column_width + column_spacing
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Position nodes vertically within a column
|
|
153
|
+
#
|
|
154
|
+
# @param nodes [Array<Elkrb::Graph::Node>] Nodes in the column
|
|
155
|
+
# @param column_x [Float] X position of the column
|
|
156
|
+
# @param column_width [Float] Width of the column (unused, kept for API)
|
|
157
|
+
# @param vertical_spacing [Float] Vertical spacing between nodes
|
|
158
|
+
def position_column_nodes(nodes, column_x, _column_width,
|
|
159
|
+
vertical_spacing)
|
|
160
|
+
current_y = 0.0
|
|
161
|
+
|
|
162
|
+
nodes.each do |node|
|
|
163
|
+
# Position node at column x (no centering for simpler layout)
|
|
164
|
+
node.x = column_x
|
|
165
|
+
node.y = current_y
|
|
166
|
+
|
|
167
|
+
# Advance to next vertical position
|
|
168
|
+
current_y += node.height + vertical_spacing
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_constraint"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Layout
|
|
7
|
+
module Constraints
|
|
8
|
+
# Alignment constraint
|
|
9
|
+
#
|
|
10
|
+
# Aligns nodes horizontally (same y) or vertically (same x) based on
|
|
11
|
+
# their align_group setting.
|
|
12
|
+
#
|
|
13
|
+
# @example Horizontal alignment
|
|
14
|
+
# db1.constraints = NodeConstraints.new(
|
|
15
|
+
# align_group: "databases",
|
|
16
|
+
# align_direction: "horizontal"
|
|
17
|
+
# )
|
|
18
|
+
# db2.constraints = NodeConstraints.new(
|
|
19
|
+
# align_group: "databases",
|
|
20
|
+
# align_direction: "horizontal"
|
|
21
|
+
# )
|
|
22
|
+
# # Both nodes will have same y coordinate
|
|
23
|
+
#
|
|
24
|
+
# @example Vertical alignment
|
|
25
|
+
# ui1.constraints = NodeConstraints.new(
|
|
26
|
+
# align_group: "ui_layer",
|
|
27
|
+
# align_direction: "vertical"
|
|
28
|
+
# )
|
|
29
|
+
# ui2.constraints = NodeConstraints.new(
|
|
30
|
+
# align_group: "ui_layer",
|
|
31
|
+
# align_direction: "vertical"
|
|
32
|
+
# )
|
|
33
|
+
# # Both nodes will have same x coordinate
|
|
34
|
+
class AlignmentConstraint < BaseConstraint
|
|
35
|
+
# Apply alignment constraint
|
|
36
|
+
#
|
|
37
|
+
# Groups nodes by align_group and aligns them according to
|
|
38
|
+
# align_direction.
|
|
39
|
+
#
|
|
40
|
+
# @param graph [Graph::Graph] The graph
|
|
41
|
+
# @return [Graph::Graph] The modified graph
|
|
42
|
+
def apply(graph)
|
|
43
|
+
# Group nodes by alignment group and direction
|
|
44
|
+
alignment_groups = group_by_alignment(all_nodes(graph))
|
|
45
|
+
|
|
46
|
+
# Apply alignment to each group
|
|
47
|
+
alignment_groups.each do |key, nodes|
|
|
48
|
+
next if nodes.length < 2
|
|
49
|
+
|
|
50
|
+
_group_name, direction = key
|
|
51
|
+
align_nodes(nodes, direction)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
graph
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Validate alignment constraints
|
|
58
|
+
#
|
|
59
|
+
# Checks that aligned nodes have matching coordinates.
|
|
60
|
+
#
|
|
61
|
+
# @param graph [Graph::Graph] The graph to validate
|
|
62
|
+
# @return [Array<String>] List of validation errors
|
|
63
|
+
def validate(graph)
|
|
64
|
+
errors = []
|
|
65
|
+
alignment_groups = group_by_alignment(all_nodes(graph))
|
|
66
|
+
|
|
67
|
+
alignment_groups.each do |key, nodes|
|
|
68
|
+
next if nodes.length < 2
|
|
69
|
+
|
|
70
|
+
group_name, direction = key
|
|
71
|
+
errors.concat(validate_group_alignment(nodes, group_name,
|
|
72
|
+
direction))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
errors
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Group nodes by align_group and align_direction
|
|
81
|
+
def group_by_alignment(nodes)
|
|
82
|
+
nodes_with_alignment = nodes.select do |node|
|
|
83
|
+
node.constraints&.align_group &&
|
|
84
|
+
node.constraints.align_direction
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
nodes_with_alignment.group_by do |node|
|
|
88
|
+
[node.constraints.align_group,
|
|
89
|
+
node.constraints.align_direction]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Align nodes in a group
|
|
94
|
+
def align_nodes(nodes, direction)
|
|
95
|
+
return if nodes.empty?
|
|
96
|
+
|
|
97
|
+
case direction
|
|
98
|
+
when Graph::NodeConstraints::HORIZONTAL
|
|
99
|
+
align_horizontally(nodes)
|
|
100
|
+
when Graph::NodeConstraints::VERTICAL
|
|
101
|
+
align_vertically(nodes)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Align nodes horizontally (same y)
|
|
106
|
+
def align_horizontally(nodes)
|
|
107
|
+
# Use average y position
|
|
108
|
+
avg_y = nodes.filter_map(&:y).sum / nodes.length.to_f
|
|
109
|
+
|
|
110
|
+
nodes.each do |node|
|
|
111
|
+
node.y = avg_y
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Align nodes vertically (same x)
|
|
116
|
+
def align_vertically(nodes)
|
|
117
|
+
# Use average x position
|
|
118
|
+
avg_x = nodes.filter_map(&:x).sum / nodes.length.to_f
|
|
119
|
+
|
|
120
|
+
nodes.each do |node|
|
|
121
|
+
node.x = avg_x
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Validate group alignment
|
|
126
|
+
def validate_group_alignment(nodes, group_name, direction)
|
|
127
|
+
errors = []
|
|
128
|
+
return errors if nodes.empty?
|
|
129
|
+
|
|
130
|
+
case direction
|
|
131
|
+
when Graph::NodeConstraints::HORIZONTAL
|
|
132
|
+
y_values = nodes.filter_map(&:y).uniq
|
|
133
|
+
if y_values.length > 1
|
|
134
|
+
errors << "Alignment group '#{group_name}' (horizontal) " \
|
|
135
|
+
"has nodes with different y coordinates: #{y_values}"
|
|
136
|
+
end
|
|
137
|
+
when Graph::NodeConstraints::VERTICAL
|
|
138
|
+
x_values = nodes.filter_map(&:x).uniq
|
|
139
|
+
if x_values.length > 1
|
|
140
|
+
errors << "Alignment group '#{group_name}' (vertical) " \
|
|
141
|
+
"has nodes with different x coordinates: #{x_values}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
errors
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
module Constraints
|
|
6
|
+
# Base class for all layout constraints
|
|
7
|
+
#
|
|
8
|
+
# Constraints modify node positions before or after layout to enforce
|
|
9
|
+
# specific positioning rules. Each constraint type handles one specific
|
|
10
|
+
# kind of positioning requirement.
|
|
11
|
+
#
|
|
12
|
+
# Subclasses must implement:
|
|
13
|
+
# - apply(graph): Modify graph to enforce constraint
|
|
14
|
+
# - validate(graph): Check if constraint is satisfied
|
|
15
|
+
#
|
|
16
|
+
# @abstract
|
|
17
|
+
class BaseConstraint
|
|
18
|
+
# Apply the constraint to the graph
|
|
19
|
+
#
|
|
20
|
+
# @param graph [Graph::Graph] The graph to apply constraint to
|
|
21
|
+
# @return [Graph::Graph] The modified graph
|
|
22
|
+
def apply(graph)
|
|
23
|
+
raise NotImplementedError,
|
|
24
|
+
"#{self.class} must implement #apply"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Validate that the constraint is satisfied
|
|
28
|
+
#
|
|
29
|
+
# @param graph [Graph::Graph] The graph to validate
|
|
30
|
+
# @return [Array<String>] List of validation errors (empty if valid)
|
|
31
|
+
def validate(_graph)
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Check if constraint applies to this node
|
|
36
|
+
#
|
|
37
|
+
# @param node [Graph::Node] The node to check
|
|
38
|
+
# @return [Boolean] True if constraint applies
|
|
39
|
+
def applies_to?(node)
|
|
40
|
+
node.constraints.present?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
protected
|
|
44
|
+
|
|
45
|
+
# Find node by ID in graph
|
|
46
|
+
#
|
|
47
|
+
# @param graph [Graph::Graph] The graph to search
|
|
48
|
+
# @param node_id [String] The node ID to find
|
|
49
|
+
# @return [Graph::Node, nil] The found node or nil
|
|
50
|
+
def find_node(graph, node_id)
|
|
51
|
+
return nil unless graph.children
|
|
52
|
+
|
|
53
|
+
graph.children.each do |node|
|
|
54
|
+
found = node.find_node(node_id)
|
|
55
|
+
return found if found
|
|
56
|
+
end
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Get all nodes from graph (including nested)
|
|
61
|
+
#
|
|
62
|
+
# @param graph [Graph::Graph] The graph
|
|
63
|
+
# @return [Array<Graph::Node>] All nodes
|
|
64
|
+
def all_nodes(graph)
|
|
65
|
+
return [] unless graph.children
|
|
66
|
+
|
|
67
|
+
graph.children.flat_map(&:all_nodes)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_constraint"
|
|
4
|
+
require_relative "fixed_position_constraint"
|
|
5
|
+
require_relative "alignment_constraint"
|
|
6
|
+
require_relative "layer_constraint"
|
|
7
|
+
require_relative "relative_position_constraint"
|
|
8
|
+
|
|
9
|
+
module Elkrb
|
|
10
|
+
module Layout
|
|
11
|
+
module Constraints
|
|
12
|
+
# Constraint processor
|
|
13
|
+
#
|
|
14
|
+
# Orchestrates the application and validation of all layout constraints.
|
|
15
|
+
# Constraints are applied in a specific order to handle dependencies:
|
|
16
|
+
# 1. Fixed position (locks nodes)
|
|
17
|
+
# 2. Layer constraints (assigns layers)
|
|
18
|
+
# 3. Relative position (depends on reference nodes)
|
|
19
|
+
# 4. Alignment (adjusts positions)
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# processor = ConstraintProcessor.new
|
|
23
|
+
# processor.apply_all(graph) # Apply before layout
|
|
24
|
+
# # ... layout algorithm runs ...
|
|
25
|
+
# processor.validate_all(graph) # Validate after layout
|
|
26
|
+
class ConstraintProcessor
|
|
27
|
+
# Pre-layout constraints (mark nodes for algorithm)
|
|
28
|
+
PRE_LAYOUT_CONSTRAINTS = [
|
|
29
|
+
FixedPositionConstraint,
|
|
30
|
+
LayerConstraint,
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# Post-layout constraints (enforce after algorithm runs)
|
|
34
|
+
POST_LAYOUT_CONSTRAINTS = [
|
|
35
|
+
RelativePositionConstraint,
|
|
36
|
+
AlignmentConstraint,
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
@pre_constraints = PRE_LAYOUT_CONSTRAINTS.map(&:new)
|
|
41
|
+
@post_constraints = POST_LAYOUT_CONSTRAINTS.map(&:new)
|
|
42
|
+
@all_constraints = (@pre_constraints + @post_constraints)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Apply pre-layout constraints
|
|
46
|
+
#
|
|
47
|
+
# These constraints mark nodes for special handling by algorithms.
|
|
48
|
+
#
|
|
49
|
+
# @param graph [Graph::Graph] The graph to constrain
|
|
50
|
+
# @return [Graph::Graph] The constrained graph
|
|
51
|
+
def apply_pre_layout(graph)
|
|
52
|
+
return graph unless has_constraints?(graph)
|
|
53
|
+
|
|
54
|
+
@pre_constraints.each do |constraint|
|
|
55
|
+
constraint.apply(graph)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
graph
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Enforce post-layout constraints
|
|
62
|
+
#
|
|
63
|
+
# These constraints adjust positions after layout completes.
|
|
64
|
+
# Also restores fixed positions that may have been moved.
|
|
65
|
+
#
|
|
66
|
+
# @param graph [Graph::Graph] The graph to constrain
|
|
67
|
+
# @return [Graph::Graph] The constrained graph
|
|
68
|
+
def enforce_post_layout(graph)
|
|
69
|
+
return graph unless has_constraints?(graph)
|
|
70
|
+
|
|
71
|
+
# First restore any fixed positions
|
|
72
|
+
fixed_constraint = @pre_constraints.find do |c|
|
|
73
|
+
c.is_a?(FixedPositionConstraint)
|
|
74
|
+
end
|
|
75
|
+
fixed_constraint&.restore_fixed_positions(graph)
|
|
76
|
+
|
|
77
|
+
# Then apply post-layout constraints
|
|
78
|
+
@post_constraints.each do |constraint|
|
|
79
|
+
constraint.apply(graph)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
graph
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Apply all constraints (legacy method)
|
|
86
|
+
#
|
|
87
|
+
# @deprecated Use apply_pre_layout and enforce_post_layout instead
|
|
88
|
+
# @param graph [Graph::Graph] The graph to constrain
|
|
89
|
+
# @return [Graph::Graph] The constrained graph
|
|
90
|
+
def apply_all(graph)
|
|
91
|
+
apply_pre_layout(graph)
|
|
92
|
+
enforce_post_layout(graph)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Validate all constraints after layout
|
|
96
|
+
#
|
|
97
|
+
# Checks that layout algorithm respected all constraints.
|
|
98
|
+
#
|
|
99
|
+
# @param graph [Graph::Graph] The graph to validate
|
|
100
|
+
# @return [Array<String>] List of validation errors
|
|
101
|
+
def validate_all(graph)
|
|
102
|
+
return [] unless has_constraints?(graph)
|
|
103
|
+
|
|
104
|
+
@all_constraints.flat_map do |constraint|
|
|
105
|
+
constraint.validate(graph)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Check if graph has any constraints
|
|
110
|
+
#
|
|
111
|
+
# @param graph [Graph::Graph] The graph to check
|
|
112
|
+
# @return [Boolean] True if any node has constraints
|
|
113
|
+
def has_constraints?(graph)
|
|
114
|
+
return false unless graph.children
|
|
115
|
+
|
|
116
|
+
graph.children.any? do |node|
|
|
117
|
+
has_constraints_recursive?(node)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# Check if node or its children have constraints
|
|
124
|
+
def has_constraints_recursive?(node)
|
|
125
|
+
return true if node.constraints
|
|
126
|
+
|
|
127
|
+
return false unless node.children
|
|
128
|
+
|
|
129
|
+
node.children.any? { |child| has_constraints_recursive?(child) }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_constraint"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Layout
|
|
7
|
+
module Constraints
|
|
8
|
+
# Fixed position constraint
|
|
9
|
+
#
|
|
10
|
+
# Prevents nodes from being moved by the layout algorithm.
|
|
11
|
+
# Nodes with fixed_position: true keep their existing x,y coordinates.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# node.x = 500
|
|
15
|
+
# node.y = 800
|
|
16
|
+
# node.constraints = NodeConstraints.new(fixed_position: true)
|
|
17
|
+
# # After layout, node remains at (500, 800)
|
|
18
|
+
class FixedPositionConstraint < BaseConstraint
|
|
19
|
+
# Apply fixed position constraint (pre-layout)
|
|
20
|
+
#
|
|
21
|
+
# Stores original positions of fixed nodes so they can be
|
|
22
|
+
# restored after layout.
|
|
23
|
+
#
|
|
24
|
+
# @param graph [Graph::Graph] The graph
|
|
25
|
+
# @return [Graph::Graph] The modified graph
|
|
26
|
+
def apply(graph)
|
|
27
|
+
all_nodes(graph).each do |node|
|
|
28
|
+
next unless node.constraints&.fixed_position
|
|
29
|
+
next if node.x.nil? || node.y.nil?
|
|
30
|
+
|
|
31
|
+
# Store original position
|
|
32
|
+
node.properties ||= {}
|
|
33
|
+
node.properties["_constraint_fixed"] = true
|
|
34
|
+
node.properties["_constraint_original_x"] = node.x
|
|
35
|
+
node.properties["_constraint_original_y"] = node.y
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
graph
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Restore fixed positions (called post-layout as well)
|
|
42
|
+
#
|
|
43
|
+
# This is called both pre and post layout to ensure fixed positions
|
|
44
|
+
# are preserved even if algorithms modify them.
|
|
45
|
+
#
|
|
46
|
+
# @param graph [Graph::Graph] The graph
|
|
47
|
+
# @return [Graph::Graph] The modified graph
|
|
48
|
+
def restore_fixed_positions(graph)
|
|
49
|
+
all_nodes(graph).each do |node|
|
|
50
|
+
next unless node.properties&.[]("_constraint_fixed")
|
|
51
|
+
|
|
52
|
+
# Restore original position
|
|
53
|
+
node.x = node.properties["_constraint_original_x"]
|
|
54
|
+
node.y = node.properties["_constraint_original_y"]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
graph
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Validate fixed positions were respected
|
|
61
|
+
#
|
|
62
|
+
# Checks that nodes marked as fixed didn't move during layout.
|
|
63
|
+
#
|
|
64
|
+
# @param graph [Graph::Graph] The graph to validate
|
|
65
|
+
# @return [Array<String>] List of validation errors
|
|
66
|
+
def validate(graph)
|
|
67
|
+
errors = []
|
|
68
|
+
|
|
69
|
+
all_nodes(graph).each do |node|
|
|
70
|
+
next unless node.properties&.[]("_constraint_fixed")
|
|
71
|
+
|
|
72
|
+
original_x = node.properties["_constraint_original_x"]
|
|
73
|
+
original_y = node.properties["_constraint_original_y"]
|
|
74
|
+
|
|
75
|
+
if node.x != original_x || node.y != original_y
|
|
76
|
+
errors << "Node '#{node.id}' has fixed_position constraint " \
|
|
77
|
+
"but was moved from (#{original_x}, #{original_y}) " \
|
|
78
|
+
"to (#{node.x}, #{node.y})"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
errors
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_constraint"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Layout
|
|
7
|
+
module Constraints
|
|
8
|
+
# Layer constraint
|
|
9
|
+
#
|
|
10
|
+
# Forces nodes into specific layers for layered (Sugiyama) algorithm.
|
|
11
|
+
# Primarily useful for hierarchical diagrams where certain nodes must
|
|
12
|
+
# appear in specific tiers.
|
|
13
|
+
#
|
|
14
|
+
# @example Three-tier architecture
|
|
15
|
+
# frontend.constraints = NodeConstraints.new(layer: 0) # Top
|
|
16
|
+
# backend.constraints = NodeConstraints.new(layer: 1) # Middle
|
|
17
|
+
# database.constraints = NodeConstraints.new(layer: 2) # Bottom
|
|
18
|
+
# # Enforces tier structure
|
|
19
|
+
class LayerConstraint < BaseConstraint
|
|
20
|
+
# Apply layer constraint
|
|
21
|
+
#
|
|
22
|
+
# Marks nodes with layer assignment that layered algorithm
|
|
23
|
+
# must respect.
|
|
24
|
+
#
|
|
25
|
+
# @param graph [Graph::Graph] The graph
|
|
26
|
+
# @return [Graph::Graph] The modified graph
|
|
27
|
+
def apply(graph)
|
|
28
|
+
all_nodes(graph).each do |node|
|
|
29
|
+
next unless node.constraints&.layer
|
|
30
|
+
|
|
31
|
+
# Mark node with its required layer
|
|
32
|
+
node.properties ||= {}
|
|
33
|
+
node.properties["_constraint_layer"] = node.constraints.layer
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
graph
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Validate layer constraints
|
|
40
|
+
#
|
|
41
|
+
# Checks that nodes assigned to layers are in correct layers.
|
|
42
|
+
# Note: This validation only applies if the layered algorithm
|
|
43
|
+
# was used and layer information is available.
|
|
44
|
+
#
|
|
45
|
+
# @param graph [Graph::Graph] The graph to validate
|
|
46
|
+
# @return [Array<String>] List of validation errors
|
|
47
|
+
def validate(graph)
|
|
48
|
+
errors = []
|
|
49
|
+
|
|
50
|
+
# Check if layer information is available
|
|
51
|
+
# (only present if layered algorithm was used)
|
|
52
|
+
all_nodes(graph).each do |node|
|
|
53
|
+
next unless node.constraints&.layer
|
|
54
|
+
next unless node.properties&.[]("_assigned_layer")
|
|
55
|
+
|
|
56
|
+
expected_layer = node.constraints.layer
|
|
57
|
+
actual_layer = node.properties["_assigned_layer"]
|
|
58
|
+
|
|
59
|
+
if expected_layer != actual_layer
|
|
60
|
+
errors << "Node '#{node.id}' constrained to layer " \
|
|
61
|
+
"#{expected_layer} but assigned to layer " \
|
|
62
|
+
"#{actual_layer}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
errors
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|