Class: Unmagic::Color::HSL

Inherits:
Unmagic::Color show all
Defined in:
lib/unmagic/color/hsl.rb

Overview

‘HSL` (Hue, Saturation, Lightness) color representation.

## Understanding HSL

While RGB describes colors as mixing light, HSL describes colors in a way that’s more intuitive to humans. It separates the “what color” from “how vibrant” and “how bright.”

## The Three Components

  1. Hue (‘0-360°`): The actual color on the color wheel

    • ‘0°/360°` = Red

    • ‘60°` = Yellow

    • ‘120°` = Green

    • ‘180°` = Cyan

    • ‘240°` = Blue

    • ‘300°` = Magenta

    Think of it as rotating around a circle of colors.

  2. Saturation (‘0-100%`): How pure/intense the color is

    • ‘0%` = Gray (no color, just brightness)

    • ‘50%` = Moderate color

    • ‘100%` = Full, vivid color

    Think of it as “how much color” vs “how much gray.”

  3. Lightness (‘0-100%`): How bright the color is

    • ‘0%` = Black (no light)

    • ‘50%` = Pure color

    • ‘100%` = White (full light)

    Think of it as a dimmer switch.

## Why HSL is Useful

HSL makes it easy to:

  • Create color variations (keep hue, adjust saturation/lightness)

  • Generate color schemes (change hue by fixed amounts)

  • Make colors lighter/darker without changing their “color-ness”

## Common Patterns

  • **Pastel colors**: High lightness, medium-low saturation (‘70-80% L`, `30-50% S`)

  • **Vibrant colors**: Medium lightness, high saturation (‘50% L`, `80-100% S`)

  • **Dark colors**: Low lightness, any saturation (‘20-30% L`)

  • **Muted colors**: Medium lightness and saturation (‘40-60% L`, `30-50% S`)

## Examples

# Parse HSL colors
color = Unmagic::Color::HSL.parse("hsl(120, 100%, 50%)")  # Pure green
color = Unmagic::Color::HSL.parse("240, 50%, 75%")        # Light blue

# Create directly
red = Unmagic::Color::HSL.new(hue: 0, saturation: 100, lightness: 50)
pastel = Unmagic::Color::HSL.new(hue: 180, saturation: 40, lightness: 80)

# Access components
color.hue.value         #=> 120 (degrees)
color.saturation.value  #=> 100 (percent)
color.lightness.value   #=> 50 (percent)

# Easy color variations
lighter = color.lighten(0.2)    # Increase lightness
muted = color.desaturate(0.3)   # Reduce saturation

# Generate color from text
Unmagic::Color::HSL.derive("user@example.com".hash)  # Consistent color

Defined Under Namespace

Classes: ParseError

Constant Summary

Constants inherited from Unmagic::Color

Blue, Green, Red

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Unmagic::Color

[], #dark?, #light?

Constructor Details

#initialize(hue:, saturation:, lightness:) ⇒ HSL

Create a new HSL color.

Examples:

Create a pure red

HSL.new(hue: 0, saturation: 100, lightness: 50)

Create a pastel blue

HSL.new(hue: 240, saturation: 40, lightness: 80)

Parameters:

  • hue (Numeric)

    Hue in degrees (0-360), wraps around if outside range

  • saturation (Numeric)

    Saturation percentage (0-100), clamped to range

  • lightness (Numeric)

    Lightness percentage (0-100), clamped to range



91
92
93
94
95
96
# File 'lib/unmagic/color/hsl.rb', line 91

def initialize(hue:, saturation:, lightness:)
  super()
  @hue = Color::Hue.new(value: hue)
  @saturation = Color::Saturation.new(saturation)
  @lightness = Color::Lightness.new(lightness)
end

Instance Attribute Details

#hueObject (readonly)

Returns the value of attribute hue.



78
79
80
# File 'lib/unmagic/color/hsl.rb', line 78

def hue
  @hue
end

#lightnessObject (readonly)

Returns the value of attribute lightness.



78
79
80
# File 'lib/unmagic/color/hsl.rb', line 78

def lightness
  @lightness
end

#saturationObject (readonly)

Returns the value of attribute saturation.



78
79
80
# File 'lib/unmagic/color/hsl.rb', line 78

def saturation
  @saturation
end

Class Method Details

.derive(seed, lightness: 50, saturation_range: (40..80)) ⇒ HSL

Generate a deterministic HSL color from an integer seed.

Creates visually distinct, consistent colors from hash values. Particularly useful because HSL naturally spreads colors evenly around the color wheel.

Examples:

Generate user avatar color

user_color = HSL.derive("alice@example.com".hash)

Generate lighter colors

HSL.derive(12345, lightness: 70)

Generate muted colors

HSL.derive(12345, saturation_range: (20..40))

Parameters:

  • seed (Integer)

    The seed value (typically from a hash function)

  • lightness (Numeric) (defaults to: 50)

    Fixed lightness percentage (0-100, default 50)

  • saturation_range (Range) (defaults to: (40..80))

    Range for saturation variation (default 40..80)

Returns:

  • (HSL)

    A deterministic color based on the seed

Raises:

  • (ArgumentError)

    If seed is not an integer



184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/unmagic/color/hsl.rb', line 184

def derive(seed, lightness: 50, saturation_range: (40..80))
  raise ArgumentError, "Seed must be an integer" unless seed.is_a?(Integer)

  h32 = seed & 0xFFFFFFFF # Ensure 32-bit

  # Hue: distribute evenly across the color wheel
  h = (h32 % 360).to_f

  # Saturation: map a byte into the provided range
  s = saturation_range.begin + ((h32 >> 8) & 0xFF) / 255.0 * (saturation_range.end - saturation_range.begin)

  new(hue: h, saturation: s, lightness: lightness)
end

.parse(input) ⇒ HSL

Parse an HSL color from a string.

Accepts formats:

  • CSS format: “hsl(120, 100%, 50%)”

  • Raw values: “120, 100%, 50%” or “120, 100, 50”

  • Percentages optional for saturation and lightness

Examples:

Parse CSS format

HSL.parse("hsl(120, 100%, 50%)")

Parse without function wrapper

HSL.parse("240, 50%, 75%")

Parameters:

  • input (String)

    The HSL color string to parse

Returns:

  • (HSL)

    The parsed HSL color

Raises:

  • (ParseError)

    If the input format is invalid or values are out of range



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/unmagic/color/hsl.rb', line 115

def parse(input)
  raise ParseError, "Input must be a string" unless input.is_a?(::String)

  # Remove hsl() wrapper if present
  clean = input.gsub(/^hsl\s*\(\s*|\s*\)$/, "").strip

  # Split and parse values
  parts = clean.split(/\s*,\s*/)
  unless parts.length == 3
    raise ParseError, "Expected 3 HSL values, got #{parts.length}"
  end

  # Check if hue is numeric
  h_str = parts[0].strip
  unless h_str.match?(/\A\d+(\.\d+)?\z/)
    raise ParseError, "Invalid hue value: #{h_str.inspect} (must be a number)"
  end

  # Check if saturation and lightness are numeric (with optional %)
  s_str = parts[1].gsub("%", "").strip
  l_str = parts[2].gsub("%", "").strip

  unless s_str.match?(/\A\d+(\.\d+)?\z/)
    raise ParseError, "Invalid saturation value: #{parts[1].inspect} (must be a number with optional %)"
  end

  unless l_str.match?(/\A\d+(\.\d+)?\z/)
    raise ParseError, "Invalid lightness value: #{parts[2].inspect} (must be a number with optional %)"
  end

  h = h_str.to_f
  s = s_str.to_f
  l = l_str.to_f

  # Validate ranges
  if h < 0 || h > 360
    raise ParseError, "Hue must be between 0 and 360, got #{h}"
  end

  if s < 0 || s > 100
    raise ParseError, "Saturation must be between 0 and 100, got #{s}"
  end

  if l < 0 || l > 100
    raise ParseError, "Lightness must be between 0 and 100, got #{l}"
  end

  new(hue: h, saturation: s, lightness: l)
end

Instance Method Details

#==(other) ⇒ Boolean

Check if two HSL colors are equal.

Parameters:

  • other (Object)

    The object to compare with

Returns:

  • (Boolean)

    true if both colors have the same HSL values



298
299
300
301
302
303
# File 'lib/unmagic/color/hsl.rb', line 298

def ==(other)
  other.is_a?(Unmagic::Color::HSL) &&
    lightness == other.lightness &&
    saturation == other.saturation &&
    hue == other.hue
end

#blend(other, amount = 0.5) ⇒ HSL

Blend this color with another color in HSL space.

Blending in HSL can produce different results than RGB blending, often creating more natural-looking color transitions.

Examples:

Create a color halfway between red and blue

red = HSL.new(hue: 0, saturation: 100, lightness: 50)
blue = HSL.new(hue: 240, saturation: 100, lightness: 50)
purple = red.blend(blue, 0.5)

Parameters:

  • other (Color)

    The color to blend with (automatically converted to HSL)

  • amount (Float) (defaults to: 0.5)

    How much of the other color to mix in (0.0-1.0)

Returns:

  • (HSL)

    A new HSL color that is a blend of the two



248
249
250
251
252
253
254
255
256
257
258
# File 'lib/unmagic/color/hsl.rb', line 248

def blend(other, amount = 0.5)
  amount = amount.to_f.clamp(0, 1)
  other_hsl = other.respond_to?(:to_hsl) ? other.to_hsl : other

  # Blend in HSL space
  new_hue = @hue.value * (1 - amount) + other_hsl.hue.value * amount
  new_saturation = @saturation.value * (1 - amount) + other_hsl.saturation.value * amount
  new_lightness = @lightness.value * (1 - amount) + other_hsl.lightness.value * amount

  Unmagic::Color::HSL.new(hue: new_hue, saturation: new_saturation, lightness: new_lightness)
end

#darken(amount = 0.1) ⇒ HSL

Create a darker version by decreasing lightness.

In HSL, darkening moves the color toward black while preserving the hue. The amount determines how much to reduce the current lightness toward 0%.

Examples:

Make a color 20% darker

bright = HSL.new(hue: 60, saturation: 100, lightness: 70)
subdued = bright.darken(0.2)

Parameters:

  • amount (Float) (defaults to: 0.1)

    How much to darken (0.0-1.0, default 0.1)

Returns:

  • (HSL)

    A darker version of this color



288
289
290
291
292
# File 'lib/unmagic/color/hsl.rb', line 288

def darken(amount = 0.1)
  amount = amount.to_f.clamp(0, 1)
  new_lightness = @lightness.value * (1 - amount)
  Unmagic::Color::HSL.new(hue: @hue.value, saturation: @saturation.value, lightness: new_lightness)
end

#lighten(amount = 0.1) ⇒ HSL

Create a lighter version by increasing lightness.

In HSL, lightening moves the color toward white while preserving the hue. The amount determines how far to move from the current lightness toward 100%.

Examples:

Make a color 30% lighter

dark = HSL.new(hue: 240, saturation: 80, lightness: 30)
light = dark.lighten(0.3)

Parameters:

  • amount (Float) (defaults to: 0.1)

    How much to lighten (0.0-1.0, default 0.1)

Returns:

  • (HSL)

    A lighter version of this color



271
272
273
274
275
# File 'lib/unmagic/color/hsl.rb', line 271

def lighten(amount = 0.1)
  amount = amount.to_f.clamp(0, 1)
  new_lightness = @lightness.value + (100 - @lightness.value) * amount
  Unmagic::Color::HSL.new(hue: @hue.value, saturation: @saturation.value, lightness: new_lightness)
end

#luminanceFloat

Calculate the relative luminance.

Converts to RGB first, then calculates luminance.

Returns:

  • (Float)

    Luminance from 0.0 (black) to 1.0 (white)



231
232
233
# File 'lib/unmagic/color/hsl.rb', line 231

def luminance
  to_rgb.luminance
end

#progression(steps:, lightness:, saturation: nil) ⇒ Array<HSL>

Generate a progression of colors by varying lightness and saturation.

This creates an array of related colors, useful for color scales in UI design (like shades of blue from light to dark).

The lightness and saturation can be provided as:

  • Array: Specific values for each step (last value repeats if array is shorter)

  • Proc: Dynamic calculation based on the base color and step index

Examples:

Create a 5-step lightness progression

base = Unmagic::Color::HSL.new(hue: 240, saturation: 80, lightness: 50)
base.progression(steps: 5, lightness: [20, 35, 50, 65, 80])

Dynamic lightness calculation

base = Unmagic::Color::HSL.new(hue: 240, saturation: 80, lightness: 50)
base.progression(steps: 7, lightness: ->(hsl, i) { 20 + (i * 12) })

Vary both lightness and saturation

base = Unmagic::Color::HSL.new(hue: 240, saturation: 80, lightness: 50)
base.progression(steps: 5, lightness: [30, 45, 60, 75, 90], saturation: [100, 80, 60, 40, 20])

Parameters:

  • steps (Integer)

    Number of colors to generate (must be at least 1)

  • lightness (Array<Numeric>, Proc)

    Lightness values or calculation function

  • saturation (Array<Numeric>, Proc, nil) (defaults to: nil)

    Optional saturation values or function

Returns:

  • (Array<HSL>)

    Array of HSL colors in the progression

Raises:

  • (ArgumentError)

    If steps < 1 or lightness/saturation are invalid types



331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
# File 'lib/unmagic/color/hsl.rb', line 331

def progression(steps:, lightness:, saturation: nil)
  raise ArgumentError, "steps must be at least 1" if steps < 1
  raise ArgumentError, "lightness must be a proc or array" unless lightness.respond_to?(:call) || lightness.is_a?(Array)
  raise ArgumentError, "saturation must be a proc or array" if saturation && !saturation.respond_to?(:call) && !saturation.is_a?(Array)

  colors = []

  (0...steps).each do |i|
    # Calculate new lightness using the provided proc or array
    new_lightness = if lightness.is_a?(Array)
      # Use array value at index i, or last value if beyond array length
      lightness[i] || lightness.last
    else
      lightness.call(self, i)
    end
    new_lightness = new_lightness.to_f.clamp(0, 100)

    # Calculate new saturation using the provided proc/array or keep current
    new_saturation = if saturation
      if saturation.is_a?(Array)
        # Use array value at index i, or last value if beyond array length
        (saturation[i] || saturation.last).to_f.clamp(0, 100)
      else
        saturation.call(self, i).to_f.clamp(0, 100)
      end
    else
      @saturation.value
    end

    # Create new HSL color with computed values
    color = self.class.new(hue: @hue.value, saturation: new_saturation, lightness: new_lightness)
    colors << color
  end

  colors
end

#to_hslHSL

Convert to HSL color space.

Since this is already an HSL color, returns self.

Returns:

  • (HSL)

    self



204
205
206
# File 'lib/unmagic/color/hsl.rb', line 204

def to_hsl
  self
end

#to_oklchOKLCH

Convert to OKLCH color space.

Converts via RGB as an intermediate step.

Returns:

  • (OKLCH)

    The color in OKLCH color space



222
223
224
# File 'lib/unmagic/color/hsl.rb', line 222

def to_oklch
  to_rgb.to_oklch
end

#to_rgbRGB

Convert to RGB color space.

Returns:

  • (RGB)

    The color in RGB color space



211
212
213
214
215
# File 'lib/unmagic/color/hsl.rb', line 211

def to_rgb
  rgb = hsl_to_rgb
  require_relative "rgb"
  Unmagic::Color::RGB.new(red: rgb[0], green: rgb[1], blue: rgb[2])
end

#to_sString

Convert to string representation.

Returns the CSS hsl() function format.

Examples:

color = HSL.new(hue: 240, saturation: 80, lightness: 50)
color.to_s
# => "hsl(240, 80.0%, 50.0%)"

Returns:

  • (String)

    HSL string like “hsl(240, 80%, 50%)”



378
379
380
# File 'lib/unmagic/color/hsl.rb', line 378

def to_s
  "hsl(#{@hue.value.round}, #{@saturation.value}%, #{@lightness.value}%)"
end