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,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
@@ -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