Class: Unmagic::Color::HSL
- Inherits:
-
Unmagic::Color
- Object
- Unmagic::Color
- Unmagic::Color::HSL
- Defined in:
- lib/unmagic/color/hsl.rb,
lib/unmagic/color/hsl/gradient/linear.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
-
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.
-
-
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.”
-
-
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
Modules: Gradient Classes: ParseError
Constant Summary
Constants inherited from Unmagic::Color
Blue, DATA_PATH, Green, Red, VERSION
Constants included from Harmony
Unmagic::Color::Harmony::SCALE_CHROMA_CURVE, Unmagic::Color::Harmony::SCALE_LIGHTNESS_DEFAULT, Unmagic::Color::Harmony::SCALE_LIGHTNESS_SHAPE
Instance Attribute Summary collapse
-
#alpha ⇒ Object
readonly
Returns the value of attribute alpha.
-
#hue ⇒ Object
readonly
Returns the value of attribute hue.
-
#lightness ⇒ Object
readonly
Returns the value of attribute lightness.
-
#saturation ⇒ Object
readonly
Returns the value of attribute saturation.
Class Method Summary collapse
-
.build(*args, **kwargs) ⇒ HSL
Build an HSL color from a string, positional values, or keyword arguments.
-
.derive(seed, lightness: 50, saturation_range: (40..80)) ⇒ HSL
Generate a deterministic HSL color from an integer seed.
-
.parse(input) ⇒ HSL
Parse an HSL color from a string.
Instance Method Summary collapse
-
#==(other) ⇒ Boolean
Check if two HSL colors are equal.
-
#blend(other, amount = 0.5) ⇒ HSL
Blend this color with another color in HSL space.
-
#darken(amount = 0.1) ⇒ HSL
Create a darker version by decreasing lightness.
-
#initialize(hue:, saturation:, lightness:, alpha: nil) ⇒ HSL
constructor
Create a new HSL color.
-
#lighten(amount = 0.1) ⇒ HSL
Create a lighter version by increasing lightness.
-
#luminance ⇒ Float
Calculate the relative luminance.
-
#pretty_print(pp) ⇒ Object
Pretty print support with colored swatch in class name.
-
#progression(steps:, lightness:, saturation: nil) ⇒ Array<HSL>
Generate a progression of colors by varying lightness and saturation.
-
#to_ansi(layer: :foreground, mode: :truecolor) ⇒ String
Convert to ANSI SGR color code.
-
#to_hex ⇒ String
Convert to hex string.
-
#to_hsl ⇒ HSL
Convert to HSL color space.
-
#to_oklch ⇒ OKLCH
Convert to OKLCH color space.
-
#to_rgb ⇒ RGB
Convert to RGB color space.
-
#to_s ⇒ String
Convert to string representation.
Methods inherited from Unmagic::Color
Methods included from Harmony
#analogous, #complementary, #monochromatic, #scale, #shades, #split_complementary, #tetradic_rectangle, #tetradic_square, #tints, #tones, #triadic
Constructor Details
#initialize(hue:, saturation:, lightness:, alpha: nil) ⇒ HSL
Create a new HSL color.
95 96 97 98 99 100 101 |
# File 'lib/unmagic/color/hsl.rb', line 95 def initialize(hue:, saturation:, lightness:, alpha: nil) super() @hue = Color::Hue.new(value: hue) @saturation = Color::Saturation.new(value: saturation) @lightness = Color::Lightness.new(value: lightness) @alpha = Color::Alpha.build(alpha) || Color::Alpha::DEFAULT end |
Instance Attribute Details
#alpha ⇒ Object (readonly)
Returns the value of attribute alpha.
78 79 80 |
# File 'lib/unmagic/color/hsl.rb', line 78 def alpha @alpha end |
#hue ⇒ Object (readonly)
Returns the value of attribute hue.
78 79 80 |
# File 'lib/unmagic/color/hsl.rb', line 78 def hue @hue end |
#lightness ⇒ Object (readonly)
Returns the value of attribute lightness.
78 79 80 |
# File 'lib/unmagic/color/hsl.rb', line 78 def lightness @lightness end |
#saturation ⇒ Object (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
.build(*args, **kwargs) ⇒ HSL
Build an HSL color from a string, positional values, or keyword arguments.
220 221 222 223 224 225 226 227 228 229 230 231 |
# File 'lib/unmagic/color/hsl.rb', line 220 def build(*args, **kwargs) if kwargs.any? new(**kwargs) elsif args.length == 1 parse(args[0]) elsif args.length == 3 values = args.map { |v| v.is_a?(::String) ? v.to_f : v } new(hue: values[0], saturation: values[1], lightness: values[2]) else raise ArgumentError, "Expected 1 or 3 arguments, got #{args.length}" end end |
.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.
252 253 254 255 256 257 258 259 260 261 262 263 264 |
# File 'lib/unmagic/color/hsl.rb', line 252 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:
-
Legacy: “hsl(120, 100%, 50%)” or “hsla(120, 100%, 50%, 0.5)”
-
Modern: “hsl(120 100% 50% / 0.5)” or “hsl(120 100% 50% / 50%)”
-
Raw values: “120, 100%, 50%” or “120, 100, 50”
-
Percentages optional for saturation and lightness
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 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 |
# File 'lib/unmagic/color/hsl.rb', line 124 def parse(input) raise ParseError, "Input must be a string" unless input.is_a?(::String) # Remove hsl() or hsla() wrapper if present clean = input.gsub(/^hsla?\s*\(\s*|\s*\)$/, "").strip # Check for modern format with slash (space-separated with / for alpha) # Example: "120 100% 50% / 0.5" # Note: Modern format is only used WITH the hsl() wrapper alpha = nil has_slash = clean.include?("/") if has_slash parts = clean.split("/").map(&:strip) raise ParseError, "Invalid format with /: expected 'H S% L% / alpha'" unless parts.length == 2 clean = parts[0] alpha = Color::Alpha.parse(parts[1]) end # Split HSL values # - Comma-separated: legacy format (with or without hsl() wrapper) # - Space-separated: only valid WITH hsl() wrapper (modern format) parts = if clean.include?(",") # Legacy comma-separated format clean.split(/\s*,\s*/) elsif has_slash || input.match?(/^hsla?\s*\(/) # Modern space-separated format (only with hsl() wrapper or slash) clean.split(/\s+/) else # No commas and no hsl() wrapper - invalid raise ParseError, "Space-separated values require hsl() wrapper, use commas for raw values" end unless [3, 4].include?(parts.length) raise ParseError, "Expected 3 or 4 HSL values, got #{parts.length}" end # Parse alpha from 4th value if present (legacy format) if parts.length == 4 && alpha.nil? alpha = Color::Alpha.parse(parts[3]) 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, alpha: alpha) end |
Instance Method Details
#==(other) ⇒ Boolean
Check if two HSL colors are equal.
380 381 382 383 384 385 |
# File 'lib/unmagic/color/hsl.rb', line 380 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.
325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 |
# File 'lib/unmagic/color/hsl.rb', line 325 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, alpha: @alpha.value * (1 - amount) + other_hsl.alpha.value * amount, ) 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%.
370 371 372 373 374 |
# File 'lib/unmagic/color/hsl.rb', line 370 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, alpha: @alpha.value) 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%.
353 354 355 356 357 |
# File 'lib/unmagic/color/hsl.rb', line 353 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, alpha: @alpha.value) end |
#luminance ⇒ Float
Calculate the relative luminance.
Converts to RGB first, then calculates luminance.
308 309 310 |
# File 'lib/unmagic/color/hsl.rb', line 308 def luminance to_rgb.luminance end |
#pretty_print(pp) ⇒ Object
Pretty print support with colored swatch in class name.
Outputs standard Ruby object format with a colored block character embedded in the class name area.
502 503 504 |
# File 'lib/unmagic/color/hsl.rb', line 502 def pretty_print(pp) pp.text("#<#{self.class.name}[\x1b[#{to_ansi(mode: :truecolor)}m█\x1b[0m] @hue=#{@hue.value.round} @saturation=#{@saturation.value.round} @lightness=#{@lightness.value.round}>") 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
413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 |
# File 'lib/unmagic/color/hsl.rb', line 413 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.build(hue: @hue.value, saturation: new_saturation, lightness: new_lightness, alpha: @alpha.value) colors << color end colors end |
#to_ansi(layer: :foreground, mode: :truecolor) ⇒ String
Convert to ANSI SGR color code.
Converts to RGB first, then generates the ANSI code.
486 487 488 |
# File 'lib/unmagic/color/hsl.rb', line 486 def to_ansi(layer: :foreground, mode: :truecolor) to_rgb.to_ansi(layer: layer, mode: mode) end |
#to_hex ⇒ String
Convert to hex string.
Converts via RGB as an intermediate step.
299 300 301 |
# File 'lib/unmagic/color/hsl.rb', line 299 def to_hex to_rgb.to_hex end |
#to_hsl ⇒ HSL
Convert to HSL color space.
Since this is already an HSL color, returns self.
272 273 274 |
# File 'lib/unmagic/color/hsl.rb', line 272 def to_hsl self end |
#to_oklch ⇒ OKLCH
Convert to OKLCH color space.
Converts via RGB as an intermediate step.
290 291 292 |
# File 'lib/unmagic/color/hsl.rb', line 290 def to_oklch to_rgb.to_oklch end |
#to_rgb ⇒ RGB
Convert to RGB color space.
279 280 281 282 283 |
# File 'lib/unmagic/color/hsl.rb', line 279 def to_rgb rgb = hsl_to_rgb require_relative "rgb" Unmagic::Color::RGB.new(red: rgb[0], green: rgb[1], blue: rgb[2], alpha: @alpha) end |
#to_s ⇒ String
Convert to string representation.
Returns the CSS hsl() function format. If alpha is less than 100%, includes alpha value using modern CSS syntax with / separator.
466 467 468 469 470 471 472 |
# File 'lib/unmagic/color/hsl.rb', line 466 def to_s if @alpha.value < 100 "hsl(#{@hue.value.round}, #{@saturation.value}%, #{@lightness.value}% / #{@alpha.to_css})" else "hsl(#{@hue.value.round}, #{@saturation.value}%, #{@lightness.value}%)" end end |