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.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +11 -0
  4. data/Gemfile +13 -0
  5. data/README.adoc +1028 -0
  6. data/Rakefile +64 -0
  7. data/benchmarks/README.md +172 -0
  8. data/benchmarks/elkjs_benchmark.js +140 -0
  9. data/benchmarks/elkrb_benchmark.rb +145 -0
  10. data/benchmarks/fixtures/graphs.json +10777 -0
  11. data/benchmarks/generate_report.rb +241 -0
  12. data/benchmarks/generate_test_graphs.rb +154 -0
  13. data/benchmarks/results/elkrb_results.json +280 -0
  14. data/benchmarks/results/elkrb_summary.json +285 -0
  15. data/elkrb.gemspec +39 -0
  16. data/examples/dot_export_demo.rb +133 -0
  17. data/examples/hierarchical_graph.rb +19 -0
  18. data/examples/layout_constraints_demo.rb +272 -0
  19. data/examples/port_constraints_demo.rb +291 -0
  20. data/examples/self_loop_demo.rb +391 -0
  21. data/examples/simple_graph.rb +50 -0
  22. data/examples/spline_routing_demo.rb +235 -0
  23. data/exe/elkrb +8 -0
  24. data/lib/elkrb/cli.rb +224 -0
  25. data/lib/elkrb/commands/batch_command.rb +66 -0
  26. data/lib/elkrb/commands/convert_command.rb +130 -0
  27. data/lib/elkrb/commands/diagram_command.rb +208 -0
  28. data/lib/elkrb/commands/render_command.rb +52 -0
  29. data/lib/elkrb/commands/validate_command.rb +241 -0
  30. data/lib/elkrb/errors.rb +30 -0
  31. data/lib/elkrb/geometry/bezier.rb +163 -0
  32. data/lib/elkrb/geometry/dimension.rb +32 -0
  33. data/lib/elkrb/geometry/point.rb +68 -0
  34. data/lib/elkrb/geometry/rectangle.rb +86 -0
  35. data/lib/elkrb/geometry/vector.rb +67 -0
  36. data/lib/elkrb/graph/edge.rb +95 -0
  37. data/lib/elkrb/graph/graph.rb +90 -0
  38. data/lib/elkrb/graph/label.rb +45 -0
  39. data/lib/elkrb/graph/layout_options.rb +247 -0
  40. data/lib/elkrb/graph/node.rb +79 -0
  41. data/lib/elkrb/graph/node_constraints.rb +107 -0
  42. data/lib/elkrb/graph/port.rb +104 -0
  43. data/lib/elkrb/graphviz_wrapper.rb +133 -0
  44. data/lib/elkrb/layout/algorithm_registry.rb +57 -0
  45. data/lib/elkrb/layout/algorithms/base_algorithm.rb +208 -0
  46. data/lib/elkrb/layout/algorithms/box.rb +47 -0
  47. data/lib/elkrb/layout/algorithms/disco.rb +206 -0
  48. data/lib/elkrb/layout/algorithms/fixed.rb +32 -0
  49. data/lib/elkrb/layout/algorithms/force.rb +165 -0
  50. data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +86 -0
  51. data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +96 -0
  52. data/lib/elkrb/layout/algorithms/layered/node_placer.rb +77 -0
  53. data/lib/elkrb/layout/algorithms/layered.rb +49 -0
  54. data/lib/elkrb/layout/algorithms/libavoid.rb +389 -0
  55. data/lib/elkrb/layout/algorithms/mrtree.rb +144 -0
  56. data/lib/elkrb/layout/algorithms/radial.rb +64 -0
  57. data/lib/elkrb/layout/algorithms/random.rb +43 -0
  58. data/lib/elkrb/layout/algorithms/rectpacking.rb +93 -0
  59. data/lib/elkrb/layout/algorithms/spore_compaction.rb +139 -0
  60. data/lib/elkrb/layout/algorithms/spore_overlap.rb +117 -0
  61. data/lib/elkrb/layout/algorithms/stress.rb +176 -0
  62. data/lib/elkrb/layout/algorithms/topdown_packing.rb +183 -0
  63. data/lib/elkrb/layout/algorithms/vertiflex.rb +174 -0
  64. data/lib/elkrb/layout/constraints/alignment_constraint.rb +150 -0
  65. data/lib/elkrb/layout/constraints/base_constraint.rb +72 -0
  66. data/lib/elkrb/layout/constraints/constraint_processor.rb +134 -0
  67. data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +87 -0
  68. data/lib/elkrb/layout/constraints/layer_constraint.rb +71 -0
  69. data/lib/elkrb/layout/constraints/relative_position_constraint.rb +110 -0
  70. data/lib/elkrb/layout/edge_router.rb +935 -0
  71. data/lib/elkrb/layout/hierarchical_processor.rb +299 -0
  72. data/lib/elkrb/layout/label_placer.rb +338 -0
  73. data/lib/elkrb/layout/layout_engine.rb +170 -0
  74. data/lib/elkrb/layout/port_constraint_processor.rb +173 -0
  75. data/lib/elkrb/options/elk_padding.rb +94 -0
  76. data/lib/elkrb/options/k_vector.rb +100 -0
  77. data/lib/elkrb/options/k_vector_chain.rb +135 -0
  78. data/lib/elkrb/parsers/elkt_parser.rb +248 -0
  79. data/lib/elkrb/serializers/dot_serializer.rb +339 -0
  80. data/lib/elkrb/serializers/elkt_serializer.rb +236 -0
  81. data/lib/elkrb/version.rb +5 -0
  82. data/lib/elkrb.rb +509 -0
  83. data/sig/elkrb/constraints.rbs +114 -0
  84. data/sig/elkrb/geometry.rbs +61 -0
  85. data/sig/elkrb/graph.rbs +112 -0
  86. data/sig/elkrb/layout.rbs +107 -0
  87. data/sig/elkrb/options.rbs +81 -0
  88. data/sig/elkrb.rbs +32 -0
  89. 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