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,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "algorithm_registry"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Layout
|
|
7
|
+
# Main entry point for graph layout operations.
|
|
8
|
+
#
|
|
9
|
+
# The LayoutEngine provides a high-level interface for applying layout
|
|
10
|
+
# algorithms to graphs. It handles algorithm selection, graph conversion,
|
|
11
|
+
# and delegates to the appropriate algorithm implementation.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage with hash input
|
|
14
|
+
# graph = {
|
|
15
|
+
# id: "root",
|
|
16
|
+
# layoutOptions: { "elk.algorithm" => "layered" },
|
|
17
|
+
# children: [
|
|
18
|
+
# { id: "n1", width: 100, height: 60 },
|
|
19
|
+
# { id: "n2", width: 100, height: 60 }
|
|
20
|
+
# ],
|
|
21
|
+
# edges: [
|
|
22
|
+
# { id: "e1", sources: ["n1"], targets: ["n2"] }
|
|
23
|
+
# ]
|
|
24
|
+
# }
|
|
25
|
+
# result = Elkrb::Layout::LayoutEngine.layout(graph)
|
|
26
|
+
#
|
|
27
|
+
# @example Using Graph model objects
|
|
28
|
+
# graph = Elkrb::Graph::Graph.new(id: "root")
|
|
29
|
+
# node1 = Elkrb::Graph::Node.new(id: "n1", width: 100, height: 60)
|
|
30
|
+
# node2 = Elkrb::Graph::Node.new(id: "n2", width: 100, height: 60)
|
|
31
|
+
# graph.children = [node1, node2]
|
|
32
|
+
# result = Elkrb::Layout::LayoutEngine.layout(graph, algorithm: "force")
|
|
33
|
+
#
|
|
34
|
+
# @example Querying available algorithms
|
|
35
|
+
# algorithms = Elkrb::Layout::LayoutEngine.known_layout_algorithms
|
|
36
|
+
# algorithms.each do |name, info|
|
|
37
|
+
# puts "#{name}: #{info[:description]}"
|
|
38
|
+
# end
|
|
39
|
+
class LayoutEngine
|
|
40
|
+
class << self
|
|
41
|
+
# Applies a layout algorithm to a graph.
|
|
42
|
+
#
|
|
43
|
+
# This method is the primary entry point for graph layout. It accepts
|
|
44
|
+
# either a Hash representation or a Graph model object, selects the
|
|
45
|
+
# appropriate algorithm based on options, and computes node positions
|
|
46
|
+
# and edge routes.
|
|
47
|
+
#
|
|
48
|
+
# The algorithm is selected in this order:
|
|
49
|
+
# 1. options[:algorithm] or options["algorithm"]
|
|
50
|
+
# 2. graph.layoutOptions["elk.algorithm"]
|
|
51
|
+
# 3. Default: "layered"
|
|
52
|
+
#
|
|
53
|
+
# @param graph [Hash, Elkrb::Graph::Graph] The graph to layout. Can be:
|
|
54
|
+
# - A Hash with keys: :id, :children, :edges, :layoutOptions
|
|
55
|
+
# - A Graph model object
|
|
56
|
+
# @param options [Hash] Layout options including:
|
|
57
|
+
# - :algorithm (String) - Algorithm name (layered, force, etc.)
|
|
58
|
+
# - Algorithm-specific options
|
|
59
|
+
# @return [Elkrb::Graph::Graph] The input graph with computed positions
|
|
60
|
+
# @raise [Elkrb::Error] If the specified algorithm is not found
|
|
61
|
+
#
|
|
62
|
+
# @example With specific algorithm
|
|
63
|
+
# result = Elkrb::Layout::LayoutEngine.layout(
|
|
64
|
+
# graph,
|
|
65
|
+
# algorithm: "force",
|
|
66
|
+
# "elk.force.repulsion" => 5.0
|
|
67
|
+
# )
|
|
68
|
+
#
|
|
69
|
+
# @example With hierarchical layout
|
|
70
|
+
# result = Elkrb::Layout::LayoutEngine.layout(
|
|
71
|
+
# graph,
|
|
72
|
+
# algorithm: "layered",
|
|
73
|
+
# hierarchical: true
|
|
74
|
+
# )
|
|
75
|
+
def layout(graph, options = {})
|
|
76
|
+
# Convert hash to Graph if needed
|
|
77
|
+
graph = convert_to_graph(graph) if graph.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
# Get algorithm name from options
|
|
80
|
+
algorithm_name = options[:algorithm] ||
|
|
81
|
+
options["algorithm"] ||
|
|
82
|
+
"layered"
|
|
83
|
+
|
|
84
|
+
algorithm_class = AlgorithmRegistry.get(algorithm_name)
|
|
85
|
+
|
|
86
|
+
raise Error, "Unknown layout algorithm: #{algorithm_name}" unless
|
|
87
|
+
algorithm_class
|
|
88
|
+
|
|
89
|
+
# Create and run algorithm with options
|
|
90
|
+
algorithm = algorithm_class.new(options)
|
|
91
|
+
algorithm.layout(graph)
|
|
92
|
+
|
|
93
|
+
graph
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Returns metadata for all registered layout algorithms.
|
|
97
|
+
#
|
|
98
|
+
# This method provides information about each available algorithm
|
|
99
|
+
# including its name, description, category, and capabilities.
|
|
100
|
+
#
|
|
101
|
+
# @return [Hash{String => Hash}] A hash mapping algorithm names to their metadata.
|
|
102
|
+
# Each metadata hash contains:
|
|
103
|
+
# - :name (String) - Display name of the algorithm
|
|
104
|
+
# - :description (String) - Brief description
|
|
105
|
+
# - :category (String, nil) - Algorithm category (e.g., "hierarchical", "force")
|
|
106
|
+
# - :supports_hierarchy (Boolean, nil) - Whether it supports hierarchical graphs
|
|
107
|
+
#
|
|
108
|
+
# @example List all algorithms
|
|
109
|
+
# algorithms = Elkrb::Layout::LayoutEngine.known_layout_algorithms
|
|
110
|
+
# algorithms.each do |name, info|
|
|
111
|
+
# puts "#{name}: #{info[:description]}"
|
|
112
|
+
# end
|
|
113
|
+
# # Output:
|
|
114
|
+
# # layered: Hierarchical layout using the Sugiyama framework
|
|
115
|
+
# # force: Physics-based layout using attractive and repulsive forces
|
|
116
|
+
# # ...
|
|
117
|
+
#
|
|
118
|
+
# @example Filter by category
|
|
119
|
+
# force_algs = Elkrb::Layout::LayoutEngine.known_layout_algorithms
|
|
120
|
+
# .select { |_, info| info[:category] == "force" }
|
|
121
|
+
def known_layout_algorithms
|
|
122
|
+
AlgorithmRegistry.all_algorithm_info
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Exports a graph to Graphviz DOT format.
|
|
126
|
+
#
|
|
127
|
+
# This method serializes an ELK graph structure to DOT format,
|
|
128
|
+
# which can be rendered by Graphviz or other DOT-compatible tools.
|
|
129
|
+
#
|
|
130
|
+
# @param graph [Hash, Elkrb::Graph::Graph] The graph to export
|
|
131
|
+
# @param options [Hash] Serialization options (see DotSerializer)
|
|
132
|
+
# @return [String] DOT format string
|
|
133
|
+
#
|
|
134
|
+
# @example Basic export
|
|
135
|
+
# dot = Elkrb::Layout::LayoutEngine.export_dot(graph)
|
|
136
|
+
# File.write("output.dot", dot)
|
|
137
|
+
#
|
|
138
|
+
# @example With custom options
|
|
139
|
+
# dot = Elkrb::Layout::LayoutEngine.export_dot(
|
|
140
|
+
# graph,
|
|
141
|
+
# directed: true,
|
|
142
|
+
# rankdir: "LR",
|
|
143
|
+
# graph_name: "MyGraph"
|
|
144
|
+
# )
|
|
145
|
+
def export_dot(graph, options = {})
|
|
146
|
+
# Convert hash to Graph if needed
|
|
147
|
+
graph = convert_to_graph(graph) if graph.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
serializer = Elkrb::Serializers::DotSerializer.new
|
|
150
|
+
serializer.serialize(graph, options)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Returns metadata for all supported layout options.
|
|
154
|
+
#
|
|
155
|
+
# @return [Array<Hash>] Array of option metadata
|
|
156
|
+
# @note Currently returns an empty array. Full implementation planned.
|
|
157
|
+
def known_layout_options
|
|
158
|
+
# TODO: Build from all algorithms' supported options
|
|
159
|
+
[]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def convert_to_graph(hash)
|
|
165
|
+
Graph::Graph.from_hash(hash)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
# Port Constraint Processor
|
|
6
|
+
#
|
|
7
|
+
# This module provides port constraint processing functionality for
|
|
8
|
+
# layout algorithms. It handles:
|
|
9
|
+
# - Automatic port side detection from positions
|
|
10
|
+
# - Port grouping by side
|
|
11
|
+
# - Port ordering within each side
|
|
12
|
+
# - Port positioning on node boundaries
|
|
13
|
+
module PortConstraintProcessor
|
|
14
|
+
# Apply port constraints to all nodes in the graph
|
|
15
|
+
#
|
|
16
|
+
# @param graph [Elkrb::Graph::Graph] The graph to process
|
|
17
|
+
def apply_port_constraints(graph)
|
|
18
|
+
return unless graph.children
|
|
19
|
+
|
|
20
|
+
graph.children.each do |node|
|
|
21
|
+
process_node_ports(node)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Process ports for a single node
|
|
28
|
+
#
|
|
29
|
+
# @param node [Elkrb::Graph::Node] The node to process
|
|
30
|
+
def process_node_ports(node)
|
|
31
|
+
return unless node.ports && !node.ports.empty?
|
|
32
|
+
return unless node.width && node.height && node.width.positive? && node.height.positive?
|
|
33
|
+
|
|
34
|
+
# Detect sides if not specified
|
|
35
|
+
detect_port_sides(node)
|
|
36
|
+
|
|
37
|
+
# Group ports by side
|
|
38
|
+
ports_by_side = group_ports_by_side(node.ports)
|
|
39
|
+
|
|
40
|
+
# Apply ordering within each side
|
|
41
|
+
ports_by_side.each do |side, ports|
|
|
42
|
+
order_ports_on_side(node, side, ports)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Position ports on node boundaries
|
|
46
|
+
position_ports_on_boundaries(node, ports_by_side)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Detect port sides for ports with UNDEFINED side
|
|
50
|
+
#
|
|
51
|
+
# @param node [Elkrb::Graph::Node] The node containing the ports
|
|
52
|
+
def detect_port_sides(node)
|
|
53
|
+
node.ports.each do |port|
|
|
54
|
+
if port.side == Graph::Port::UNDEFINED
|
|
55
|
+
detected_side = port.detect_side(node.width, node.height)
|
|
56
|
+
port.side = detected_side
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Group ports by their side
|
|
62
|
+
#
|
|
63
|
+
# @param ports [Array<Elkrb::Graph::Port>] The ports to group
|
|
64
|
+
# @return [Hash<String, Array<Elkrb::Graph::Port>>] Ports grouped by side
|
|
65
|
+
def group_ports_by_side(ports)
|
|
66
|
+
ports.group_by(&:side)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Order ports on a specific side
|
|
70
|
+
#
|
|
71
|
+
# Ports are sorted by:
|
|
72
|
+
# 1. Index (if specified and >= 0)
|
|
73
|
+
# 2. Position along the side (x for horizontal sides, y for vertical)
|
|
74
|
+
#
|
|
75
|
+
# @param node [Elkrb::Graph::Node] The node containing the ports
|
|
76
|
+
# @param side [String] The side to order ports on
|
|
77
|
+
# @param ports [Array<Elkrb::Graph::Port>] The ports on this side
|
|
78
|
+
def order_ports_on_side(_node, side, ports)
|
|
79
|
+
# Sort by index if specified, otherwise by position
|
|
80
|
+
ports.sort_by! do |port|
|
|
81
|
+
if port.index >= 0
|
|
82
|
+
port.index
|
|
83
|
+
elsif [Graph::Port::NORTH, Graph::Port::SOUTH].include?(side)
|
|
84
|
+
# Horizontal sides: sort by x position
|
|
85
|
+
port.x || 0
|
|
86
|
+
else
|
|
87
|
+
# Vertical sides: sort by y position
|
|
88
|
+
port.y || 0
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Assign sequential indices to ports without explicit index
|
|
93
|
+
ports.each_with_index do |port, idx|
|
|
94
|
+
port.index = idx if port.index.negative?
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Position ports on node boundaries
|
|
99
|
+
#
|
|
100
|
+
# @param node [Elkrb::Graph::Node] The node containing the ports
|
|
101
|
+
# @param ports_by_side [Hash<String, Array<Elkrb::Graph::Port>>] Ports grouped by side
|
|
102
|
+
def position_ports_on_boundaries(node, ports_by_side)
|
|
103
|
+
# NORTH: top edge, distributed horizontally
|
|
104
|
+
if ports_by_side[Graph::Port::NORTH]
|
|
105
|
+
distribute_ports_horizontally(
|
|
106
|
+
node,
|
|
107
|
+
ports_by_side[Graph::Port::NORTH],
|
|
108
|
+
0,
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# SOUTH: bottom edge, distributed horizontally
|
|
113
|
+
if ports_by_side[Graph::Port::SOUTH]
|
|
114
|
+
distribute_ports_horizontally(
|
|
115
|
+
node,
|
|
116
|
+
ports_by_side[Graph::Port::SOUTH],
|
|
117
|
+
node.height,
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# WEST: left edge, distributed vertically
|
|
122
|
+
if ports_by_side[Graph::Port::WEST]
|
|
123
|
+
distribute_ports_vertically(
|
|
124
|
+
node,
|
|
125
|
+
ports_by_side[Graph::Port::WEST],
|
|
126
|
+
0,
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# EAST: right edge, distributed vertically
|
|
131
|
+
if ports_by_side[Graph::Port::EAST]
|
|
132
|
+
distribute_ports_vertically(
|
|
133
|
+
node,
|
|
134
|
+
ports_by_side[Graph::Port::EAST],
|
|
135
|
+
node.width,
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Distribute ports horizontally along a horizontal edge
|
|
141
|
+
#
|
|
142
|
+
# @param node [Elkrb::Graph::Node] The node containing the ports
|
|
143
|
+
# @param ports [Array<Elkrb::Graph::Port>] The ports to distribute
|
|
144
|
+
# @param y_pos [Float] The y position of the edge
|
|
145
|
+
def distribute_ports_horizontally(node, ports, y_pos)
|
|
146
|
+
count = ports.length
|
|
147
|
+
spacing = node.width / (count + 1).to_f
|
|
148
|
+
|
|
149
|
+
ports.each_with_index do |port, idx|
|
|
150
|
+
port.x = spacing * (idx + 1)
|
|
151
|
+
port.y = y_pos
|
|
152
|
+
port.offset = port.x
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Distribute ports vertically along a vertical edge
|
|
157
|
+
#
|
|
158
|
+
# @param node [Elkrb::Graph::Node] The node containing the ports
|
|
159
|
+
# @param ports [Array<Elkrb::Graph::Port>] The ports to distribute
|
|
160
|
+
# @param x_pos [Float] The x position of the edge
|
|
161
|
+
def distribute_ports_vertically(node, ports, x_pos)
|
|
162
|
+
count = ports.length
|
|
163
|
+
spacing = node.height / (count + 1).to_f
|
|
164
|
+
|
|
165
|
+
ports.each_with_index do |port, idx|
|
|
166
|
+
port.x = x_pos
|
|
167
|
+
port.y = spacing * (idx + 1)
|
|
168
|
+
port.offset = port.y
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Options
|
|
5
|
+
# ElkPadding parser for padding specifications
|
|
6
|
+
#
|
|
7
|
+
# Parses padding strings in the format:
|
|
8
|
+
# "[left=2, top=3, right=3, bottom=2]"
|
|
9
|
+
class ElkPadding
|
|
10
|
+
attr_reader :left, :top, :right, :bottom
|
|
11
|
+
|
|
12
|
+
def initialize(left: 0, top: 0, right: 0, bottom: 0)
|
|
13
|
+
@left = left.to_f
|
|
14
|
+
@top = top.to_f
|
|
15
|
+
@right = right.to_f
|
|
16
|
+
@bottom = bottom.to_f
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Parse padding from string or hash
|
|
20
|
+
#
|
|
21
|
+
# @param value [String, Hash, ElkPadding] The padding specification
|
|
22
|
+
# @return [ElkPadding] Parsed padding object
|
|
23
|
+
def self.parse(value)
|
|
24
|
+
return value if value.is_a?(ElkPadding)
|
|
25
|
+
return from_hash(value) if value.is_a?(Hash)
|
|
26
|
+
return from_string(value) if value.is_a?(String)
|
|
27
|
+
|
|
28
|
+
raise ArgumentError, "Invalid padding value: #{value.inspect}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Parse from hash
|
|
32
|
+
#
|
|
33
|
+
# @param hash [Hash] Hash with :left, :top, :right, :bottom keys
|
|
34
|
+
# @return [ElkPadding] Parsed padding object
|
|
35
|
+
def self.from_hash(hash)
|
|
36
|
+
new(
|
|
37
|
+
left: hash[:left] || hash["left"] || 0,
|
|
38
|
+
top: hash[:top] || hash["top"] || 0,
|
|
39
|
+
right: hash[:right] || hash["right"] || 0,
|
|
40
|
+
bottom: hash[:bottom] || hash["bottom"] || 0,
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Parse from string
|
|
45
|
+
#
|
|
46
|
+
# @param str [String] String like "[left=2, top=3, right=3, bottom=2]"
|
|
47
|
+
# @return [ElkPadding] Parsed padding object
|
|
48
|
+
def self.from_string(str)
|
|
49
|
+
# Remove brackets and split by comma
|
|
50
|
+
content = str.strip.gsub(/^\[|\]$/, "")
|
|
51
|
+
parts = {}
|
|
52
|
+
|
|
53
|
+
content.split(",").each do |part|
|
|
54
|
+
key, value = part.split("=").map(&:strip)
|
|
55
|
+
parts[key.to_sym] = value.to_f
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
new(**parts)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Convert to hash
|
|
62
|
+
#
|
|
63
|
+
# @return [Hash] Hash representation
|
|
64
|
+
def to_h
|
|
65
|
+
{
|
|
66
|
+
left: @left,
|
|
67
|
+
top: @top,
|
|
68
|
+
right: @right,
|
|
69
|
+
bottom: @bottom,
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Convert to string
|
|
74
|
+
#
|
|
75
|
+
# @return [String] String representation
|
|
76
|
+
def to_s
|
|
77
|
+
"[left=#{@left}, top=#{@top}, right=#{@right}, bottom=#{@bottom}]"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check equality
|
|
81
|
+
#
|
|
82
|
+
# @param other [ElkPadding] Other padding object
|
|
83
|
+
# @return [Boolean] True if equal
|
|
84
|
+
def ==(other)
|
|
85
|
+
return false unless other.is_a?(ElkPadding)
|
|
86
|
+
|
|
87
|
+
@left == other.left &&
|
|
88
|
+
@top == other.top &&
|
|
89
|
+
@right == other.right &&
|
|
90
|
+
@bottom == other.bottom
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Options
|
|
5
|
+
# KVector parser for coordinate pairs
|
|
6
|
+
#
|
|
7
|
+
# Parses coordinate strings in the format:
|
|
8
|
+
# "(23, 43)" or "(23,43)"
|
|
9
|
+
class KVector
|
|
10
|
+
attr_reader :x, :y
|
|
11
|
+
|
|
12
|
+
def initialize(x, y)
|
|
13
|
+
@x = x.to_f
|
|
14
|
+
@y = y.to_f
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Parse KVector from string, hash, or array
|
|
18
|
+
#
|
|
19
|
+
# @param value [String, Hash, Array, KVector] The coordinate specification
|
|
20
|
+
# @return [KVector] Parsed coordinate object
|
|
21
|
+
def self.parse(value)
|
|
22
|
+
return value if value.is_a?(KVector)
|
|
23
|
+
return from_hash(value) if value.is_a?(Hash)
|
|
24
|
+
return from_array(value) if value.is_a?(Array)
|
|
25
|
+
return from_string(value) if value.is_a?(String)
|
|
26
|
+
|
|
27
|
+
raise ArgumentError, "Invalid KVector value: #{value.inspect}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Parse from hash
|
|
31
|
+
#
|
|
32
|
+
# @param hash [Hash] Hash with :x and :y keys
|
|
33
|
+
# @return [KVector] Parsed coordinate object
|
|
34
|
+
def self.from_hash(hash)
|
|
35
|
+
new(
|
|
36
|
+
hash[:x] || hash["x"] || 0,
|
|
37
|
+
hash[:y] || hash["y"] || 0,
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Parse from array
|
|
42
|
+
#
|
|
43
|
+
# @param array [Array] Array with [x, y] values
|
|
44
|
+
# @return [KVector] Parsed coordinate object
|
|
45
|
+
def self.from_array(array)
|
|
46
|
+
raise ArgumentError, "Array must have 2 elements" unless array.size == 2
|
|
47
|
+
|
|
48
|
+
new(array[0], array[1])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Parse from string
|
|
52
|
+
#
|
|
53
|
+
# @param str [String] String like "(23, 43)" or "(23,43)"
|
|
54
|
+
# @return [KVector] Parsed coordinate object
|
|
55
|
+
def self.from_string(str)
|
|
56
|
+
# Remove parentheses and split by comma
|
|
57
|
+
content = str.strip.gsub(/^\(|\)$/, "")
|
|
58
|
+
parts = content.split(",").map(&:strip)
|
|
59
|
+
|
|
60
|
+
unless parts.size == 2
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"Invalid KVector format: #{str}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
new(parts[0], parts[1])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Convert to hash
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash] Hash representation
|
|
71
|
+
def to_h
|
|
72
|
+
{ x: @x, y: @y }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Convert to array
|
|
76
|
+
#
|
|
77
|
+
# @return [Array] Array representation
|
|
78
|
+
def to_a
|
|
79
|
+
[@x, @y]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Convert to string
|
|
83
|
+
#
|
|
84
|
+
# @return [String] String representation
|
|
85
|
+
def to_s
|
|
86
|
+
"(#{@x}, #{@y})"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check equality
|
|
90
|
+
#
|
|
91
|
+
# @param other [KVector] Other coordinate object
|
|
92
|
+
# @return [Boolean] True if equal
|
|
93
|
+
def ==(other)
|
|
94
|
+
return false unless other.is_a?(KVector)
|
|
95
|
+
|
|
96
|
+
@x == other.x && @y == other.y
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "k_vector"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Options
|
|
7
|
+
# KVectorChain parser for coordinate chains
|
|
8
|
+
#
|
|
9
|
+
# Parses coordinate chain strings in the format:
|
|
10
|
+
# "( {1,2}, {3,4} )" or "({1,2},{3,4})"
|
|
11
|
+
class KVectorChain
|
|
12
|
+
attr_reader :vectors
|
|
13
|
+
|
|
14
|
+
def initialize(vectors = [])
|
|
15
|
+
@vectors = vectors.map { |v| KVector.parse(v) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Parse KVectorChain from string or array
|
|
19
|
+
#
|
|
20
|
+
# @param value [String, Array, KVectorChain] The coordinate chain
|
|
21
|
+
# @return [KVectorChain] Parsed coordinate chain object
|
|
22
|
+
def self.parse(value)
|
|
23
|
+
return value if value.is_a?(KVectorChain)
|
|
24
|
+
return from_array(value) if value.is_a?(Array)
|
|
25
|
+
return from_string(value) if value.is_a?(String)
|
|
26
|
+
|
|
27
|
+
raise ArgumentError, "Invalid KVectorChain value: #{value.inspect}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Parse from array
|
|
31
|
+
#
|
|
32
|
+
# @param array [Array] Array of coordinate pairs or KVectors
|
|
33
|
+
# @return [KVectorChain] Parsed coordinate chain object
|
|
34
|
+
def self.from_array(array)
|
|
35
|
+
new(array)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Parse from string
|
|
39
|
+
#
|
|
40
|
+
# @param str [String] String like "( {1,2}, {3,4} )"
|
|
41
|
+
# @return [KVectorChain] Parsed coordinate chain object
|
|
42
|
+
def self.from_string(str)
|
|
43
|
+
# Remove outer parentheses
|
|
44
|
+
content = str.strip.gsub(/^\(\s*|\s*\)$/, "")
|
|
45
|
+
|
|
46
|
+
# Split by },{ or } , {
|
|
47
|
+
parts = content.split(/\}\s*,\s*\{/)
|
|
48
|
+
|
|
49
|
+
# Clean up first and last parts
|
|
50
|
+
parts[0] = parts[0].sub(/^\{/, "") if parts[0]
|
|
51
|
+
parts[-1] = parts[-1].sub(/\}$/, "") if parts[-1]
|
|
52
|
+
|
|
53
|
+
# Parse each coordinate pair
|
|
54
|
+
vectors = parts.map do |part|
|
|
55
|
+
coords = part.split(",").map(&:strip)
|
|
56
|
+
unless coords.size == 2
|
|
57
|
+
raise ArgumentError,
|
|
58
|
+
"Invalid coordinate pair: #{part}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
KVector.new(coords[0], coords[1])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
new(vectors)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Add a vector to the chain
|
|
68
|
+
#
|
|
69
|
+
# @param vector [KVector, Array, Hash] Vector to add
|
|
70
|
+
# @return [KVectorChain] Self for chaining
|
|
71
|
+
def add(vector)
|
|
72
|
+
@vectors << KVector.parse(vector)
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
alias << add
|
|
76
|
+
|
|
77
|
+
# Get vector at index
|
|
78
|
+
#
|
|
79
|
+
# @param index [Integer] Index of vector
|
|
80
|
+
# @return [KVector] Vector at index
|
|
81
|
+
def [](index)
|
|
82
|
+
@vectors[index]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Number of vectors in chain
|
|
86
|
+
#
|
|
87
|
+
# @return [Integer] Count of vectors
|
|
88
|
+
def size
|
|
89
|
+
@vectors.size
|
|
90
|
+
end
|
|
91
|
+
alias length size
|
|
92
|
+
|
|
93
|
+
# Check if chain is empty
|
|
94
|
+
#
|
|
95
|
+
# @return [Boolean] True if empty
|
|
96
|
+
def empty?
|
|
97
|
+
@vectors.empty?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Iterate over vectors
|
|
101
|
+
#
|
|
102
|
+
# @yield [KVector] Each vector in chain
|
|
103
|
+
def each(&)
|
|
104
|
+
@vectors.each(&)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Convert to array
|
|
108
|
+
#
|
|
109
|
+
# @return [Array<KVector>] Array of vectors
|
|
110
|
+
def to_a
|
|
111
|
+
@vectors.dup
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Convert to string
|
|
115
|
+
#
|
|
116
|
+
# @return [String] String representation
|
|
117
|
+
def to_s
|
|
118
|
+
return "()" if @vectors.empty?
|
|
119
|
+
|
|
120
|
+
vector_strs = @vectors.map { |v| "{#{v.x}, #{v.y}}" }
|
|
121
|
+
"( #{vector_strs.join(', ')} )"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Check equality
|
|
125
|
+
#
|
|
126
|
+
# @param other [KVectorChain] Other coordinate chain
|
|
127
|
+
# @return [Boolean] True if equal
|
|
128
|
+
def ==(other)
|
|
129
|
+
return false unless other.is_a?(KVectorChain)
|
|
130
|
+
|
|
131
|
+
@vectors == other.vectors
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|