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,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Elkrb
|
|
7
|
+
module Commands
|
|
8
|
+
# Command for validating ELK graph structure
|
|
9
|
+
# Checks for required fields, valid relationships, and structural integrity
|
|
10
|
+
class ValidateCommand
|
|
11
|
+
def initialize(file, options)
|
|
12
|
+
@file = file
|
|
13
|
+
@options = options
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
# Load and validate graph
|
|
18
|
+
graph = load_any_format(@file)
|
|
19
|
+
errors = validate_graph(graph)
|
|
20
|
+
|
|
21
|
+
if errors.empty?
|
|
22
|
+
puts "✅ #{@file} is valid"
|
|
23
|
+
else
|
|
24
|
+
puts "❌ #{@file} has #{errors.length} error(s):"
|
|
25
|
+
errors.each { |e| puts " • #{e}" }
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def load_any_format(file)
|
|
33
|
+
raise ArgumentError, "File not found: #{file}" unless File.exist?(file)
|
|
34
|
+
|
|
35
|
+
require_relative "../graph/graph"
|
|
36
|
+
content = File.read(file)
|
|
37
|
+
ext = File.extname(file).downcase
|
|
38
|
+
|
|
39
|
+
graph = case ext
|
|
40
|
+
when ".json"
|
|
41
|
+
Elkrb::Graph::Graph.from_json(content)
|
|
42
|
+
when ".yml", ".yaml"
|
|
43
|
+
Elkrb::Graph::Graph.from_yaml(content)
|
|
44
|
+
when ".elkt"
|
|
45
|
+
require_relative "../parsers/elkt_parser"
|
|
46
|
+
Elkrb::Parsers::ElktParser.parse(content)
|
|
47
|
+
else
|
|
48
|
+
detect_and_parse(content)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Convert to hash for validation
|
|
52
|
+
if graph.is_a?(Hash)
|
|
53
|
+
graph
|
|
54
|
+
else
|
|
55
|
+
JSON.parse(graph.to_json,
|
|
56
|
+
symbolize_names: true)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def detect_and_parse(content)
|
|
61
|
+
require_relative "../graph/graph"
|
|
62
|
+
|
|
63
|
+
# Try JSON first
|
|
64
|
+
begin
|
|
65
|
+
return Elkrb::Graph::Graph.from_json(content)
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
# Not JSON
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Try YAML
|
|
71
|
+
begin
|
|
72
|
+
return Elkrb::Graph::Graph.from_yaml(content)
|
|
73
|
+
rescue Psych::SyntaxError
|
|
74
|
+
# Not YAML
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Try ELKT
|
|
78
|
+
begin
|
|
79
|
+
require_relative "../parsers/elkt_parser"
|
|
80
|
+
Elkrb::Parsers::ElktParser.parse(content)
|
|
81
|
+
rescue StandardError
|
|
82
|
+
raise ArgumentError, "Unable to parse input file"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_graph(graph)
|
|
87
|
+
errors = []
|
|
88
|
+
|
|
89
|
+
# Check graph structure
|
|
90
|
+
errors << "Graph must be a Hash" unless graph.is_a?(Hash)
|
|
91
|
+
return errors unless graph.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
# Check required fields
|
|
94
|
+
errors << "Graph missing 'id' field" unless graph[:id] || graph["id"]
|
|
95
|
+
|
|
96
|
+
# Validate children (nodes)
|
|
97
|
+
children = graph[:children] || graph["children"] || []
|
|
98
|
+
children.each_with_index do |node, idx|
|
|
99
|
+
errors.concat(validate_node(node, "children[#{idx}]"))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Validate edges
|
|
103
|
+
edges = graph[:edges] || graph["edges"] || []
|
|
104
|
+
edges.each_with_index do |edge, idx|
|
|
105
|
+
errors.concat(validate_edge(edge, "edges[#{idx}]",
|
|
106
|
+
collect_node_ids(graph)))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Strict mode: additional checks
|
|
110
|
+
if @options[:strict]
|
|
111
|
+
errors.concat(validate_strict(graph))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
errors
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def validate_node(node, path)
|
|
118
|
+
errors = []
|
|
119
|
+
|
|
120
|
+
errors << "#{path}: Node must be a Hash" unless node.is_a?(Hash)
|
|
121
|
+
return errors unless node.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
# Check required fields
|
|
124
|
+
node_id = node[:id] || node["id"]
|
|
125
|
+
errors << "#{path}: Node missing 'id' field" unless node_id
|
|
126
|
+
|
|
127
|
+
# Check dimensions (recommended but not required unless strict)
|
|
128
|
+
if @options[:strict]
|
|
129
|
+
width = node[:width] || node["width"]
|
|
130
|
+
height = node[:height] || node["height"]
|
|
131
|
+
|
|
132
|
+
errors << "#{path}: Node '#{node_id}' missing 'width'" unless width
|
|
133
|
+
errors << "#{path}: Node '#{node_id}' missing 'height'" unless height
|
|
134
|
+
|
|
135
|
+
if width && (!width.is_a?(Numeric) || width <= 0)
|
|
136
|
+
errors << "#{path}: Node '#{node_id}' has invalid width: #{width}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if height && (!height.is_a?(Numeric) || height <= 0)
|
|
140
|
+
errors << "#{path}: Node '#{node_id}' has invalid height: #{height}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Validate nested children
|
|
145
|
+
children = node[:children] || node["children"] || []
|
|
146
|
+
children.each_with_index do |child, idx|
|
|
147
|
+
errors.concat(validate_node(child, "#{path}.children[#{idx}]"))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Validate ports
|
|
151
|
+
ports = node[:ports] || node["ports"] || []
|
|
152
|
+
ports.each_with_index do |port, idx|
|
|
153
|
+
errors.concat(validate_port(port, "#{path}.ports[#{idx}]"))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
errors
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def validate_edge(edge, path, valid_node_ids)
|
|
160
|
+
errors = []
|
|
161
|
+
|
|
162
|
+
errors << "#{path}: Edge must be a Hash" unless edge.is_a?(Hash)
|
|
163
|
+
return errors unless edge.is_a?(Hash)
|
|
164
|
+
|
|
165
|
+
# Check required fields
|
|
166
|
+
edge_id = edge[:id] || edge["id"]
|
|
167
|
+
sources = edge[:sources] || edge["sources"]
|
|
168
|
+
targets = edge[:targets] || edge["targets"]
|
|
169
|
+
|
|
170
|
+
errors << "#{path}: Edge missing 'id' field" unless edge_id
|
|
171
|
+
errors << "#{path}: Edge '#{edge_id}' missing 'sources' field" unless sources
|
|
172
|
+
errors << "#{path}: Edge '#{edge_id}' missing 'targets' field" unless targets
|
|
173
|
+
|
|
174
|
+
# Validate sources and targets are arrays
|
|
175
|
+
if sources && !sources.is_a?(Array)
|
|
176
|
+
errors << "#{path}: Edge '#{edge_id}' sources must be an array"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
if targets && !targets.is_a?(Array)
|
|
180
|
+
errors << "#{path}: Edge '#{edge_id}' targets must be an array"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Check that sources and targets reference valid nodes (in strict mode)
|
|
184
|
+
if @options[:strict] && sources.is_a?(Array) && targets.is_a?(Array)
|
|
185
|
+
sources.each do |source|
|
|
186
|
+
unless valid_node_ids.include?(source)
|
|
187
|
+
errors << "#{path}: Edge '#{edge_id}' references unknown source node '#{source}'"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
targets.each do |target|
|
|
192
|
+
unless valid_node_ids.include?(target)
|
|
193
|
+
errors << "#{path}: Edge '#{edge_id}' references unknown target node '#{target}'"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
errors
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def validate_port(port, path)
|
|
202
|
+
errors = []
|
|
203
|
+
|
|
204
|
+
errors << "#{path}: Port must be a Hash" unless port.is_a?(Hash)
|
|
205
|
+
return errors unless port.is_a?(Hash)
|
|
206
|
+
|
|
207
|
+
# Check required fields
|
|
208
|
+
port_id = port[:id] || port["id"]
|
|
209
|
+
errors << "#{path}: Port missing 'id' field" unless port_id
|
|
210
|
+
|
|
211
|
+
errors
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def validate_strict(graph)
|
|
215
|
+
errors = []
|
|
216
|
+
|
|
217
|
+
# Check for layout options
|
|
218
|
+
layout_options = graph[:layoutOptions] || graph["layoutOptions"]
|
|
219
|
+
if layout_options && !layout_options.is_a?(Hash)
|
|
220
|
+
errors << "layoutOptions must be a Hash"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
errors
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def collect_node_ids(graph, ids = [])
|
|
227
|
+
children = graph[:children] || graph["children"] || []
|
|
228
|
+
|
|
229
|
+
children.each do |node|
|
|
230
|
+
node_id = node[:id] || node["id"]
|
|
231
|
+
ids << node_id if node_id
|
|
232
|
+
|
|
233
|
+
# Recursively collect from nested children
|
|
234
|
+
collect_node_ids(node, ids)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
ids
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
data/lib/elkrb/errors.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
# Base error class for Elkrb
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Exception raised when an unsupported configuration is detected
|
|
8
|
+
class UnsupportedConfigurationException < Error
|
|
9
|
+
attr_reader :option, :value
|
|
10
|
+
|
|
11
|
+
def initialize(message, option: nil, value: nil)
|
|
12
|
+
@option = option
|
|
13
|
+
@value = value
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Exception raised when graph validation fails
|
|
19
|
+
class ValidationError < Error; end
|
|
20
|
+
|
|
21
|
+
# Exception raised when an algorithm is not found
|
|
22
|
+
class AlgorithmNotFoundError < Error
|
|
23
|
+
attr_reader :algorithm_name
|
|
24
|
+
|
|
25
|
+
def initialize(algorithm_name)
|
|
26
|
+
@algorithm_name = algorithm_name
|
|
27
|
+
super("Algorithm not found: #{algorithm_name}")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "point"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Geometry
|
|
7
|
+
# Bezier curve implementation for smooth edge routing
|
|
8
|
+
#
|
|
9
|
+
# This class provides cubic Bezier curve calculations for creating
|
|
10
|
+
# smooth, curved edges in graph layouts. It supports calculating
|
|
11
|
+
# control points and generating points along the curve.
|
|
12
|
+
class Bezier
|
|
13
|
+
# Calculate points along a cubic Bezier curve
|
|
14
|
+
#
|
|
15
|
+
# @param start_point [Point] The starting point (P0)
|
|
16
|
+
# @param end_point [Point] The ending point (P3)
|
|
17
|
+
# @param control1 [Point] The first control point (P1)
|
|
18
|
+
# @param control2 [Point] The second control point (P2)
|
|
19
|
+
# @param segments [Integer] Number of line segments to generate
|
|
20
|
+
# @return [Array<Point>] Array of points along the curve
|
|
21
|
+
def self.calculate_curve(start_point, end_point, control1, control2,
|
|
22
|
+
segments = 20)
|
|
23
|
+
points = []
|
|
24
|
+
segments.times do |i|
|
|
25
|
+
t = i.to_f / (segments - 1)
|
|
26
|
+
points << bezier_point(t, start_point, control1, control2, end_point)
|
|
27
|
+
end
|
|
28
|
+
points
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Calculate default control points for a smooth curve
|
|
32
|
+
#
|
|
33
|
+
# This method generates control points that create a natural-looking
|
|
34
|
+
# curve between two points. The control points are positioned
|
|
35
|
+
# perpendicular to the direct line between start and end points.
|
|
36
|
+
#
|
|
37
|
+
# @param start_point [Point] The starting point
|
|
38
|
+
# @param end_point [Point] The ending point
|
|
39
|
+
# @param curvature [Float] Curve strength (0.0 = straight, 1.0 = very
|
|
40
|
+
# curved)
|
|
41
|
+
# @return [Array<Point>] Two control points [control1, control2]
|
|
42
|
+
def self.calculate_control_points(start_point, end_point,
|
|
43
|
+
curvature = 0.5)
|
|
44
|
+
dx = end_point.x - start_point.x
|
|
45
|
+
dy = end_point.y - start_point.y
|
|
46
|
+
distance = Math.sqrt((dx**2) + (dy**2))
|
|
47
|
+
|
|
48
|
+
return [start_point, end_point] if distance < 0.001
|
|
49
|
+
|
|
50
|
+
# Control points offset perpendicular to line
|
|
51
|
+
offset = distance * curvature
|
|
52
|
+
|
|
53
|
+
# Calculate perpendicular direction
|
|
54
|
+
perp_x = -dy / distance
|
|
55
|
+
perp_y = dx / distance
|
|
56
|
+
|
|
57
|
+
control1 = Point.new(
|
|
58
|
+
x: start_point.x + (dx * 0.33) + (perp_x * offset),
|
|
59
|
+
y: start_point.y + (dy * 0.33) + (perp_y * offset),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
control2 = Point.new(
|
|
63
|
+
x: start_point.x + (dx * 0.66) - (perp_x * offset),
|
|
64
|
+
y: start_point.y + (dy * 0.66) - (perp_y * offset),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
[control1, control2]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Calculate a point on a cubic Bezier curve at parameter t
|
|
71
|
+
#
|
|
72
|
+
# Uses the cubic Bezier formula:
|
|
73
|
+
# B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
|
|
74
|
+
#
|
|
75
|
+
# @param t [Float] Parameter value (0.0 to 1.0)
|
|
76
|
+
# @param p0 [Point] Start point
|
|
77
|
+
# @param p1 [Point] First control point
|
|
78
|
+
# @param p2 [Point] Second control point
|
|
79
|
+
# @param p3 [Point] End point
|
|
80
|
+
# @return [Point] Point on the curve at parameter t
|
|
81
|
+
def self.bezier_point(t, p0, p1, p2, p3)
|
|
82
|
+
# Clamp t to [0, 1]
|
|
83
|
+
t = [[t, 0.0].max, 1.0].min
|
|
84
|
+
|
|
85
|
+
# Calculate Bezier coefficients
|
|
86
|
+
u = 1.0 - t
|
|
87
|
+
tt = t * t
|
|
88
|
+
uu = u * u
|
|
89
|
+
uuu = uu * u
|
|
90
|
+
ttt = tt * t
|
|
91
|
+
|
|
92
|
+
# B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
|
|
93
|
+
x = (uuu * p0.x) +
|
|
94
|
+
(3 * uu * t * p1.x) +
|
|
95
|
+
(3 * u * tt * p2.x) +
|
|
96
|
+
(ttt * p3.x)
|
|
97
|
+
|
|
98
|
+
y = (uuu * p0.y) +
|
|
99
|
+
(3 * uu * t * p1.y) +
|
|
100
|
+
(3 * u * tt * p2.y) +
|
|
101
|
+
(ttt * p3.y)
|
|
102
|
+
|
|
103
|
+
Point.new(x: x, y: y)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Calculate simple control points for horizontal-first routing
|
|
107
|
+
#
|
|
108
|
+
# Creates control points that produce a curve with horizontal exit
|
|
109
|
+
# and entry directions, useful for left-to-right or right-to-left
|
|
110
|
+
# edge routing.
|
|
111
|
+
#
|
|
112
|
+
# @param start_point [Point] The starting point
|
|
113
|
+
# @param end_point [Point] The ending point
|
|
114
|
+
# @param offset_ratio [Float] How far to offset controls (0.0 to 1.0)
|
|
115
|
+
# @return [Array<Point>] Two control points [control1, control2]
|
|
116
|
+
def self.horizontal_control_points(start_point, end_point,
|
|
117
|
+
offset_ratio = 0.5)
|
|
118
|
+
dx = end_point.x - start_point.x
|
|
119
|
+
offset = dx.abs * offset_ratio
|
|
120
|
+
|
|
121
|
+
control1 = Point.new(
|
|
122
|
+
x: start_point.x + offset,
|
|
123
|
+
y: start_point.y,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
control2 = Point.new(
|
|
127
|
+
x: end_point.x - offset,
|
|
128
|
+
y: end_point.y,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
[control1, control2]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Calculate simple control points for vertical-first routing
|
|
135
|
+
#
|
|
136
|
+
# Creates control points that produce a curve with vertical exit
|
|
137
|
+
# and entry directions, useful for top-to-bottom or bottom-to-top
|
|
138
|
+
# edge routing.
|
|
139
|
+
#
|
|
140
|
+
# @param start_point [Point] The starting point
|
|
141
|
+
# @param end_point [Point] The ending point
|
|
142
|
+
# @param offset_ratio [Float] How far to offset controls (0.0 to 1.0)
|
|
143
|
+
# @return [Array<Point>] Two control points [control1, control2]
|
|
144
|
+
def self.vertical_control_points(start_point, end_point,
|
|
145
|
+
offset_ratio = 0.5)
|
|
146
|
+
dy = end_point.y - start_point.y
|
|
147
|
+
offset = dy.abs * offset_ratio
|
|
148
|
+
|
|
149
|
+
control1 = Point.new(
|
|
150
|
+
x: start_point.x,
|
|
151
|
+
y: start_point.y + offset,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
control2 = Point.new(
|
|
155
|
+
x: end_point.x,
|
|
156
|
+
y: end_point.y - offset,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
[control1, control2]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Geometry
|
|
5
|
+
class Dimension
|
|
6
|
+
attr_accessor :width, :height
|
|
7
|
+
|
|
8
|
+
def initialize(width = 0.0, height = 0.0)
|
|
9
|
+
@width = width.to_f
|
|
10
|
+
@height = height.to_f
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def area
|
|
14
|
+
@width * @height
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def ==(other)
|
|
18
|
+
return false unless other.is_a?(Dimension)
|
|
19
|
+
|
|
20
|
+
@width == other.width && @height == other.height
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{ width: @width, height: @height }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_s
|
|
28
|
+
"#{@width}x#{@height}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Elkrb
|
|
6
|
+
module Geometry
|
|
7
|
+
class Point < Lutaml::Model::Serializable
|
|
8
|
+
attribute :x, :float, default: -> { 0.0 }
|
|
9
|
+
attribute :y, :float, default: -> { 0.0 }
|
|
10
|
+
|
|
11
|
+
json do
|
|
12
|
+
map "x", to: :x
|
|
13
|
+
map "y", to: :y
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
yaml do
|
|
17
|
+
map "x", to: :x
|
|
18
|
+
map "y", to: :y
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(**attributes)
|
|
22
|
+
# Handle both keyword args and positional args
|
|
23
|
+
if attributes.empty?
|
|
24
|
+
super(x: 0.0, y: 0.0)
|
|
25
|
+
elsif attributes.key?(:x) || attributes.key?(:y)
|
|
26
|
+
super
|
|
27
|
+
else
|
|
28
|
+
# Handle case where first two positional args might be passed
|
|
29
|
+
super(x: 0.0, y: 0.0)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def +(other)
|
|
34
|
+
Point.new(x: @x + other.x, y: @y + other.y)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def -(other)
|
|
38
|
+
Point.new(x: @x - other.x, y: @y - other.y)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def *(other)
|
|
42
|
+
Point.new(x: @x * other, y: @y * other)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def /(other)
|
|
46
|
+
Point.new(x: @x / other, y: @y / other)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def distance_to(other)
|
|
50
|
+
Math.sqrt(((@x - other.x)**2) + ((@y - other.y)**2))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ==(other)
|
|
54
|
+
return false unless other.is_a?(Point)
|
|
55
|
+
|
|
56
|
+
@x == other.x && @y == other.y
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_h
|
|
60
|
+
{ x: @x, y: @y }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def to_s
|
|
64
|
+
"(#{@x}, #{@y})"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "point"
|
|
4
|
+
require_relative "dimension"
|
|
5
|
+
|
|
6
|
+
module Elkrb
|
|
7
|
+
module Geometry
|
|
8
|
+
class Rectangle
|
|
9
|
+
attr_accessor :x, :y, :width, :height
|
|
10
|
+
|
|
11
|
+
def initialize(x = 0.0, y = 0.0, width = 0.0, height = 0.0)
|
|
12
|
+
@x = x.to_f
|
|
13
|
+
@y = y.to_f
|
|
14
|
+
@width = width.to_f
|
|
15
|
+
@height = height.to_f
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def position
|
|
19
|
+
Point.new(@x, @y)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def position=(point)
|
|
23
|
+
@x = point.x
|
|
24
|
+
@y = point.y
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def size
|
|
28
|
+
Dimension.new(@width, @height)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def size=(dimension)
|
|
32
|
+
@width = dimension.width
|
|
33
|
+
@height = dimension.height
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def left
|
|
37
|
+
@x
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def right
|
|
41
|
+
@x + @width
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def top
|
|
45
|
+
@y
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def bottom
|
|
49
|
+
@y + @height
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def center
|
|
53
|
+
Point.new(@x + (@width / 2.0), @y + (@height / 2.0))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def contains?(point)
|
|
57
|
+
point.x.between?(@x, right) &&
|
|
58
|
+
point.y >= @y && point.y <= bottom
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def intersects?(other)
|
|
62
|
+
!(right < other.left || left > other.right ||
|
|
63
|
+
bottom < other.top || top > other.bottom)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def area
|
|
67
|
+
@width * @height
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def ==(other)
|
|
71
|
+
return false unless other.is_a?(Rectangle)
|
|
72
|
+
|
|
73
|
+
@x == other.x && @y == other.y &&
|
|
74
|
+
@width == other.width && @height == other.height
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_h
|
|
78
|
+
{ x: @x, y: @y, width: @width, height: @height }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_s
|
|
82
|
+
"(#{@x}, #{@y}, #{@width}x#{@height})"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Geometry
|
|
5
|
+
class Vector
|
|
6
|
+
attr_accessor :x, :y
|
|
7
|
+
|
|
8
|
+
def initialize(x = 0.0, y = 0.0)
|
|
9
|
+
@x = x.to_f
|
|
10
|
+
@y = y.to_f
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def +(other)
|
|
14
|
+
Vector.new(@x + other.x, @y + other.y)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def -(other)
|
|
18
|
+
Vector.new(@x - other.x, @y - other.y)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def *(other)
|
|
22
|
+
Vector.new(@x * other, @y * other)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def /(other)
|
|
26
|
+
Vector.new(@x / other, @y / other)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def magnitude
|
|
30
|
+
Math.sqrt((@x**2) + (@y**2))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def normalize
|
|
34
|
+
mag = magnitude
|
|
35
|
+
return Vector.new(0, 0) if mag.zero?
|
|
36
|
+
|
|
37
|
+
Vector.new(@x / mag, @y / mag)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dot(other)
|
|
41
|
+
(@x * other.x) + (@y * other.y)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def perpendicular
|
|
45
|
+
Vector.new(-@y, @x)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def angle
|
|
49
|
+
Math.atan2(@y, @x)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ==(other)
|
|
53
|
+
return false unless other.is_a?(Vector)
|
|
54
|
+
|
|
55
|
+
@x == other.x && @y == other.y
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def to_h
|
|
59
|
+
{ x: @x, y: @y }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_s
|
|
63
|
+
"<#{@x}, #{@y}>"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|