File: hsl.rb

package info (click to toggle)
ruby-color 2.2.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 328 kB
  • sloc: ruby: 2,006; makefile: 5
file content (272 lines) | stat: -rw-r--r-- 7,588 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
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
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
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# 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] https://web.archive.org/web/20150311023529/http://bobpowell.net/RGBHSB.aspx
  # [2] https://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