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,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Graph
|
|
7
|
+
class Port < Lutaml::Model::Serializable
|
|
8
|
+
attribute :id, :string
|
|
9
|
+
attribute :x, :float
|
|
10
|
+
attribute :y, :float
|
|
11
|
+
attribute :width, :float
|
|
12
|
+
attribute :height, :float
|
|
13
|
+
attribute :labels, Label, collection: true
|
|
14
|
+
attribute :layout_options, LayoutOptions
|
|
15
|
+
attribute :properties, :hash
|
|
16
|
+
attribute :side, :string, default: -> { "UNDEFINED" }
|
|
17
|
+
attribute :index, :integer, default: -> { -1 }
|
|
18
|
+
attribute :offset, :float, default: -> { 0.0 }
|
|
19
|
+
|
|
20
|
+
# Port sides
|
|
21
|
+
NORTH = "NORTH"
|
|
22
|
+
SOUTH = "SOUTH"
|
|
23
|
+
EAST = "EAST"
|
|
24
|
+
WEST = "WEST"
|
|
25
|
+
UNDEFINED = "UNDEFINED"
|
|
26
|
+
|
|
27
|
+
SIDES = [NORTH, SOUTH, EAST, WEST, UNDEFINED].freeze
|
|
28
|
+
|
|
29
|
+
# Node reference (not serialized)
|
|
30
|
+
attr_accessor :node
|
|
31
|
+
|
|
32
|
+
json do
|
|
33
|
+
map "id", to: :id
|
|
34
|
+
map "x", to: :x
|
|
35
|
+
map "y", to: :y
|
|
36
|
+
map "width", to: :width
|
|
37
|
+
map "height", to: :height
|
|
38
|
+
map "labels", to: :labels
|
|
39
|
+
map "layoutOptions", to: :layout_options
|
|
40
|
+
map "properties", to: :properties
|
|
41
|
+
map "side", to: :side
|
|
42
|
+
map "index", to: :index
|
|
43
|
+
map "offset", to: :offset
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
yaml do
|
|
47
|
+
map "id", to: :id
|
|
48
|
+
map "x", to: :x
|
|
49
|
+
map "y", to: :y
|
|
50
|
+
map "width", to: :width
|
|
51
|
+
map "height", to: :height
|
|
52
|
+
map "labels", to: :labels
|
|
53
|
+
map "layout_options", to: :layout_options
|
|
54
|
+
map "properties", to: :properties
|
|
55
|
+
map "side", to: :side
|
|
56
|
+
map "index", to: :index
|
|
57
|
+
map "offset", to: :offset
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Validate and set port side
|
|
61
|
+
#
|
|
62
|
+
# @param value [String] The port side (NORTH, SOUTH, EAST, WEST, UNDEFINED)
|
|
63
|
+
# @raise [ArgumentError] If the side value is invalid
|
|
64
|
+
def side=(value)
|
|
65
|
+
return if value.nil?
|
|
66
|
+
|
|
67
|
+
normalized = value.to_s.upcase
|
|
68
|
+
unless SIDES.include?(normalized)
|
|
69
|
+
raise ArgumentError,
|
|
70
|
+
"Invalid port side: #{value}. Must be one of #{SIDES.join(', ')}"
|
|
71
|
+
end
|
|
72
|
+
@side = normalized
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Detect port side from position relative to node
|
|
76
|
+
#
|
|
77
|
+
# This method analyzes the port's position (x, y) relative to the node's
|
|
78
|
+
# dimensions to determine which side of the node the port is closest to.
|
|
79
|
+
#
|
|
80
|
+
# @param node_width [Float] Width of the parent node
|
|
81
|
+
# @param node_height [Float] Height of the parent node
|
|
82
|
+
# @return [String] The detected side (NORTH, SOUTH, EAST, WEST, UNDEFINED)
|
|
83
|
+
def detect_side(node_width, node_height)
|
|
84
|
+
return UNDEFINED if x.nil? || y.nil? || node_width.nil? || node_height.nil?
|
|
85
|
+
return UNDEFINED if node_width <= 0 || node_height <= 0
|
|
86
|
+
|
|
87
|
+
# Calculate relative position (0.0 to 1.0)
|
|
88
|
+
rel_x = x / node_width.to_f
|
|
89
|
+
rel_y = y / node_height.to_f
|
|
90
|
+
|
|
91
|
+
# Calculate distance to each side
|
|
92
|
+
distances = {
|
|
93
|
+
NORTH => rel_y, # Distance from top
|
|
94
|
+
SOUTH => 1.0 - rel_y, # Distance from bottom
|
|
95
|
+
WEST => rel_x, # Distance from left
|
|
96
|
+
EAST => 1.0 - rel_x, # Distance from right
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Return the side with minimum distance
|
|
100
|
+
distances.min_by { |_, dist| dist }.first
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
# Wrapper for optional Graphviz integration
|
|
5
|
+
# Provides graceful degradation when Graphviz is not installed
|
|
6
|
+
class GraphvizWrapper
|
|
7
|
+
class GraphvizNotFoundError < StandardError; end
|
|
8
|
+
|
|
9
|
+
SUPPORTED_FORMATS = %i[png svg pdf ps eps].freeze
|
|
10
|
+
SUPPORTED_ENGINES = %w[dot neato fdp sfdp twopi circo].freeze
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@dot_path = find_graphviz
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def available?
|
|
17
|
+
!@dot_path.nil?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def render(dot_file, output_file, format, options = {})
|
|
21
|
+
raise GraphvizNotFoundError, installation_message unless available?
|
|
22
|
+
|
|
23
|
+
validate_format!(format)
|
|
24
|
+
validate_file_exists!(dot_file)
|
|
25
|
+
|
|
26
|
+
engine = options[:engine] || "dot"
|
|
27
|
+
validate_engine!(engine)
|
|
28
|
+
|
|
29
|
+
dpi = options[:dpi] || 96
|
|
30
|
+
|
|
31
|
+
cmd = build_command(engine, format, dot_file, output_file, dpi)
|
|
32
|
+
execute_command(cmd)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def version
|
|
36
|
+
return nil unless available?
|
|
37
|
+
|
|
38
|
+
output = `#{@dot_path} -V 2>&1`
|
|
39
|
+
output.match(/version\s+([\d.]+)/i)&.captures&.first
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def supported_formats
|
|
43
|
+
SUPPORTED_FORMATS
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def supported_engines
|
|
47
|
+
SUPPORTED_ENGINES
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def find_graphviz
|
|
53
|
+
# Try common locations
|
|
54
|
+
candidates = [
|
|
55
|
+
"dot",
|
|
56
|
+
"/usr/bin/dot",
|
|
57
|
+
"/usr/local/bin/dot",
|
|
58
|
+
"/opt/homebrew/bin/dot",
|
|
59
|
+
"/opt/local/bin/dot",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
candidates.each do |path|
|
|
63
|
+
if File.executable?(path)
|
|
64
|
+
return path
|
|
65
|
+
elsif system("which #{path} > /dev/null 2>&1")
|
|
66
|
+
return path
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def build_command(engine, format, input_file, output_file, dpi)
|
|
74
|
+
cmd_parts = [
|
|
75
|
+
@dot_path,
|
|
76
|
+
"-K#{engine}",
|
|
77
|
+
"-T#{format}",
|
|
78
|
+
"-Gdpi=#{dpi}",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
cmd_parts << "-o#{output_file}" if output_file
|
|
82
|
+
cmd_parts << input_file
|
|
83
|
+
|
|
84
|
+
cmd_parts.join(" ")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def execute_command(cmd)
|
|
88
|
+
success = system(cmd)
|
|
89
|
+
unless success
|
|
90
|
+
raise GraphvizNotFoundError,
|
|
91
|
+
"Graphviz command failed: #{cmd}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
success
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_format!(format)
|
|
98
|
+
format_sym = format.to_sym
|
|
99
|
+
return if SUPPORTED_FORMATS.include?(format_sym)
|
|
100
|
+
|
|
101
|
+
raise ArgumentError, "Unsupported format: #{format}. " \
|
|
102
|
+
"Supported formats: #{SUPPORTED_FORMATS.join(', ')}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_engine!(engine)
|
|
106
|
+
return if SUPPORTED_ENGINES.include?(engine.to_s)
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "Unsupported engine: #{engine}. " \
|
|
109
|
+
"Supported engines: #{SUPPORTED_ENGINES.join(', ')}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def validate_file_exists!(file)
|
|
113
|
+
return if File.exist?(file)
|
|
114
|
+
|
|
115
|
+
raise ArgumentError, "Input file not found: #{file}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def installation_message
|
|
119
|
+
<<~MSG
|
|
120
|
+
Graphviz is required but not found.
|
|
121
|
+
|
|
122
|
+
Installation instructions:
|
|
123
|
+
macOS: brew install graphviz
|
|
124
|
+
Ubuntu: sudo apt-get install graphviz
|
|
125
|
+
Fedora: sudo dnf install graphviz
|
|
126
|
+
Windows: https://graphviz.org/download/
|
|
127
|
+
|
|
128
|
+
Alternatively, export to DOT format and render manually:
|
|
129
|
+
elkrb diagram input.json -o output.dot
|
|
130
|
+
MSG
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
class AlgorithmRegistry
|
|
6
|
+
@algorithms = {}
|
|
7
|
+
@metadata = {}
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def register(name, algorithm_class, metadata = {})
|
|
11
|
+
name_str = name.to_s
|
|
12
|
+
@algorithms[name_str] = algorithm_class
|
|
13
|
+
@metadata[name_str] = metadata
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def get(name)
|
|
17
|
+
algorithm_name = normalize_name(name)
|
|
18
|
+
@algorithms[algorithm_name]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def available_algorithms
|
|
22
|
+
@algorithms.keys.sort
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def algorithm_info(name)
|
|
26
|
+
algorithm_class = get(name)
|
|
27
|
+
return nil unless algorithm_class
|
|
28
|
+
|
|
29
|
+
name_str = normalize_name(name)
|
|
30
|
+
metadata = @metadata[name_str] || {}
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
id: name_str,
|
|
34
|
+
name: metadata[:name] || name_str.capitalize,
|
|
35
|
+
description: metadata[:description] || "",
|
|
36
|
+
category: metadata[:category] || "general",
|
|
37
|
+
supports_hierarchy: metadata[:supports_hierarchy] || false,
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def all_algorithm_info
|
|
42
|
+
available_algorithms.map { |name| algorithm_info(name) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def normalize_name(name)
|
|
48
|
+
# Support both full names and short names
|
|
49
|
+
# e.g., "org.eclipse.elk.layered" -> "layered"
|
|
50
|
+
name = name.to_s
|
|
51
|
+
name = name.split(".").last if name.include?(".")
|
|
52
|
+
name.downcase
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../edge_router"
|
|
4
|
+
require_relative "../hierarchical_processor"
|
|
5
|
+
require_relative "../label_placer"
|
|
6
|
+
require_relative "../port_constraint_processor"
|
|
7
|
+
require_relative "../constraints/constraint_processor"
|
|
8
|
+
|
|
9
|
+
module Elkrb
|
|
10
|
+
module Layout
|
|
11
|
+
module Algorithms
|
|
12
|
+
# Base class for all layout algorithms
|
|
13
|
+
#
|
|
14
|
+
# Layout algorithms are responsible for computing positions for nodes
|
|
15
|
+
# and routing paths for edges in a graph. Each algorithm implements
|
|
16
|
+
# a specific layout strategy (e.g., hierarchical, force-directed, etc.)
|
|
17
|
+
class BaseAlgorithm
|
|
18
|
+
include EdgeRouter
|
|
19
|
+
include HierarchicalProcessor
|
|
20
|
+
include LabelPlacer
|
|
21
|
+
include PortConstraintProcessor
|
|
22
|
+
|
|
23
|
+
attr_reader :options
|
|
24
|
+
|
|
25
|
+
def initialize(options = {})
|
|
26
|
+
@options = options
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Main layout method - automatically handles hierarchical graphs and labels
|
|
30
|
+
#
|
|
31
|
+
# Subclasses should implement #layout_flat for their specific
|
|
32
|
+
# algorithm logic. This method will automatically handle hierarchical
|
|
33
|
+
# graphs by calling layout_hierarchical when needed, and place labels
|
|
34
|
+
# after layout is complete.
|
|
35
|
+
#
|
|
36
|
+
# @param graph [Elkrb::Graph::Graph] The graph to layout
|
|
37
|
+
# @return [Elkrb::Graph::Graph] The graph with updated positions
|
|
38
|
+
def layout(graph)
|
|
39
|
+
# Apply port constraints before layout
|
|
40
|
+
apply_port_constraints(graph)
|
|
41
|
+
|
|
42
|
+
# Apply pre-layout constraints (marks nodes)
|
|
43
|
+
apply_pre_layout_constraints(graph)
|
|
44
|
+
|
|
45
|
+
# Perform layout
|
|
46
|
+
if option("hierarchical", false) || graph.hierarchical?
|
|
47
|
+
layout_hierarchical(graph, @options)
|
|
48
|
+
else
|
|
49
|
+
layout_flat(graph, @options)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Enforce post-layout constraints (adjust positions)
|
|
53
|
+
enforce_post_layout_constraints(graph)
|
|
54
|
+
|
|
55
|
+
# Apply edge routing
|
|
56
|
+
apply_edge_routing(graph)
|
|
57
|
+
|
|
58
|
+
# Place labels after layout (unless disabled)
|
|
59
|
+
unless option("label.placement.disabled", false)
|
|
60
|
+
place_labels(graph)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
graph
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Layout a flat (non-hierarchical) graph
|
|
67
|
+
#
|
|
68
|
+
# This method must be implemented by subclasses with their specific
|
|
69
|
+
# layout algorithm logic.
|
|
70
|
+
#
|
|
71
|
+
# @param graph [Elkrb::Graph::Graph] The graph to layout
|
|
72
|
+
# @param options [Hash] Layout options
|
|
73
|
+
# @return [Elkrb::Graph::Graph] The graph with updated positions
|
|
74
|
+
def layout_flat(graph, options = {})
|
|
75
|
+
raise NotImplementedError,
|
|
76
|
+
"#{self.class.name} must implement #layout_flat method"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
protected
|
|
80
|
+
|
|
81
|
+
# Get an option value with a default fallback
|
|
82
|
+
#
|
|
83
|
+
# @param key [String, Symbol] The option key
|
|
84
|
+
# @param default [Object] The default value if option is not set
|
|
85
|
+
# @return [Object] The option value or default
|
|
86
|
+
def option(key, default = nil)
|
|
87
|
+
key_str = key.to_s
|
|
88
|
+
@options[key_str] || @options[key.to_sym] || default
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Get spacing between nodes
|
|
92
|
+
#
|
|
93
|
+
# @return [Float] The node spacing value
|
|
94
|
+
def node_spacing
|
|
95
|
+
option("spacing_node_node", 20.0).to_f
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get padding values
|
|
99
|
+
#
|
|
100
|
+
# @return [Hash] Padding values for top, bottom, left, right
|
|
101
|
+
def padding
|
|
102
|
+
default_padding = { top: 12, bottom: 12, left: 12, right: 12 }
|
|
103
|
+
padding_opt = option("padding", default_padding)
|
|
104
|
+
|
|
105
|
+
if padding_opt.is_a?(Hash)
|
|
106
|
+
default_padding.merge(padding_opt)
|
|
107
|
+
else
|
|
108
|
+
default_padding
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Calculate the bounding box for a set of nodes
|
|
113
|
+
#
|
|
114
|
+
# @param nodes [Array<Elkrb::Graph::Node>] The nodes
|
|
115
|
+
# @return [Elkrb::Geometry::Rectangle] The bounding rectangle
|
|
116
|
+
def calculate_bounding_box(nodes)
|
|
117
|
+
return Elkrb::Geometry::Rectangle.new(0, 0, 0, 0) if nodes.empty?
|
|
118
|
+
|
|
119
|
+
min_x = nodes.map(&:x).min
|
|
120
|
+
min_y = nodes.map(&:y).min
|
|
121
|
+
max_x = nodes.map { |n| n.x + n.width }.max
|
|
122
|
+
max_y = nodes.map { |n| n.y + n.height }.max
|
|
123
|
+
|
|
124
|
+
Elkrb::Geometry::Rectangle.new(
|
|
125
|
+
min_x,
|
|
126
|
+
min_y,
|
|
127
|
+
max_x - min_x,
|
|
128
|
+
max_y - min_y,
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Apply padding to graph dimensions
|
|
133
|
+
#
|
|
134
|
+
# @param graph [Elkrb::Graph::Graph] The graph
|
|
135
|
+
def apply_padding(graph)
|
|
136
|
+
return if graph.children.nil? || graph.children.empty?
|
|
137
|
+
|
|
138
|
+
pad = padding
|
|
139
|
+
bbox = calculate_bounding_box(graph.children)
|
|
140
|
+
|
|
141
|
+
# Shift all nodes by padding
|
|
142
|
+
graph.children.each do |node|
|
|
143
|
+
node.x = node.x - bbox.x + pad[:left]
|
|
144
|
+
node.y = node.y - bbox.y + pad[:top]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Set graph dimensions
|
|
148
|
+
graph.width = bbox.width + pad[:left] + pad[:right]
|
|
149
|
+
graph.height = bbox.height + pad[:top] + pad[:bottom]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Apply edge routing based on routing style option
|
|
153
|
+
#
|
|
154
|
+
# @param graph [Elkrb::Graph::Graph] The graph
|
|
155
|
+
def apply_edge_routing(graph)
|
|
156
|
+
routing_style = get_edge_routing_style(graph)
|
|
157
|
+
route_edges(graph, nil, routing_style)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Get edge routing style from graph options
|
|
161
|
+
#
|
|
162
|
+
# @param graph [Elkrb::Graph::Graph] The graph
|
|
163
|
+
# @return [String] Routing style (ORTHOGONAL, POLYLINE, SPLINES)
|
|
164
|
+
def get_edge_routing_style(graph)
|
|
165
|
+
return "ORTHOGONAL" unless graph.layout_options
|
|
166
|
+
|
|
167
|
+
style = graph.layout_options["elk.edgeRouting"] ||
|
|
168
|
+
graph.layout_options["edgeRouting"] ||
|
|
169
|
+
graph.layout_options.edge_routing ||
|
|
170
|
+
option("elk.edgeRouting") ||
|
|
171
|
+
option("edgeRouting")
|
|
172
|
+
|
|
173
|
+
style ? style.to_s.upcase : "ORTHOGONAL"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Apply pre-layout constraints
|
|
177
|
+
#
|
|
178
|
+
# These constraints mark nodes for special algorithm handling.
|
|
179
|
+
#
|
|
180
|
+
# @param graph [Elkrb::Graph::Graph] The graph
|
|
181
|
+
def apply_pre_layout_constraints(graph)
|
|
182
|
+
processor = Constraints::ConstraintProcessor.new
|
|
183
|
+
processor.apply_pre_layout(graph)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Enforce post-layout constraints
|
|
187
|
+
#
|
|
188
|
+
# These constraints adjust positions after layout algorithm runs.
|
|
189
|
+
#
|
|
190
|
+
# @param graph [Elkrb::Graph::Graph] The graph
|
|
191
|
+
def enforce_post_layout_constraints(graph)
|
|
192
|
+
processor = Constraints::ConstraintProcessor.new
|
|
193
|
+
processor.enforce_post_layout(graph)
|
|
194
|
+
|
|
195
|
+
# Validate all constraints
|
|
196
|
+
errors = processor.validate_all(graph)
|
|
197
|
+
|
|
198
|
+
return if errors.empty?
|
|
199
|
+
|
|
200
|
+
# Log warnings for constraint violations
|
|
201
|
+
errors.each do |error|
|
|
202
|
+
warn "Layout constraint violation: #{error}"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_algorithm"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Layout
|
|
7
|
+
module Algorithms
|
|
8
|
+
# Box layout algorithm
|
|
9
|
+
#
|
|
10
|
+
# Arranges nodes in a simple box/grid pattern. Nodes are placed
|
|
11
|
+
# in rows from left to right, top to bottom, with uniform spacing.
|
|
12
|
+
# Useful for simple diagrams and quick visualization.
|
|
13
|
+
class Box < BaseAlgorithm
|
|
14
|
+
def layout_flat(graph, _options = {})
|
|
15
|
+
return graph if graph.children.nil? || graph.children.empty?
|
|
16
|
+
|
|
17
|
+
# Get configuration
|
|
18
|
+
aspect_ratio = option("aspect_ratio", 1.6).to_f
|
|
19
|
+
spacing = node_spacing
|
|
20
|
+
|
|
21
|
+
# Calculate number of columns based on aspect ratio
|
|
22
|
+
num_nodes = graph.children.length
|
|
23
|
+
cols = Math.sqrt(num_nodes * aspect_ratio).ceil
|
|
24
|
+
cols = [cols, 1].max
|
|
25
|
+
|
|
26
|
+
# Find maximum node dimensions for uniform grid
|
|
27
|
+
max_width = graph.children.map(&:width).max
|
|
28
|
+
max_height = graph.children.map(&:height).max
|
|
29
|
+
|
|
30
|
+
# Position nodes in grid
|
|
31
|
+
graph.children.each_with_index do |node, i|
|
|
32
|
+
row = i / cols
|
|
33
|
+
col = i % cols
|
|
34
|
+
|
|
35
|
+
node.x = col * (max_width + spacing)
|
|
36
|
+
node.y = row * (max_height + spacing)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Apply padding and set graph dimensions
|
|
40
|
+
apply_padding(graph)
|
|
41
|
+
|
|
42
|
+
graph
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|