unmagic-color 0.1.0 → 0.2.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.
@@ -62,24 +62,29 @@ module Unmagic
62
62
  # Error raised when parsing RGB color strings fails
63
63
  class ParseError < Color::Error; end
64
64
 
65
- attr_reader :red, :green, :blue
65
+ attr_reader :red, :green, :blue, :alpha
66
66
 
67
67
  # Create a new RGB color.
68
68
  #
69
69
  # @param red [Integer] Red component (0-255), values outside this range are clamped
70
70
  # @param green [Integer] Green component (0-255), values outside this range are clamped
71
71
  # @param blue [Integer] Blue component (0-255), values outside this range are clamped
72
+ # @param alpha [Numeric, Color::Alpha, nil] Alpha channel (0-100%), defaults to 100 (fully opaque)
72
73
  #
73
74
  # @example Create a red color
74
75
  # RGB.new(red: 255, green: 0, blue: 0)
75
76
  #
77
+ # @example Create a semi-transparent red
78
+ # RGB.new(red: 255, green: 0, blue: 0, alpha: 50)
79
+ #
76
80
  # @example Values are automatically clamped
77
81
  # RGB.new(red: 300, green: -10, blue: 128)
78
- def initialize(red:, green:, blue:)
82
+ def initialize(red:, green:, blue:, alpha: nil)
79
83
  super()
80
84
  @red = Color::Red.new(value: red)
81
85
  @green = Color::Green.new(value: green)
82
86
  @blue = Color::Blue.new(value: blue)
87
+ @alpha = Color::Alpha.build(alpha) || Color::Alpha::DEFAULT
83
88
  end
84
89
 
85
90
  class << self
@@ -107,6 +112,11 @@ module Unmagic
107
112
 
108
113
  input = input.strip
109
114
 
115
+ # Check for ANSI format first (numeric with optional semicolons)
116
+ if input.match?(/\A\d+(?:;\d+)*\z/) && ANSI.valid?(input)
117
+ return ANSI.parse(input)
118
+ end
119
+
110
120
  # Check if it looks like a hex color (starts with # or only contains hex digits)
111
121
  if input.start_with?("#") || input.match?(/\A[0-9A-Fa-f]{3,6}\z/)
112
122
  return Hex.parse(input)
@@ -116,6 +126,58 @@ module Unmagic
116
126
  parse_rgb_format(input)
117
127
  end
118
128
 
129
+ # Build an RGB color from an integer, string, positional values, or keyword arguments.
130
+ #
131
+ # @param args [Integer, String, Array<Integer>] Either an integer (0xRRGGBB), color string, or 3 component values
132
+ # @option kwargs [Integer] :red Red component (0-255)
133
+ # @option kwargs [Integer] :green Green component (0-255)
134
+ # @option kwargs [Integer] :blue Blue component (0-255)
135
+ # @return [RGB] The constructed RGB color
136
+ #
137
+ # @example From integer (packed RGB)
138
+ # RGB.build(0xDAA520) # goldenrod
139
+ # RGB.build(14329120) # same as 0xDAA520
140
+ #
141
+ # @example From string
142
+ # RGB.build("#FF8800")
143
+ #
144
+ # @example From positional values
145
+ # RGB.build(255, 128, 0)
146
+ #
147
+ # @example From keyword arguments
148
+ # RGB.build(red: 255, green: 128, blue: 0)
149
+ def build(*args, **kwargs)
150
+ # Handle keyword arguments
151
+ return new(**kwargs) if kwargs.any?
152
+
153
+ # Handle single argument
154
+ if args.length == 1
155
+ value = args[0]
156
+
157
+ # Integer: extract RGB components via bit operations
158
+ if value.is_a?(::Integer)
159
+ return new(
160
+ red: (value >> 16) & 0xFF,
161
+ green: (value >> 8) & 0xFF,
162
+ blue: value & 0xFF,
163
+ )
164
+ end
165
+
166
+ # String: delegate to parse
167
+ return parse(value) if value.is_a?(::String)
168
+
169
+ raise ArgumentError, "Expected Integer or String, got #{value.class}"
170
+ end
171
+
172
+ # Handle three positional arguments (r, g, b)
173
+ if args.length == 3
174
+ values = args.map { |v| v.is_a?(::String) ? v.to_i : v }
175
+ return new(red: values[0], green: values[1], blue: values[2])
176
+ end
177
+
178
+ raise ArgumentError, "Expected 1 or 3 arguments, got #{args.length}"
179
+ end
180
+
119
181
  # Generate a deterministic RGB color from an integer seed.
120
182
  #
121
183
  # This creates consistent, visually distinct colors from hash values or IDs.
@@ -169,33 +231,64 @@ module Unmagic
169
231
  new(red: r, green: g, blue: b)
170
232
  end
171
233
 
172
- # Parse RGB format like "rgb(255, 128, 0)" or "255, 128, 0"
234
+ # Parse RGB format like "rgb(255, 128, 0)" or "rgb(255 128 0 / 0.5)"
235
+ #
236
+ # Supports both legacy comma-separated format and modern space-separated
237
+ # format with optional alpha value.
173
238
  #
174
239
  # @param input [String] RGB string to parse
175
240
  # @return [RGB] Parsed RGB color
176
241
  # @raise [ParseError] If format is invalid
177
242
  def parse_rgb_format(input)
178
- # Remove rgb() wrapper if present
179
- clean = input.gsub(/^rgb\s*\(\s*|\s*\)$/, "").strip
243
+ # Remove rgb() or rgba() wrapper if present
244
+ clean = input.gsub(/^rgba?\s*\(\s*|\s*\)$/, "").strip
245
+
246
+ # Check for modern format with slash (space-separated with / for alpha)
247
+ # Example: "255 128 0 / 0.5" or "255 128 0 / 50%"
248
+ if clean.include?("/")
249
+ parts = clean.split("/").map(&:strip)
250
+ raise ParseError, "Invalid format with /: expected 'R G B / alpha'" unless parts.length == 2
251
+
252
+ rgb_values = parts[0].split(/\s+/)
253
+ alpha_str = parts[1]
254
+
255
+ unless rgb_values.length == 3
256
+ raise ParseError, "Expected 3 RGB values before /, got #{rgb_values.length}"
257
+ end
180
258
 
181
- # Split values
259
+ alpha = Color::Alpha.parse(alpha_str)
260
+ r, g, b = parse_rgb_values(rgb_values)
261
+
262
+ return new(red: r, green: g, blue: b, alpha: alpha)
263
+ end
264
+
265
+ # Legacy comma-separated format (with or without alpha)
266
+ # Example: "255, 128, 0" or "255, 128, 0, 0.5"
182
267
  values = clean.split(/\s*,\s*/)
183
- unless values.length == 3
184
- raise ParseError, "Expected 3 RGB values, got #{values.length}"
268
+
269
+ unless [3, 4].include?(values.length)
270
+ raise ParseError, "Expected 3 or 4 RGB values, got #{values.length}"
185
271
  end
186
272
 
187
- # Check if all values are numeric (allow negative for clamping)
188
- values.each_with_index do |v, i|
273
+ r, g, b = parse_rgb_values(values[0..2])
274
+ alpha = values.length == 4 ? Color::Alpha.parse(values[3]) : nil
275
+
276
+ new(red: r, green: g, blue: b, alpha: alpha)
277
+ end
278
+
279
+ # Parse RGB component values
280
+ #
281
+ # @param values [Array<String>] Array of 3 RGB value strings
282
+ # @return [Array<Integer>] Array of 3 integers (0-255)
283
+ # @raise [ParseError] If values are invalid
284
+ def parse_rgb_values(values)
285
+ values.map.with_index do |v, i|
189
286
  unless v.match?(/\A-?\d+\z/)
190
287
  component = ["red", "green", "blue"][i]
191
288
  raise ParseError, "Invalid #{component} value: #{v.inspect} (must be a number)"
192
289
  end
290
+ v.to_i
193
291
  end
194
-
195
- # Convert to integers (constructor will clamp)
196
- parsed = values.map(&:to_i)
197
-
198
- new(red: parsed[0], green: parsed[1], blue: parsed[2])
199
292
  end
200
293
  end
201
294
 
@@ -211,16 +304,27 @@ module Unmagic
211
304
  # Convert to hexadecimal color string.
212
305
  #
213
306
  # Returns a lowercase hex string with hash prefix, always 6 characters
214
- # (2 per component).
307
+ # (2 per component). If alpha is less than 100%, includes 8 characters
308
+ # with alpha as the last 2 hex digits.
215
309
  #
216
- # @return [String] Hex color string like "#ff5733"
310
+ # @return [String] Hex color string like "#ff5733" or "#ff5733 80" with alpha
217
311
  #
218
- # @example
312
+ # @example Fully opaque color
219
313
  # rgb = RGB.new(red: 255, green: 87, blue: 51)
220
314
  # rgb.to_hex
221
315
  # # => "#ff5733"
316
+ #
317
+ # @example Semi-transparent color
318
+ # rgb = RGB.new(red: 255, green: 87, blue: 51, alpha: 50)
319
+ # rgb.to_hex
320
+ # # => "#ff573380"
222
321
  def to_hex
223
- format("#%02x%02x%02x", @red.value, @green.value, @blue.value)
322
+ if @alpha.value < 100
323
+ alpha_hex = (@alpha.to_ratio * 255).round.to_s(16).rjust(2, "0")
324
+ format("#%02x%02x%02x%s", @red.value, @green.value, @blue.value, alpha_hex)
325
+ else
326
+ format("#%02x%02x%02x", @red.value, @green.value, @blue.value)
327
+ end
224
328
  end
225
329
 
226
330
  # Convert to HSL color space.
@@ -265,7 +369,12 @@ module Unmagic
265
369
  end
266
370
  end
267
371
 
268
- Unmagic::Color::HSL.new(hue: (h * 360).round, saturation: (s * 100).round, lightness: (l * 100).round)
372
+ Unmagic::Color::HSL.new(
373
+ hue: (h * 360).round,
374
+ saturation: (s * 100).round,
375
+ lightness: (l * 100).round,
376
+ alpha: @alpha,
377
+ )
269
378
  end
270
379
 
271
380
  # Convert to OKLCH color space.
@@ -282,9 +391,9 @@ module Unmagic
282
391
  l = luminance
283
392
  # Approximate chroma from saturation and lightness
284
393
  hsl = to_hsl
285
- c = (hsl.saturation / 100.0) * 0.2 * (1 - (l - 0.5).abs * 2)
394
+ c = hsl.saturation.to_ratio * 0.2 * (1 - (l - 0.5).abs * 2)
286
395
  h = hsl.hue
287
- Unmagic::Color::OKLCH.new(lightness: l, chroma: c, hue: h)
396
+ Unmagic::Color::OKLCH.new(lightness: l, chroma: c, hue: h, alpha: @alpha)
288
397
  end
289
398
 
290
399
  # Calculate the relative luminance.
@@ -339,6 +448,7 @@ module Unmagic
339
448
  red: (@red.value * (1 - amount) + other_rgb.red.value * amount).round,
340
449
  green: (@green.value * (1 - amount) + other_rgb.green.value * amount).round,
341
450
  blue: (@blue.value * (1 - amount) + other_rgb.blue.value * amount).round,
451
+ alpha: @alpha.value * (1 - amount) + other_rgb.alpha.value * amount,
342
452
  )
343
453
  end
344
454
 
@@ -385,6 +495,199 @@ module Unmagic
385
495
  def to_s
386
496
  to_hex
387
497
  end
498
+
499
+ # Convert to ANSI SGR color code.
500
+ #
501
+ # Returns an ANSI Select Graphic Rendition (SGR) parameter string for terminal output.
502
+ # Supports multiple color modes for different terminal capabilities.
503
+ #
504
+ # @param layer [Symbol] Whether to generate foreground (:foreground) or background (:background) code
505
+ # @param mode [Symbol] Color format mode:
506
+ # - :truecolor (default) - 24-bit RGB (38;2;R;G;B or 48;2;R;G;B)
507
+ # - :palette256 - 256-color palette (38;5;N or 48;5;N)
508
+ # - :palette16 - 16-color palette (30-37, 90-97, 40-47, 100-107)
509
+ # @return [String] ANSI SGR code
510
+ # @raise [ArgumentError] If layer or mode is invalid
511
+ #
512
+ # @example Default mode (truecolor)
513
+ # red = RGB.new(red: 255, green: 0, blue: 0)
514
+ # red.to_ansi
515
+ # # => "38;2;255;0;0"
516
+ #
517
+ # @example Background color
518
+ # red = RGB.new(red: 255, green: 0, blue: 0)
519
+ # red.to_ansi(layer: :background)
520
+ # # => "48;2;255;0;0"
521
+ #
522
+ # @example True color mode (explicit)
523
+ # custom = RGB.new(red: 100, green: 150, blue: 200)
524
+ # custom.to_ansi(mode: :truecolor)
525
+ # # => "38;2;100;150;200"
526
+ #
527
+ # @example 256-color palette mode
528
+ # custom = RGB.new(red: 100, green: 150, blue: 200)
529
+ # custom.to_ansi(mode: :palette256)
530
+ # # => "38;5;67"
531
+ #
532
+ # @example 16-color palette mode
533
+ # custom = RGB.new(red: 100, green: 150, blue: 200)
534
+ # custom.to_ansi(mode: :palette16)
535
+ # # => "34"
536
+ def to_ansi(layer: :foreground, mode: :truecolor)
537
+ raise ArgumentError, "layer must be :foreground or :background" unless [:foreground, :background].include?(layer)
538
+ raise ArgumentError, "mode must be :truecolor, :palette256, or :palette16" unless [:truecolor, :palette256, :palette16].include?(mode)
539
+
540
+ case mode
541
+ when :truecolor
542
+ to_ansi_truecolor(layer)
543
+ when :palette256
544
+ to_ansi_palette256(layer)
545
+ when :palette16
546
+ to_ansi_palette16(layer)
547
+ end
548
+ end
549
+
550
+ private
551
+
552
+ # Convert to ANSI true color format (24-bit RGB).
553
+ #
554
+ # @param layer [Symbol] Foreground or background layer
555
+ # @return [String] ANSI SGR code
556
+ def to_ansi_truecolor(layer)
557
+ prefix = layer == :foreground ? 38 : 48
558
+ "#{prefix};2;#{@red.value};#{@green.value};#{@blue.value}"
559
+ end
560
+
561
+ # Convert to ANSI 256-color palette format.
562
+ #
563
+ # Finds the nearest color in the 256-color palette.
564
+ #
565
+ # @param layer [Symbol] Foreground or background layer
566
+ # @return [String] ANSI SGR code
567
+ def to_ansi_palette256(layer)
568
+ index = rgb_to_palette256
569
+ prefix = layer == :foreground ? 38 : 48
570
+ "#{prefix};5;#{index}"
571
+ end
572
+
573
+ # Convert to 16-color palette ANSI format.
574
+ #
575
+ # Finds the nearest of the 8 basic colors and uses bright variants.
576
+ #
577
+ # @param layer [Symbol] Foreground or background layer
578
+ # @return [String] ANSI SGR code
579
+ def to_ansi_palette16(layer)
580
+ index = rgb_to_palette16
581
+ prefix = layer == :foreground ? 90 : 100
582
+ (prefix + index).to_s
583
+ end
584
+
585
+ # Find the nearest color in the 256-color palette.
586
+ #
587
+ # @return [Integer] Palette index (0-255)
588
+ def rgb_to_palette256
589
+ r = @red.value
590
+ g = @green.value
591
+ b = @blue.value
592
+
593
+ # Check if it's grayscale (all components within small threshold)
594
+ if (r - g).abs < 10 && (r - b).abs < 10 && (g - b).abs < 10
595
+ # Use grayscale ramp (232-255)
596
+ gray = (r + g + b) / 3
597
+ if gray < 8
598
+ return 16 # Use first RGB cube entry for very dark
599
+ elsif gray > 238
600
+ return 231 # Use last RGB cube entry for very light
601
+ else
602
+ # Map to grayscale ramp: 232 + (0-23)
603
+ index = ((gray - 8) / 10.0).round
604
+ return 232 + index.clamp(0, 23)
605
+ end
606
+ end
607
+
608
+ # Find nearest in 6x6x6 RGB cube (16-231)
609
+ # Each component: 0, 95, 135, 175, 215, 255 (values 0-5)
610
+ r_index = rgb_to_cube_index(r)
611
+ g_index = rgb_to_cube_index(g)
612
+ b_index = rgb_to_cube_index(b)
613
+
614
+ 16 + (r_index * 36) + (g_index * 6) + b_index
615
+ end
616
+
617
+ # Convert RGB component to 6x6x6 cube index.
618
+ #
619
+ # @param value [Integer] RGB component value (0-255)
620
+ # @return [Integer] Cube index (0-5)
621
+ def rgb_to_cube_index(value)
622
+ # Cube values: 0, 95, 135, 175, 215, 255
623
+ # Thresholds: 47.5, 115, 155, 195, 235
624
+ if value < 48
625
+ 0
626
+ elsif value < 115
627
+ 1
628
+ elsif value < 155
629
+ 2
630
+ elsif value < 195
631
+ 3
632
+ elsif value < 235
633
+ 4
634
+ else
635
+ 5
636
+ end
637
+ end
638
+
639
+ # Find the nearest color in the 16-color palette (0-7).
640
+ #
641
+ # @return [Integer] Color index (0-7)
642
+ def rgb_to_palette16
643
+ # Standard ANSI colors
644
+ colors = [
645
+ [0, 0, 0], # 0: black
646
+ [255, 0, 0], # 1: red
647
+ [0, 255, 0], # 2: green
648
+ [255, 255, 0], # 3: yellow
649
+ [0, 0, 255], # 4: blue
650
+ [255, 0, 255], # 5: magenta
651
+ [0, 255, 255], # 6: cyan
652
+ [255, 255, 255], # 7: white
653
+ ]
654
+
655
+ r = @red.value
656
+ g = @green.value
657
+ b = @blue.value
658
+
659
+ min_distance = Float::INFINITY
660
+ nearest_index = 0
661
+
662
+ colors.each_with_index do |color, index|
663
+ cr, cg, cb = color
664
+ distance = ((r - cr)**2 + (g - cg)**2 + (b - cb)**2)
665
+ if distance < min_distance
666
+ min_distance = distance
667
+ nearest_index = index
668
+ end
669
+ end
670
+
671
+ nearest_index
672
+ end
673
+
674
+ public
675
+
676
+ # Pretty print support with colored swatch in class name.
677
+ #
678
+ # Outputs standard Ruby object format with a colored block character
679
+ # embedded in the class name area.
680
+ #
681
+ # @param pp [PrettyPrint] The pretty printer instance
682
+ #
683
+ # @example
684
+ # rgb = RGB.new(red: 255, green: 87, blue: 51)
685
+ # pp rgb
686
+ # # Outputs: #<Unmagic::Color::RGB[█] @red=255 @green=87 @blue=51>
687
+ # # (with colored █ block)
688
+ def pretty_print(pp)
689
+ pp.text("#<#{self.class.name}[\x1b[#{to_ansi(mode: :truecolor)}m█\x1b[0m] @red=#{@red.value} @green=#{@green.value} @blue=#{@blue.value}>")
690
+ end
388
691
  end
389
692
  end
390
693
  end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Color
5
+ # Unit types for color manipulation.
6
+ module Units
7
+ # Represents an angle in degrees (0-360°).
8
+ #
9
+ # Supports numeric values, degree strings, and named direction keywords.
10
+ # Values are automatically wrapped to the 0-360 range.
11
+ #
12
+ # ## Supported Formats
13
+ #
14
+ # - Numeric: `225`, `45.5`
15
+ # - Degree strings: `"225deg"`, `"45.5deg"`, `"-45deg"`, `"225°"`, `"45.5°"`
16
+ # - Named directions: `"top"`, `"bottom left"`, `"north"`, `"southwest"`, etc.
17
+ #
18
+ # ## Named Direction Keywords
19
+ #
20
+ # - Cardinal: `"top"` (0°), `"right"` (90°), `"bottom"` (180°), `"left"` (270°)
21
+ # - Diagonal: `"top right"` (45°), `"bottom right"` (135°), `"bottom left"` (225°), `"top left"` (315°)
22
+ # - Aliases: `"north"`, `"south"`, `"east"`, `"west"`, `"northeast"`, etc.
23
+ #
24
+ # @example Numeric values
25
+ # Degrees.build(225) #=> 225.0°
26
+ # Degrees.build(45.5) #=> 45.5°
27
+ # Degrees.build(-45) #=> 315.0° (wrapped)
28
+ #
29
+ # @example Degree strings
30
+ # Degrees.build("225deg") #=> 225.0°
31
+ # Degrees.build("45.5deg") #=> 45.5°
32
+ # Degrees.build("225°") #=> 225.0°
33
+ #
34
+ # @example Named directions
35
+ # Degrees.build("top") #=> 0.0°
36
+ # Degrees.build("bottom left") #=> 225.0°
37
+ # Degrees.build("north") #=> 0.0° (alias for "top")
38
+ #
39
+ # @example Constants
40
+ # Degrees::TOP #=> 0.0°
41
+ # Degrees::BOTTOM_LEFT #=> 225.0°
42
+ #
43
+ # @example String output
44
+ # Degrees::TOP.to_s #=> "top"
45
+ # Degrees::TOP.to_css #=> "0.0deg"
46
+ # Degrees.new(value: 123).to_s #=> "123.0°"
47
+ class Degrees
48
+ include Comparable
49
+
50
+ # Error raised when parsing invalid degree values.
51
+ class ParseError < Color::Error; end
52
+
53
+ attr_reader :value, :name, :aliases
54
+
55
+ class << self
56
+ # All predefined degree constants
57
+ #
58
+ # @return [Array<Degrees>] All constant degree values
59
+ def all
60
+ all_by_name.values.uniq
61
+ end
62
+
63
+ # Find a constant by name or alias
64
+ #
65
+ # @param search [String] Name or alias to search for
66
+ # @return [Degrees, nil] Matching constant or nil
67
+ def find_by_name(search)
68
+ normalized = search.strip.downcase
69
+ all_by_name.fetch(normalized)
70
+ rescue KeyError
71
+ nil
72
+ end
73
+
74
+ private
75
+
76
+ # Hash mapping all names and aliases to their constants
77
+ #
78
+ # @return [Hash<String, Degrees>] Name/alias to constant mapping
79
+ def all_by_name
80
+ @all_by_name ||= begin
81
+ constants = [TOP, RIGHT, BOTTOM, LEFT, TOP_RIGHT, BOTTOM_RIGHT, BOTTOM_LEFT, TOP_LEFT]
82
+ hash = {}
83
+ constants.each do |constant|
84
+ hash[constant.name] = constant
85
+ constant.aliases.each { |alias_name| hash[alias_name] = constant }
86
+ end
87
+ hash
88
+ end
89
+ end
90
+
91
+ public
92
+
93
+ # Build a Degrees instance from various input formats.
94
+ #
95
+ # @param input [Numeric, String, Degrees] The angle to parse
96
+ # @return [Degrees] Normalized degrees instance
97
+ # @raise [ParseError] If input format is invalid
98
+ #
99
+ # @example From number
100
+ # Degrees.build(225)
101
+ #
102
+ # @example From degree string
103
+ # Degrees.build("225deg")
104
+ #
105
+ # @example From CSS direction
106
+ # Degrees.build("to left top")
107
+ def build(input)
108
+ case input
109
+ when Degrees
110
+ input
111
+ when ::Numeric
112
+ new(value: input)
113
+ when ::String
114
+ parse(input)
115
+ else
116
+ raise ParseError, "Expected Numeric, String, or Degrees, got #{input.class}"
117
+ end
118
+ end
119
+
120
+ # Parse a degrees string.
121
+ #
122
+ # @param input [String] The string to parse
123
+ # @return [Degrees] Parsed degrees instance
124
+ # @raise [ParseError] If format is invalid
125
+ def parse(input)
126
+ raise ParseError, "Input must be a string" unless input.is_a?(::String)
127
+
128
+ input = input.strip
129
+
130
+ # Try to find a named constant first
131
+ constant = find_by_name(input)
132
+ return constant if constant
133
+
134
+ # Remove "deg" or "°" suffix if present
135
+ input = input.sub(/deg\z/i, "").sub(/°\z/, "")
136
+
137
+ # Try parsing as number
138
+ if input.match?(/\A-?\d+(?:\.\d+)?\z/)
139
+ return new(value: input.to_f)
140
+ end
141
+
142
+ raise ParseError, "Invalid degrees format: #{input.inspect}"
143
+ end
144
+ end
145
+
146
+ # Create a new Degrees instance.
147
+ #
148
+ # @param value [Numeric] Angle in degrees (wraps to 0-360 range)
149
+ # @param name [String, nil] Optional name for this degree (e.g., "top", "bottom")
150
+ # @param aliases [Array<String>] Optional aliases for this degree (e.g., ["north"])
151
+ def initialize(value:, name: nil, aliases: [])
152
+ @value = value.to_f % 360
153
+ @name = name
154
+ @aliases = aliases
155
+ end
156
+
157
+ # Convert to float value.
158
+ #
159
+ # @return [Float] Degrees value (0-360)
160
+ def to_f
161
+ @value
162
+ end
163
+
164
+ # Get the opposite direction (180 degrees away).
165
+ #
166
+ # @return [Degrees] Opposite degree
167
+ def opposite
168
+ opposite_value = (@value + 180) % 360
169
+ self.class.all.find { |d| d.value == opposite_value } || self.class.new(value: opposite_value)
170
+ end
171
+
172
+ # Convert to CSS string format.
173
+ #
174
+ # @return [String] CSS degree string (e.g., "225.0deg")
175
+ def to_css
176
+ "#{@value}deg"
177
+ end
178
+
179
+ # Convert to string representation.
180
+ #
181
+ # @return [String] Canonical string format that can be parsed back
182
+ def to_s
183
+ @name || "#{@value}°"
184
+ end
185
+
186
+ # Compare two Degrees instances.
187
+ #
188
+ # @param other [Degrees, Numeric] Value to compare
189
+ # @return [Integer, nil] Comparison result
190
+ def <=>(other)
191
+ case other
192
+ when Degrees
193
+ @value <=> other.value
194
+ when ::Numeric
195
+ @value <=> other.to_f
196
+ end
197
+ end
198
+
199
+ # Check equality.
200
+ #
201
+ # @param other [Object] Value to compare
202
+ # @return [Boolean] true if values are equal
203
+ def ==(other)
204
+ case other
205
+ when Degrees
206
+ @value == other.value
207
+ when ::Numeric
208
+ @value == other.to_f
209
+ else
210
+ false
211
+ end
212
+ end
213
+
214
+ # Predefined degree constants
215
+ TOP = new(value: 0, name: "top", aliases: ["north"]).freeze
216
+ # Right direction (90°, east)
217
+ RIGHT = new(value: 90, name: "right", aliases: ["east"]).freeze
218
+ # Bottom direction (180°, south)
219
+ BOTTOM = new(value: 180, name: "bottom", aliases: ["south"]).freeze
220
+ # Left direction (270°, west)
221
+ LEFT = new(value: 270, name: "left", aliases: ["west"]).freeze
222
+ # Top-right diagonal direction (45°, northeast)
223
+ TOP_RIGHT = new(value: 45, name: "top right", aliases: ["topright", "northeast", "north east"]).freeze
224
+ # Bottom-right diagonal direction (135°, southeast)
225
+ BOTTOM_RIGHT = new(value: 135, name: "bottom right", aliases: ["bottomright", "southeast", "south east"]).freeze
226
+ # Bottom-left diagonal direction (225°, southwest)
227
+ BOTTOM_LEFT = new(value: 225, name: "bottom left", aliases: ["bottomleft", "southwest", "south west"]).freeze
228
+ # Top-left diagonal direction (315°, northwest)
229
+ TOP_LEFT = new(value: 315, name: "top left", aliases: ["topleft", "northwest", "north west"]).freeze
230
+ end
231
+ end
232
+ end
233
+ end