unmagic-color 0.1.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.
@@ -0,0 +1,421 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Color
5
+ # `HSL` (Hue, Saturation, Lightness) color representation.
6
+ #
7
+ # ## Understanding HSL
8
+ #
9
+ # While {RGB} describes colors as mixing light, HSL describes colors in a way that's
10
+ # more intuitive to humans. It separates the "what color" from "how vibrant" and "how bright."
11
+ #
12
+ # ## The Three Components
13
+ #
14
+ # 1. **Hue** (`0-360°`): The actual color on the color wheel
15
+ # - `0°/360°` = Red
16
+ # - `60°` = Yellow
17
+ # - `120°` = Green
18
+ # - `180°` = Cyan
19
+ # - `240°` = Blue
20
+ # - `300°` = Magenta
21
+ #
22
+ # Think of it as rotating around a circle of colors.
23
+ #
24
+ # 2. **Saturation** (`0-100%`): How pure/intense the color is
25
+ # - `0%` = Gray (no color, just brightness)
26
+ # - `50%` = Moderate color
27
+ # - `100%` = Full, vivid color
28
+ #
29
+ # Think of it as "how much color" vs "how much gray."
30
+ #
31
+ # 3. **Lightness** (`0-100%`): How bright the color is
32
+ # - `0%` = Black (no light)
33
+ # - `50%` = Pure color
34
+ # - `100%` = White (full light)
35
+ #
36
+ # Think of it as a dimmer switch.
37
+ #
38
+ # ## Why HSL is Useful
39
+ #
40
+ # HSL makes it easy to:
41
+ #
42
+ # - Create color variations (keep hue, adjust saturation/lightness)
43
+ # - Generate color schemes (change hue by fixed amounts)
44
+ # - Make colors lighter/darker without changing their "color-ness"
45
+ #
46
+ # ## Common Patterns
47
+ #
48
+ # - **Pastel colors**: High lightness, medium-low saturation (`70-80% L`, `30-50% S`)
49
+ # - **Vibrant colors**: Medium lightness, high saturation (`50% L`, `80-100% S`)
50
+ # - **Dark colors**: Low lightness, any saturation (`20-30% L`)
51
+ # - **Muted colors**: Medium lightness and saturation (`40-60% L`, `30-50% S`)
52
+ #
53
+ # ## Examples
54
+ #
55
+ # # Parse HSL colors
56
+ # color = Unmagic::Color::HSL.parse("hsl(120, 100%, 50%)") # Pure green
57
+ # color = Unmagic::Color::HSL.parse("240, 50%, 75%") # Light blue
58
+ #
59
+ # # Create directly
60
+ # red = Unmagic::Color::HSL.new(hue: 0, saturation: 100, lightness: 50)
61
+ # pastel = Unmagic::Color::HSL.new(hue: 180, saturation: 40, lightness: 80)
62
+ #
63
+ # # Access components
64
+ # color.hue.value #=> 120 (degrees)
65
+ # color.saturation.value #=> 100 (percent)
66
+ # color.lightness.value #=> 50 (percent)
67
+ #
68
+ # # Easy color variations
69
+ # lighter = color.lighten(0.2) # Increase lightness
70
+ # muted = color.desaturate(0.3) # Reduce saturation
71
+ #
72
+ # # Generate color from text
73
+ # Unmagic::Color::HSL.derive("user@example.com".hash) # Consistent color
74
+ class HSL < Color
75
+ # Error raised when parsing HSL color strings fails
76
+ class ParseError < Color::Error; end
77
+
78
+ attr_reader :hue, :saturation, :lightness
79
+
80
+ # Create a new HSL color.
81
+ #
82
+ # @param hue [Numeric] Hue in degrees (0-360), wraps around if outside range
83
+ # @param saturation [Numeric] Saturation percentage (0-100), clamped to range
84
+ # @param lightness [Numeric] Lightness percentage (0-100), clamped to range
85
+ #
86
+ # @example Create a pure red
87
+ # HSL.new(hue: 0, saturation: 100, lightness: 50)
88
+ #
89
+ # @example Create a pastel blue
90
+ # HSL.new(hue: 240, saturation: 40, lightness: 80)
91
+ def initialize(hue:, saturation:, lightness:)
92
+ super()
93
+ @hue = Color::Hue.new(value: hue)
94
+ @saturation = Color::Saturation.new(saturation)
95
+ @lightness = Color::Lightness.new(lightness)
96
+ end
97
+
98
+ class << self
99
+ # Parse an HSL color from a string.
100
+ #
101
+ # Accepts formats:
102
+ # - CSS format: "hsl(120, 100%, 50%)"
103
+ # - Raw values: "120, 100%, 50%" or "120, 100, 50"
104
+ # - Percentages optional for saturation and lightness
105
+ #
106
+ # @param input [String] The HSL color string to parse
107
+ # @return [HSL] The parsed HSL color
108
+ # @raise [ParseError] If the input format is invalid or values are out of range
109
+ #
110
+ # @example Parse CSS format
111
+ # HSL.parse("hsl(120, 100%, 50%)")
112
+ #
113
+ # @example Parse without function wrapper
114
+ # HSL.parse("240, 50%, 75%")
115
+ def parse(input)
116
+ raise ParseError, "Input must be a string" unless input.is_a?(::String)
117
+
118
+ # Remove hsl() wrapper if present
119
+ clean = input.gsub(/^hsl\s*\(\s*|\s*\)$/, "").strip
120
+
121
+ # Split and parse values
122
+ parts = clean.split(/\s*,\s*/)
123
+ unless parts.length == 3
124
+ raise ParseError, "Expected 3 HSL values, got #{parts.length}"
125
+ end
126
+
127
+ # Check if hue is numeric
128
+ h_str = parts[0].strip
129
+ unless h_str.match?(/\A\d+(\.\d+)?\z/)
130
+ raise ParseError, "Invalid hue value: #{h_str.inspect} (must be a number)"
131
+ end
132
+
133
+ # Check if saturation and lightness are numeric (with optional %)
134
+ s_str = parts[1].gsub("%", "").strip
135
+ l_str = parts[2].gsub("%", "").strip
136
+
137
+ unless s_str.match?(/\A\d+(\.\d+)?\z/)
138
+ raise ParseError, "Invalid saturation value: #{parts[1].inspect} (must be a number with optional %)"
139
+ end
140
+
141
+ unless l_str.match?(/\A\d+(\.\d+)?\z/)
142
+ raise ParseError, "Invalid lightness value: #{parts[2].inspect} (must be a number with optional %)"
143
+ end
144
+
145
+ h = h_str.to_f
146
+ s = s_str.to_f
147
+ l = l_str.to_f
148
+
149
+ # Validate ranges
150
+ if h < 0 || h > 360
151
+ raise ParseError, "Hue must be between 0 and 360, got #{h}"
152
+ end
153
+
154
+ if s < 0 || s > 100
155
+ raise ParseError, "Saturation must be between 0 and 100, got #{s}"
156
+ end
157
+
158
+ if l < 0 || l > 100
159
+ raise ParseError, "Lightness must be between 0 and 100, got #{l}"
160
+ end
161
+
162
+ new(hue: h, saturation: s, lightness: l)
163
+ end
164
+
165
+ # Generate a deterministic HSL color from an integer seed.
166
+ #
167
+ # Creates visually distinct, consistent colors from hash values. Particularly
168
+ # useful because HSL naturally spreads colors evenly around the color wheel.
169
+ #
170
+ # @param seed [Integer] The seed value (typically from a hash function)
171
+ # @param lightness [Numeric] Fixed lightness percentage (0-100, default 50)
172
+ # @param saturation_range [Range] Range for saturation variation (default 40..80)
173
+ # @return [HSL] A deterministic color based on the seed
174
+ # @raise [ArgumentError] If seed is not an integer
175
+ #
176
+ # @example Generate user avatar color
177
+ # user_color = HSL.derive("alice@example.com".hash)
178
+ #
179
+ # @example Generate lighter colors
180
+ # HSL.derive(12345, lightness: 70)
181
+ #
182
+ # @example Generate muted colors
183
+ # HSL.derive(12345, saturation_range: (20..40))
184
+ def derive(seed, lightness: 50, saturation_range: (40..80))
185
+ raise ArgumentError, "Seed must be an integer" unless seed.is_a?(Integer)
186
+
187
+ h32 = seed & 0xFFFFFFFF # Ensure 32-bit
188
+
189
+ # Hue: distribute evenly across the color wheel
190
+ h = (h32 % 360).to_f
191
+
192
+ # Saturation: map a byte into the provided range
193
+ s = saturation_range.begin + ((h32 >> 8) & 0xFF) / 255.0 * (saturation_range.end - saturation_range.begin)
194
+
195
+ new(hue: h, saturation: s, lightness: lightness)
196
+ end
197
+ end
198
+
199
+ # Convert to HSL color space.
200
+ #
201
+ # Since this is already an HSL color, returns self.
202
+ #
203
+ # @return [HSL] self
204
+ def to_hsl
205
+ self
206
+ end
207
+
208
+ # Convert to RGB color space.
209
+ #
210
+ # @return [RGB] The color in RGB color space
211
+ def to_rgb
212
+ rgb = hsl_to_rgb
213
+ require_relative "rgb"
214
+ Unmagic::Color::RGB.new(red: rgb[0], green: rgb[1], blue: rgb[2])
215
+ end
216
+
217
+ # Convert to OKLCH color space.
218
+ #
219
+ # Converts via RGB as an intermediate step.
220
+ #
221
+ # @return [OKLCH] The color in OKLCH color space
222
+ def to_oklch
223
+ to_rgb.to_oklch
224
+ end
225
+
226
+ # Calculate the relative luminance.
227
+ #
228
+ # Converts to RGB first, then calculates luminance.
229
+ #
230
+ # @return [Float] Luminance from 0.0 (black) to 1.0 (white)
231
+ def luminance
232
+ to_rgb.luminance
233
+ end
234
+
235
+ # Blend this color with another color in HSL space.
236
+ #
237
+ # Blending in HSL can produce different results than RGB blending,
238
+ # often creating more natural-looking color transitions.
239
+ #
240
+ # @param other [Color] The color to blend with (automatically converted to HSL)
241
+ # @param amount [Float] How much of the other color to mix in (0.0-1.0)
242
+ # @return [HSL] A new HSL color that is a blend of the two
243
+ #
244
+ # @example Create a color halfway between red and blue
245
+ # red = HSL.new(hue: 0, saturation: 100, lightness: 50)
246
+ # blue = HSL.new(hue: 240, saturation: 100, lightness: 50)
247
+ # purple = red.blend(blue, 0.5)
248
+ def blend(other, amount = 0.5)
249
+ amount = amount.to_f.clamp(0, 1)
250
+ other_hsl = other.respond_to?(:to_hsl) ? other.to_hsl : other
251
+
252
+ # Blend in HSL space
253
+ new_hue = @hue.value * (1 - amount) + other_hsl.hue.value * amount
254
+ new_saturation = @saturation.value * (1 - amount) + other_hsl.saturation.value * amount
255
+ new_lightness = @lightness.value * (1 - amount) + other_hsl.lightness.value * amount
256
+
257
+ Unmagic::Color::HSL.new(hue: new_hue, saturation: new_saturation, lightness: new_lightness)
258
+ end
259
+
260
+ # Create a lighter version by increasing lightness.
261
+ #
262
+ # In HSL, lightening moves the color toward white while preserving the hue.
263
+ # The amount determines how far to move from the current lightness toward 100%.
264
+ #
265
+ # @param amount [Float] How much to lighten (0.0-1.0, default 0.1)
266
+ # @return [HSL] A lighter version of this color
267
+ #
268
+ # @example Make a color 30% lighter
269
+ # dark = HSL.new(hue: 240, saturation: 80, lightness: 30)
270
+ # light = dark.lighten(0.3)
271
+ def lighten(amount = 0.1)
272
+ amount = amount.to_f.clamp(0, 1)
273
+ new_lightness = @lightness.value + (100 - @lightness.value) * amount
274
+ Unmagic::Color::HSL.new(hue: @hue.value, saturation: @saturation.value, lightness: new_lightness)
275
+ end
276
+
277
+ # Create a darker version by decreasing lightness.
278
+ #
279
+ # In HSL, darkening moves the color toward black while preserving the hue.
280
+ # The amount determines how much to reduce the current lightness toward 0%.
281
+ #
282
+ # @param amount [Float] How much to darken (0.0-1.0, default 0.1)
283
+ # @return [HSL] A darker version of this color
284
+ #
285
+ # @example Make a color 20% darker
286
+ # bright = HSL.new(hue: 60, saturation: 100, lightness: 70)
287
+ # subdued = bright.darken(0.2)
288
+ def darken(amount = 0.1)
289
+ amount = amount.to_f.clamp(0, 1)
290
+ new_lightness = @lightness.value * (1 - amount)
291
+ Unmagic::Color::HSL.new(hue: @hue.value, saturation: @saturation.value, lightness: new_lightness)
292
+ end
293
+
294
+ # Check if two HSL colors are equal.
295
+ #
296
+ # @param other [Object] The object to compare with
297
+ # @return [Boolean] true if both colors have the same HSL values
298
+ def ==(other)
299
+ other.is_a?(Unmagic::Color::HSL) &&
300
+ lightness == other.lightness &&
301
+ saturation == other.saturation &&
302
+ hue == other.hue
303
+ end
304
+
305
+ # Generate a progression of colors by varying lightness and saturation.
306
+ #
307
+ # This creates an array of related colors, useful for color scales in UI design
308
+ # (like shades of blue from light to dark).
309
+ #
310
+ # The lightness and saturation can be provided as:
311
+ # - Array: Specific values for each step (last value repeats if array is shorter)
312
+ # - Proc: Dynamic calculation based on the base color and step index
313
+ #
314
+ # @param steps [Integer] Number of colors to generate (must be at least 1)
315
+ # @param lightness [Array<Numeric>, Proc] Lightness values or calculation function
316
+ # @param saturation [Array<Numeric>, Proc, nil] Optional saturation values or function
317
+ # @return [Array<HSL>] Array of HSL colors in the progression
318
+ # @raise [ArgumentError] If steps < 1 or lightness/saturation are invalid types
319
+ #
320
+ # @example Create a 5-step lightness progression
321
+ # base = Unmagic::Color::HSL.new(hue: 240, saturation: 80, lightness: 50)
322
+ # base.progression(steps: 5, lightness: [20, 35, 50, 65, 80])
323
+ #
324
+ # @example Dynamic lightness calculation
325
+ # base = Unmagic::Color::HSL.new(hue: 240, saturation: 80, lightness: 50)
326
+ # base.progression(steps: 7, lightness: ->(hsl, i) { 20 + (i * 12) })
327
+ #
328
+ # @example Vary both lightness and saturation
329
+ # base = Unmagic::Color::HSL.new(hue: 240, saturation: 80, lightness: 50)
330
+ # base.progression(steps: 5, lightness: [30, 45, 60, 75, 90], saturation: [100, 80, 60, 40, 20])
331
+ def progression(steps:, lightness:, saturation: nil)
332
+ raise ArgumentError, "steps must be at least 1" if steps < 1
333
+ raise ArgumentError, "lightness must be a proc or array" unless lightness.respond_to?(:call) || lightness.is_a?(Array)
334
+ raise ArgumentError, "saturation must be a proc or array" if saturation && !saturation.respond_to?(:call) && !saturation.is_a?(Array)
335
+
336
+ colors = []
337
+
338
+ (0...steps).each do |i|
339
+ # Calculate new lightness using the provided proc or array
340
+ new_lightness = if lightness.is_a?(Array)
341
+ # Use array value at index i, or last value if beyond array length
342
+ lightness[i] || lightness.last
343
+ else
344
+ lightness.call(self, i)
345
+ end
346
+ new_lightness = new_lightness.to_f.clamp(0, 100)
347
+
348
+ # Calculate new saturation using the provided proc/array or keep current
349
+ new_saturation = if saturation
350
+ if saturation.is_a?(Array)
351
+ # Use array value at index i, or last value if beyond array length
352
+ (saturation[i] || saturation.last).to_f.clamp(0, 100)
353
+ else
354
+ saturation.call(self, i).to_f.clamp(0, 100)
355
+ end
356
+ else
357
+ @saturation.value
358
+ end
359
+
360
+ # Create new HSL color with computed values
361
+ color = self.class.new(hue: @hue.value, saturation: new_saturation, lightness: new_lightness)
362
+ colors << color
363
+ end
364
+
365
+ colors
366
+ end
367
+
368
+ # Convert to string representation.
369
+ #
370
+ # Returns the CSS hsl() function format.
371
+ #
372
+ # @return [String] HSL string like "hsl(240, 80%, 50%)"
373
+ #
374
+ # @example
375
+ # color = HSL.new(hue: 240, saturation: 80, lightness: 50)
376
+ # color.to_s
377
+ # # => "hsl(240, 80.0%, 50.0%)"
378
+ def to_s
379
+ "hsl(#{@hue.value.round}, #{@saturation.value}%, #{@lightness.value}%)"
380
+ end
381
+
382
+ private
383
+
384
+ def hsl_to_rgb
385
+ h = @hue.value / 360.0
386
+ s = @saturation.to_ratio # Convert percentage to 0-1
387
+ l = @lightness.to_ratio # Convert percentage to 0-1
388
+
389
+ if s == 0
390
+ # Achromatic
391
+ gray = (l * 255).round
392
+ [gray, gray, gray]
393
+ else
394
+ q = l < 0.5 ? l * (1 + s) : l + s - l * s
395
+ p = 2 * l - q
396
+
397
+ r = hue_to_rgb(p, q, h + 1 / 3.0)
398
+ g = hue_to_rgb(p, q, h)
399
+ b = hue_to_rgb(p, q, h - 1 / 3.0)
400
+
401
+ [(r * 255).round, (g * 255).round, (b * 255).round]
402
+ end
403
+ end
404
+
405
+ def hue_to_rgb(p, q, t)
406
+ t += 1 if t < 0
407
+ t -= 1 if t > 1
408
+
409
+ if t < 1 / 6.0
410
+ p + (q - p) * 6 * t
411
+ elsif t < 1 / 2.0
412
+ q
413
+ elsif t < 2 / 3.0
414
+ p + (q - p) * (2 / 3.0 - t) * 6
415
+ else
416
+ p
417
+ end
418
+ end
419
+ end
420
+ end
421
+ end