lib/color/hsl.rb



# frozen_string_literal: true

##
# The \HSL color model is a cylindrical-coordinate representation of the sRGB color model,
# standing for hue (measured in degrees on the cylinder), saturation (measured in
# percentage), and lightness (measured in percentage).
#
# \HSL colors are immutable Data class instances. Array deconstruction is `[hue,
# saturation, luminosity]` and hash deconstruction is `{h:, hue:, s:, saturation:, l:,
# luminosity:}`. See #h, #hue, #s, #saturation, #l, #luminosity.
class Color::HSL
  include Color

  ##
  # :attr_reader: h
  # Returns the hue of the color in the range 0.0..1.0.

  ##
  # :attr_reader: hue
  # Returns the hue of the color in degrees (0.0..360.0).

  ##
  # :attr_reader: s
  # Returns the saturation of the color in the range 0.0..1.0.

  ##
  # :attr_reader: saturation
  # Returns the percentage of saturation of the color (0.0..100.0).

  ##
  # :attr_reader: brightness
  # Returns the luminosity (#l) of the color.

  ##
  # :attr_reader: l
  # Returns the luminosity of the color in the range 0.0..1.0.

  ##
  # :attr_reader: luminosity
  # Returns the percentage of luminosity of the color.

  ##
  # Creates a \HSL color object from degrees (0.0 .. 360.0) and percentage values
  # (0.0 .. 100.0).
  #
  # ```ruby
  # Color::HSL.from_values(145, 30, 50)          # => HSL [145deg 30% 50%]
  # Color::HSL.from_values(h: 145, s: 30, l: 50) # => HSL [145deg 30% 50%]
  # ```
  #
  # :call-seq:
  #   from_values(h, s, l)
  #   from_values(h:, s:, l:)
  def self.from_values(*args, **kwargs)
    h, s, l =
      case [args, kwargs]
      in [[_, _, _], {}]
        args
      in [[], {h:, s:, l:}]
        [h, s, l]
      else
        new(*args, **kwargs)
      end

    new(h: h / 360.0, s: s / 100.0, l: l / 100.0)
  end

  class << self
    alias_method :from_fraction, :new
    alias_method :from_internal, :new # :nodoc:
  end

  ##
  # Creates a \HSL color object from fractional values (0.0 .. 1.0).
  #
  # ```ruby
  # Color::HSL.from_fraction(0.3, 0.3, 0.5) # => HSL [108deg 30% 50%]
  # Color::HSL.new(h: 0.3, s: 0.3, l: 0.5)  # => HSL [108deg 30% 50%]
  # Color::HSL[0.3, 0.3, 0.5]               # => HSL [108deg 30% 50%]
  # ```
  def initialize(h:, s:, l:)
    super(h: normalize(h), s: normalize(s), l: normalize(l))
  end

  ##
  # Coerces the other Color object into \HSL.
  def coerce(other) = other.to_hsl

  ##
  # Converts from \HSL to Color::RGB.
  #
  # As with all color conversions, this is an approximation. The code here is adapted from
  # fvd and van Dam, originally found at [1] (implemented similarly at [2]). This
  # simplifies the calculations with the following assumptions:
  #
  # - Luminance values <= 0 always translate to a black Color::RGB value.
  # - Luminance values >= 1 always translate to a white Color::RGB value.
  # - Saturation values <= 0 always translate to a shade of gray using luminance as
  #   a percentage of gray.
  #
  # [1] http://bobpowell.net/RGBHSB.aspx
  # [2] http://support.microsoft.com/kb/29240
  def to_rgb(...)
    if near_zero_or_less?(l)
      Color::RGB::Black000
    elsif near_one_or_more?(l)
      Color::RGB::WhiteFFF
    elsif near_zero?(s)
      Color::RGB.from_fraction(l, l, l)
    else
      Color::RGB.from_fraction(*compute_fvd_rgb)
    end
  end

  ##
  # Converts from \HSL to Color::YIQ via Color::RGB.
  def to_yiq(...) = to_rgb(...).to_yiq(...)

  ##
  # Converts from \HSL to Color::CMYK via Color::RGB.
  def to_cmyk(...) = to_rgb(...).to_cmyk(...)

  ##
  # Converts from \HSL to Color::Grayscale.
  #
  # Luminance is treated as the Grayscale ratio.
  def to_grayscale(...) = Color::Grayscale.from_fraction(l)

  ##
  # Converts from \HSL to Color::CIELAB via Color::RGB.
  def to_lab(...) = to_rgb(...).to_lab(...)

  ##
  # Converts from \HSL to Color::XYZ via Color::RGB.
  def to_xyz(...) = to_rgb(...).to_xyz(...)

  ##
  def to_hsl(...) = self

  ##
  # Present the color as a CSS `hsl` function with optional `alpha`.
  #
  # ```ruby
  # hsl = Color::HSL.from_values(145, 30, 50)
  # hsl.css             # => hsl(145deg 30% 50%)
  # hsl.css(alpha: 0.5) # => hsl(145deg 30% 50% / 0.50)
  # ```
  def css(alpha: nil)
    params = [
      css_value(hue, :degrees),
      css_value(saturation, :percent),
      css_value(luminosity, :percent)
    ].join(" ")
    params = "#{params} / #{css_value(alpha)}" if alpha

    "hsl(#{params})"
  end

  ##
  def brightness = l # :nodoc:

  ##
  def hue = h * 360.0 # :nodoc:

  ##
  def saturation = s * 100.0 # :nodoc:

  ##
  def luminosity = l * 100.0 # :nodoc:

  ##
  alias_method :lightness, :luminosity

  ##
  def inspect = "HSL [%.2fdeg %.2f%% %.2f%%]" % [hue, saturation, luminosity] # :nodoc:

  ##
  def pretty_print(q) # :nodoc:
    q.text "HSL"
    q.breakable
    q.group 2, "[", "]" do
      q.text "%.2fdeg" % hue
      q.fill_breakable
      q.text "%.2f%%" % saturation
      q.fill_breakable
      q.text "%.2f%%" % luminosity
    end
  end

  ##
  # Mix the mask color (which will be converted to a \HSL color) with the current color
  # at the stated fractional mix ratio (0.0..1.0).
  #
  # This implementation differs from Color::RGB#mix_with.
  #
  # :call-seq:
  #   mix_with(mask, mix_ratio: 0.5)
  def mix_with(mask, *args, **kwargs)
    mix_ratio = normalize(kwargs[:mix_ratio] || args.first || 0.5)

    map_with(mask) { ((_2 - _1) * mix_ratio) + _1 }
  end

  ##
  def to_a = [hue, saturation, luminosity] # :nodoc:

  ##
  alias_method :deconstruct, :to_a

  ##
  def deconstruct_keys(_keys) = {h:, s:, l:, hue:, saturation:, luminance:} # :nodoc:

  ##
  def to_internal = [h, s, l] # :nodoc:

  private

  ##
  # This algorithm calculates based on a mixture of the saturation and luminance, and then
  # takes the RGB values from the hue + 1/3, hue, and hue - 1/3 positions in a circular
  # representation of color divided into four parts (confusing, I know, but it's the way
  # that it works). See #hue_to_rgb for more information.
  def compute_fvd_rgb # :nodoc:
    t1, t2 = fvd_mix_sat_lum
    [h + (1 / 3.0), h, h - (1 / 3.0)].map { |v|
      hue_to_rgb(rotate_hue(v), t1, t2)
    }
  end

  ##
  # Mix saturation and luminance for use in hue_to_rgb. The base value is different
  # depending on whether luminance is <= 50% or > 50%.
  def fvd_mix_sat_lum # :nodoc:
    t = if near_zero_or_less?(l - 0.5)
      l * (1.0 + s.to_f)
    else
      l + s - (l * s.to_f)
    end
    [2.0 * l - t, t]
  end

  ##
  # In \HSL, hues are referenced as degrees in a color circle. The flow itself is endless;
  # therefore, we can rotate around. The only thing our implementation restricts is that
  # you should not be > 1.0.
  def rotate_hue(h) # :nodoc:
    h += 1.0 if near_zero_or_less?(h)
    h -= 1.0 if near_one_or_more?(h)
    h
  end

  ##
  # We calculate the interaction of the saturation/luminance mix (calculated earlier)
  # based on the position of the hue in the circular color space divided into quadrants.
  # Our hue range is [0, 1), not [0, 360º).
  #
  # - The first quadrant covers the first 60º [0, 60º].
  # - The second quadrant covers the next 120º (60º, 180º].
  # - The third quadrant covers the next 60º (180º, 240º].
  # - The fourth quadrant covers the final 120º (240º, 360º).
  def hue_to_rgb(h, t1, t2) # :nodoc:
    if near_zero_or_less?((6.0 * h) - 1.0)
      t1 + ((t2 - t1) * h * 6.0)
    elsif near_zero_or_less?((2.0 * h) - 1.0)
      t2
    elsif near_zero_or_less?((3.0 * h) - 2.0)
      t1 + (t2 - t1) * ((2 / 3.0) - h) * 6.0
    else
      t1
    end
  end
end