unmagic-color 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -0
- data/README.md +209 -36
- data/data/css.jsonc +150 -0
- data/data/css.txt +148 -0
- data/data/x11.jsonc +660 -0
- data/data/x11.txt +753 -0
- data/lib/unmagic/color/console/banner.rb +55 -0
- data/lib/unmagic/color/console/card.rb +165 -0
- data/lib/unmagic/color/console/help.rb +70 -0
- data/lib/unmagic/color/console/highlighter.rb +114 -0
- data/lib/unmagic/color/gradient/base.rb +252 -0
- data/lib/unmagic/color/gradient/bitmap.rb +91 -0
- data/lib/unmagic/color/gradient/stop.rb +48 -0
- data/lib/unmagic/color/gradient.rb +154 -0
- data/lib/unmagic/color/harmony.rb +293 -0
- data/lib/unmagic/color/hsl/gradient/linear.rb +152 -0
- data/lib/unmagic/color/hsl.rb +136 -21
- data/lib/unmagic/color/oklch/gradient/linear.rb +151 -0
- data/lib/unmagic/color/oklch.rb +115 -12
- data/lib/unmagic/color/rgb/ansi.rb +227 -0
- data/lib/unmagic/color/rgb/gradient/linear.rb +165 -0
- data/lib/unmagic/color/rgb/hex.rb +20 -8
- data/lib/unmagic/color/rgb/named.rb +213 -43
- data/lib/unmagic/color/rgb.rb +325 -22
- data/lib/unmagic/color/units/degrees.rb +233 -0
- data/lib/unmagic/color/units/direction.rb +206 -0
- data/lib/unmagic/color/util/percentage.rb +150 -22
- data/lib/unmagic/color/version.rb +8 -0
- data/lib/unmagic/color.rb +95 -0
- metadata +23 -3
- data/data/rgb.txt +0 -164
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
module Gradient
|
|
6
|
+
# A 2D grid of color pixels representing a rasterized gradient.
|
|
7
|
+
#
|
|
8
|
+
# Bitmap represents the output of gradient rasterization as a grid of colors.
|
|
9
|
+
# For linear gradients, this is a single row (height=1). The 2D structure
|
|
10
|
+
# allows for future gradient types that need multiple rows.
|
|
11
|
+
#
|
|
12
|
+
# ## Pixel Storage
|
|
13
|
+
#
|
|
14
|
+
# Pixels are stored as a 2D array: `pixels[y][x] = color`
|
|
15
|
+
#
|
|
16
|
+
# - Linear gradients: `pixels = [[color1, color2, ...]]` (single row)
|
|
17
|
+
# - Multi-row gradients: `pixels = [[row1...], [row2...], ...]`
|
|
18
|
+
#
|
|
19
|
+
# ## Examples
|
|
20
|
+
#
|
|
21
|
+
# # Create a 1D bitmap (from linear gradient)
|
|
22
|
+
# bitmap = Unmagic::Color::Gradient::Bitmap.new(
|
|
23
|
+
# width: 5,
|
|
24
|
+
# height: 1,
|
|
25
|
+
# pixels: [[red, orange, yellow, green, blue]]
|
|
26
|
+
# )
|
|
27
|
+
#
|
|
28
|
+
# # Access pixels
|
|
29
|
+
# bitmap.at(0, 0) # => red (first pixel)
|
|
30
|
+
# bitmap.at(4, 0) # => blue (last pixel)
|
|
31
|
+
# bitmap[] # => red (shortcut for first pixel)
|
|
32
|
+
#
|
|
33
|
+
# # Convert to flat array
|
|
34
|
+
# bitmap.to_a # => [red, orange, yellow, green, blue]
|
|
35
|
+
class Bitmap
|
|
36
|
+
attr_reader :width, :height, :pixels
|
|
37
|
+
|
|
38
|
+
# Create a new bitmap.
|
|
39
|
+
#
|
|
40
|
+
# @param width [Integer] Number of pixels horizontally
|
|
41
|
+
# @param height [Integer] Number of pixels vertically
|
|
42
|
+
# @param pixels [Array<Array<Color>>] 2D array of colors (pixels[y][x])
|
|
43
|
+
def initialize(width:, height:, pixels:)
|
|
44
|
+
@width = width
|
|
45
|
+
@height = height
|
|
46
|
+
@pixels = pixels
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Access a pixel at the given coordinates.
|
|
50
|
+
#
|
|
51
|
+
# @param x [Integer] Horizontal position (0 to width-1)
|
|
52
|
+
# @param y [Integer] Vertical position (0 to height-1), defaults to 0
|
|
53
|
+
# @return [Color] The color at the specified position
|
|
54
|
+
def at(x, y = 0)
|
|
55
|
+
@pixels[y][x]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Shortcut to access a pixel.
|
|
59
|
+
#
|
|
60
|
+
# When called without arguments, returns the first pixel (0, 0).
|
|
61
|
+
# When called with arguments, delegates to `at`.
|
|
62
|
+
#
|
|
63
|
+
# @param args [Array] Optional x and y coordinates
|
|
64
|
+
# @return [Color] The color at the specified position
|
|
65
|
+
#
|
|
66
|
+
# @example Get first pixel
|
|
67
|
+
# bitmap[] # => color at (0, 0)
|
|
68
|
+
#
|
|
69
|
+
# @example Get specific pixel
|
|
70
|
+
# bitmap[5, 0] # => color at (5, 0)
|
|
71
|
+
def [](*args)
|
|
72
|
+
if args.empty?
|
|
73
|
+
at(0, 0)
|
|
74
|
+
else
|
|
75
|
+
at(*args)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Convert to a flat 1D array of colors.
|
|
80
|
+
#
|
|
81
|
+
# Flattens the 2D pixel grid into a single array, reading left-to-right,
|
|
82
|
+
# top-to-bottom.
|
|
83
|
+
#
|
|
84
|
+
# @return [Array<Color>] Flattened array of all colors
|
|
85
|
+
def to_a
|
|
86
|
+
@pixels.flatten
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
module Gradient
|
|
6
|
+
# A color stop at a specific position in a gradient.
|
|
7
|
+
#
|
|
8
|
+
# Represents a single color at a position along a gradient. Positions are
|
|
9
|
+
# normalized from 0.0 (start) to 1.0 (end). Multiple stops define the
|
|
10
|
+
# color transitions in a gradient.
|
|
11
|
+
#
|
|
12
|
+
# ## Usage
|
|
13
|
+
#
|
|
14
|
+
# Stop objects are typically created automatically by gradient classes
|
|
15
|
+
# using the `.build()` method, but can be created directly for more control.
|
|
16
|
+
#
|
|
17
|
+
# ## Examples
|
|
18
|
+
#
|
|
19
|
+
# # Create a stop directly
|
|
20
|
+
# stop = Unmagic::Color::Gradient::Stop.new(
|
|
21
|
+
# color: Unmagic::Color::RGB.parse("#FF0000"),
|
|
22
|
+
# position: 0.5
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# # Access stop properties
|
|
26
|
+
# stop.color # => #<RGB...>
|
|
27
|
+
# stop.position # => 0.5
|
|
28
|
+
class Stop
|
|
29
|
+
attr_reader :color, :position
|
|
30
|
+
|
|
31
|
+
# Create a new color stop.
|
|
32
|
+
#
|
|
33
|
+
# @param color [Color] The color at this stop (must be a Color instance)
|
|
34
|
+
# @param position [Numeric] Position along gradient (0.0-1.0)
|
|
35
|
+
#
|
|
36
|
+
# @raise [ArgumentError] If color is not a Color instance
|
|
37
|
+
# @raise [ArgumentError] If position is not between 0.0 and 1.0
|
|
38
|
+
def initialize(color:, position:)
|
|
39
|
+
raise ArgumentError, "color must be a Color instance" unless color.is_a?(Unmagic::Color)
|
|
40
|
+
raise ArgumentError, "position must be between 0.0 and 1.0" if position < 0.0 || position > 1.0
|
|
41
|
+
|
|
42
|
+
@color = color
|
|
43
|
+
@position = position.to_f
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "units/direction"
|
|
4
|
+
|
|
5
|
+
module Unmagic
|
|
6
|
+
class Color
|
|
7
|
+
# Gradient generation for smooth color transitions.
|
|
8
|
+
#
|
|
9
|
+
# Provides classes for creating linear color gradients in RGB, HSL, or
|
|
10
|
+
# OKLCH color spaces.
|
|
11
|
+
#
|
|
12
|
+
# ## Core Classes
|
|
13
|
+
#
|
|
14
|
+
# - {Stop} - Represents a color at a specific position
|
|
15
|
+
# - {Bitmap} - A 2D grid of colors (output from rasterization)
|
|
16
|
+
# - {Base} - Base class for gradient implementations
|
|
17
|
+
#
|
|
18
|
+
# ## Gradient Types
|
|
19
|
+
#
|
|
20
|
+
# - `RGB::Gradient::Linear` - Linear gradient in RGB space
|
|
21
|
+
# - `HSL::Gradient::Linear` - Linear gradient in HSL space
|
|
22
|
+
# - `OKLCH::Gradient::Linear` - Linear gradient in OKLCH space
|
|
23
|
+
#
|
|
24
|
+
# @example Auto-detect color space, auto-balance positions
|
|
25
|
+
# gradient = Unmagic::Color::Gradient.linear(["#FF0000", "#0000FF"])
|
|
26
|
+
# bitmap = gradient.rasterize(width: 10)
|
|
27
|
+
#
|
|
28
|
+
# @example Use explicit positions
|
|
29
|
+
# gradient = Unmagic::Color::Gradient.linear([["#FF0000", 0.0], ["#0000FF", 1.0]])
|
|
30
|
+
# bitmap = gradient.rasterize(width: 10)
|
|
31
|
+
#
|
|
32
|
+
# @example Works with HSL and OKLCH too
|
|
33
|
+
# gradient = Unmagic::Color::Gradient.linear(["hsl(0, 100%, 50%)", "hsl(240, 100%, 50%)"])
|
|
34
|
+
# bitmap = gradient.rasterize(width: 10)
|
|
35
|
+
#
|
|
36
|
+
# @example Three colors are evenly spaced (0.0, 0.5, 1.0)
|
|
37
|
+
# gradient = Unmagic::Color::Gradient.linear(["#FF0000", "#00FF00", "#0000FF"])
|
|
38
|
+
#
|
|
39
|
+
# @example Rasterize at different resolutions
|
|
40
|
+
# colors = gradient.rasterize(width: 5).pixels[0] # 5 colors
|
|
41
|
+
# colors = gradient.rasterize(width: 100).pixels[0] # 100 colors
|
|
42
|
+
#
|
|
43
|
+
# @example Angled gradients
|
|
44
|
+
# gradient = Unmagic::Color::Gradient.linear(["#FF0000", "#0000FF"], direction: "45deg")
|
|
45
|
+
# bitmap = gradient.rasterize(width: 10, height: 10)
|
|
46
|
+
#
|
|
47
|
+
# @example Direction keywords
|
|
48
|
+
# gradient = Unmagic::Color::Gradient.linear(["#FF0000", "#0000FF"], direction: "to right")
|
|
49
|
+
# bitmap = gradient.rasterize(width: 10, height: 1)
|
|
50
|
+
module Gradient
|
|
51
|
+
# Base error class for gradient-related errors.
|
|
52
|
+
class Error < Color::Error; end
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
# Create a linear gradient, auto-detecting color space from input.
|
|
56
|
+
#
|
|
57
|
+
# Examines the first color to determine which color space to use (RGB, HSL, or OKLCH),
|
|
58
|
+
# then delegates to the appropriate Linear gradient class.
|
|
59
|
+
#
|
|
60
|
+
# Works like CSS linear-gradient - you can mix positioned and non-positioned colors.
|
|
61
|
+
# Non-positioned colors auto-balance between their surrounding positioned neighbors.
|
|
62
|
+
#
|
|
63
|
+
# Color space detection:
|
|
64
|
+
# - Strings starting with "hsl(" use HSL color space
|
|
65
|
+
# - Strings starting with "oklch(" use OKLCH color space
|
|
66
|
+
# - All other strings (hex, rgb()) use RGB color space
|
|
67
|
+
# - Color objects use their class directly
|
|
68
|
+
#
|
|
69
|
+
# @param colors [Array] Colors or [color, position] pairs
|
|
70
|
+
# @param direction [String, Numeric, Degrees, Direction, nil] Optional gradient direction
|
|
71
|
+
# - Direction strings: "to top", "from left to right", "45deg", "90°"
|
|
72
|
+
# - Numeric degrees: 45, 90, 180
|
|
73
|
+
# - Degrees/Direction instances
|
|
74
|
+
# - Defaults to "to bottom" (180°) if omitted
|
|
75
|
+
# @return [Linear] A gradient instance in the detected color space
|
|
76
|
+
#
|
|
77
|
+
# @example Simple gradient
|
|
78
|
+
# Gradient.linear(["#FF0000", "#0000FF"])
|
|
79
|
+
#
|
|
80
|
+
# @example With direction keyword
|
|
81
|
+
# Gradient.linear(["#FF0000", "#0000FF"], direction: "to right")
|
|
82
|
+
# Gradient.linear(["blue", "red"], direction: "from left to right")
|
|
83
|
+
#
|
|
84
|
+
# @example With numeric direction
|
|
85
|
+
# Gradient.linear(["#FF0000", "#0000FF"], direction: 45)
|
|
86
|
+
# Gradient.linear(["#FF0000", "#0000FF"], direction: "90°")
|
|
87
|
+
#
|
|
88
|
+
# @example Mixed positions (like CSS linear-gradient)
|
|
89
|
+
# Gradient.linear(["#FF0000", ["#FFFF00", 0.3], "#00FF00", ["#0000FF", 0.9], "#FF00FF"])
|
|
90
|
+
def linear(colors, direction: nil)
|
|
91
|
+
# Convert direction to Direction instance
|
|
92
|
+
direction_instance = if direction.nil?
|
|
93
|
+
# Default to "to bottom" (CSS default)
|
|
94
|
+
Unmagic::Color::Units::Degrees::Direction::TOP_TO_BOTTOM
|
|
95
|
+
elsif direction.is_a?(Unmagic::Color::Units::Degrees::Direction)
|
|
96
|
+
direction
|
|
97
|
+
elsif direction.is_a?(Unmagic::Color::Units::Degrees)
|
|
98
|
+
# Degrees instance - convert to Direction (from opposite to this degree)
|
|
99
|
+
Unmagic::Color::Units::Degrees::Direction.new(from: direction.opposite, to: direction)
|
|
100
|
+
elsif direction.is_a?(::Numeric)
|
|
101
|
+
# Numeric - convert to Degrees, then to Direction
|
|
102
|
+
degrees = Unmagic::Color::Units::Degrees.new(value: direction)
|
|
103
|
+
Unmagic::Color::Units::Degrees::Direction.new(from: degrees.opposite, to: degrees)
|
|
104
|
+
elsif direction.is_a?(::String)
|
|
105
|
+
# String - parse as Direction or Degrees
|
|
106
|
+
if Unmagic::Color::Units::Degrees::Direction.matches?(direction)
|
|
107
|
+
Unmagic::Color::Units::Degrees::Direction.parse(direction)
|
|
108
|
+
else
|
|
109
|
+
degrees = Unmagic::Color::Units::Degrees.parse(direction)
|
|
110
|
+
Unmagic::Color::Units::Degrees::Direction.new(from: degrees.opposite, to: degrees)
|
|
111
|
+
end
|
|
112
|
+
else
|
|
113
|
+
raise Error, "Invalid direction type: #{direction.class}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
raise Error, "colors must have at least one color" if colors.empty?
|
|
117
|
+
|
|
118
|
+
# Extract first color for color space detection (handle both positioned and non-positioned)
|
|
119
|
+
first = colors.first
|
|
120
|
+
first_color_or_string = first.is_a?(::Array) ? first.first : first
|
|
121
|
+
|
|
122
|
+
# Determine color class from first color
|
|
123
|
+
color_class = if first_color_or_string.is_a?(Unmagic::Color)
|
|
124
|
+
first_color_or_string.class
|
|
125
|
+
elsif first_color_or_string.is_a?(::String)
|
|
126
|
+
if first_color_or_string.match?(/^\s*hsl\s*\(/)
|
|
127
|
+
Unmagic::Color::HSL
|
|
128
|
+
elsif first_color_or_string.match?(/^\s*oklch\s*\(/)
|
|
129
|
+
Unmagic::Color::OKLCH
|
|
130
|
+
else
|
|
131
|
+
Unmagic::Color::RGB
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
raise Error, "First color must be a Color instance or String"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Delegate to appropriate Linear class
|
|
138
|
+
gradient_class = case color_class.name
|
|
139
|
+
when "Unmagic::Color::RGB"
|
|
140
|
+
Unmagic::Color::RGB::Gradient::Linear
|
|
141
|
+
when "Unmagic::Color::HSL"
|
|
142
|
+
Unmagic::Color::HSL::Gradient::Linear
|
|
143
|
+
when "Unmagic::Color::OKLCH"
|
|
144
|
+
Unmagic::Color::OKLCH::Gradient::Linear
|
|
145
|
+
else
|
|
146
|
+
raise Error, "Unsupported color class: #{color_class}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
gradient_class.build(colors, direction: direction_instance)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
# Color harmony and variations module.
|
|
6
|
+
#
|
|
7
|
+
# Provides methods for generating harmonious color palettes based on
|
|
8
|
+
# color theory principles. All calculations are performed in HSL color space
|
|
9
|
+
# for accurate hue-based relationships.
|
|
10
|
+
#
|
|
11
|
+
# Included in the base Color class, making these methods available to
|
|
12
|
+
# RGB, HSL, and OKLCH color spaces via inheritance.
|
|
13
|
+
#
|
|
14
|
+
# ## Color Harmonies
|
|
15
|
+
#
|
|
16
|
+
# Color harmonies are combinations of colors that are aesthetically pleasing
|
|
17
|
+
# based on their positions on the color wheel:
|
|
18
|
+
#
|
|
19
|
+
# - **Complementary**: Colors opposite on the wheel (180° apart)
|
|
20
|
+
# - **Analogous**: Colors adjacent on the wheel (typically 30° apart)
|
|
21
|
+
# - **Triadic**: Three colors evenly spaced (120° apart)
|
|
22
|
+
# - **Split-complementary**: Base color plus two colors adjacent to its complement
|
|
23
|
+
# - **Tetradic**: Four colors forming a rectangle or square on the wheel
|
|
24
|
+
#
|
|
25
|
+
# ## Color Variations
|
|
26
|
+
#
|
|
27
|
+
# Create related colors by adjusting lightness or saturation:
|
|
28
|
+
#
|
|
29
|
+
# - **Shades**: Darker versions (reducing lightness)
|
|
30
|
+
# - **Tints**: Lighter versions (increasing lightness)
|
|
31
|
+
# - **Tones**: Less saturated versions (reducing saturation)
|
|
32
|
+
#
|
|
33
|
+
# @example Basic harmony usage
|
|
34
|
+
# red = Unmagic::Color.parse("#FF0000")
|
|
35
|
+
# red.complementary # => #<RGB #00ffff>
|
|
36
|
+
# red.triadic # => [#<RGB ...>, #<RGB ...>]
|
|
37
|
+
#
|
|
38
|
+
# @example Color variations
|
|
39
|
+
# blue = Unmagic::Color.parse("#0000FF")
|
|
40
|
+
# blue.shades(steps: 3) # => [darker1, darker2, darker3]
|
|
41
|
+
# blue.tints(steps: 3) # => [lighter1, lighter2, lighter3]
|
|
42
|
+
module Harmony
|
|
43
|
+
# Returns the complementary color (180° opposite on the color wheel).
|
|
44
|
+
#
|
|
45
|
+
# Complementary colors create high contrast and visual tension.
|
|
46
|
+
# They're effective for creating emphasis and drawing attention.
|
|
47
|
+
#
|
|
48
|
+
# @return [RGB, HSL, OKLCH] The complementary color (same type as self)
|
|
49
|
+
#
|
|
50
|
+
# @example
|
|
51
|
+
# red = Unmagic::Color.parse("#FF0000")
|
|
52
|
+
# red.complementary
|
|
53
|
+
# # => #<RGB #00ffff> (cyan)
|
|
54
|
+
def complementary
|
|
55
|
+
rotate_hue(180)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns two analogous colors (adjacent on the color wheel).
|
|
59
|
+
#
|
|
60
|
+
# Analogous colors create harmonious, cohesive designs. They're often
|
|
61
|
+
# found in nature and produce a calm, comfortable feel.
|
|
62
|
+
#
|
|
63
|
+
# @param angle [Numeric] Degrees of separation from the base color (default: 30)
|
|
64
|
+
# @return [Array<RGB, HSL, OKLCH>] Two colors [-angle, +angle] from the base
|
|
65
|
+
#
|
|
66
|
+
# @example Default 30° separation
|
|
67
|
+
# red = Unmagic::Color.parse("#FF0000")
|
|
68
|
+
# red.analogous
|
|
69
|
+
# # => [#<RGB ...>, #<RGB ...>] (red-violet, red-orange)
|
|
70
|
+
#
|
|
71
|
+
# @example Custom 15° separation
|
|
72
|
+
# red.analogous(angle: 15)
|
|
73
|
+
def analogous(angle: 30)
|
|
74
|
+
[rotate_hue(-angle), rotate_hue(angle)]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns two triadic colors (evenly spaced 120° on the color wheel).
|
|
78
|
+
#
|
|
79
|
+
# Triadic colors offer strong visual contrast while retaining harmony.
|
|
80
|
+
# They tend to be vibrant even when using pale or unsaturated versions.
|
|
81
|
+
#
|
|
82
|
+
# @return [Array<RGB, HSL, OKLCH>] Two colors at +120° and +240°
|
|
83
|
+
#
|
|
84
|
+
# @example
|
|
85
|
+
# red = Unmagic::Color.parse("#FF0000")
|
|
86
|
+
# red.triadic
|
|
87
|
+
# # => [#<RGB ...>, #<RGB ...>] (green-ish, blue-ish)
|
|
88
|
+
def triadic
|
|
89
|
+
[rotate_hue(120), rotate_hue(240)]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns two split-complementary colors.
|
|
93
|
+
#
|
|
94
|
+
# Split-complementary uses the two colors adjacent to the complement,
|
|
95
|
+
# providing high contrast with less tension than pure complementary.
|
|
96
|
+
#
|
|
97
|
+
# @param angle [Numeric] Degrees from the complement (default: 30)
|
|
98
|
+
# @return [Array<RGB, HSL, OKLCH>] Two colors at (180-angle)° and (180+angle)°
|
|
99
|
+
#
|
|
100
|
+
# @example
|
|
101
|
+
# red = Unmagic::Color.parse("#FF0000")
|
|
102
|
+
# red.split_complementary
|
|
103
|
+
# # => [#<RGB ...>, #<RGB ...>] (cyan-blue, cyan-green)
|
|
104
|
+
def split_complementary(angle: 30)
|
|
105
|
+
[rotate_hue(180 - angle), rotate_hue(180 + angle)]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Returns three tetradic colors forming a square on the color wheel.
|
|
109
|
+
#
|
|
110
|
+
# Square tetradic uses four colors evenly spaced (90° apart).
|
|
111
|
+
# This creates a rich, bold color scheme with equal visual weight.
|
|
112
|
+
#
|
|
113
|
+
# @return [Array<RGB, HSL, OKLCH>] Three colors at +90°, +180°, +270°
|
|
114
|
+
#
|
|
115
|
+
# @example
|
|
116
|
+
# red = Unmagic::Color.parse("#FF0000")
|
|
117
|
+
# red.tetradic_square
|
|
118
|
+
# # => [#<RGB ...>, #<RGB ...>, #<RGB ...>]
|
|
119
|
+
def tetradic_square
|
|
120
|
+
[rotate_hue(90), rotate_hue(180), rotate_hue(270)]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns three tetradic colors forming a rectangle on the color wheel.
|
|
124
|
+
#
|
|
125
|
+
# Rectangular tetradic uses two complementary pairs with configurable
|
|
126
|
+
# spacing. This provides flexibility between harmony and contrast.
|
|
127
|
+
#
|
|
128
|
+
# @param angle [Numeric] Degrees between first pair (default: 60)
|
|
129
|
+
# @return [Array<RGB, HSL, OKLCH>] Three colors at +angle°, +180°, +(180+angle)°
|
|
130
|
+
#
|
|
131
|
+
# @example
|
|
132
|
+
# red = Unmagic::Color.parse("#FF0000")
|
|
133
|
+
# red.tetradic_rectangle(angle: 60)
|
|
134
|
+
def tetradic_rectangle(angle: 60)
|
|
135
|
+
[rotate_hue(angle), rotate_hue(180), rotate_hue(180 + angle)]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Returns an array of colors with varying lightness (same hue).
|
|
139
|
+
#
|
|
140
|
+
# Creates a monochromatic palette by generating colors across a
|
|
141
|
+
# lightness range while preserving hue and saturation.
|
|
142
|
+
#
|
|
143
|
+
# @param steps [Integer] Number of colors to generate (default: 5)
|
|
144
|
+
# @return [Array<RGB, HSL, OKLCH>] Colors with lightness from 15% to 85%
|
|
145
|
+
#
|
|
146
|
+
# @example
|
|
147
|
+
# blue = Unmagic::Color.parse("#0000FF")
|
|
148
|
+
# blue.monochromatic(steps: 5)
|
|
149
|
+
# # => [very dark blue, dark blue, medium blue, light blue, very light blue]
|
|
150
|
+
def monochromatic(steps: 5)
|
|
151
|
+
raise ArgumentError, "steps must be at least 1" if steps < 1
|
|
152
|
+
|
|
153
|
+
hsl = to_hsl
|
|
154
|
+
min_lightness = 15.0
|
|
155
|
+
max_lightness = 85.0
|
|
156
|
+
step_size = (max_lightness - min_lightness) / (steps - 1).to_f
|
|
157
|
+
|
|
158
|
+
(0...steps).map do |i|
|
|
159
|
+
lightness = min_lightness + (i * step_size)
|
|
160
|
+
result = HSL.new(
|
|
161
|
+
hue: hsl.hue.value,
|
|
162
|
+
saturation: hsl.saturation.value,
|
|
163
|
+
lightness: lightness,
|
|
164
|
+
alpha: hsl.alpha.value,
|
|
165
|
+
)
|
|
166
|
+
convert_harmony_result(result)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Returns an array of progressively darker colors (shades).
|
|
171
|
+
#
|
|
172
|
+
# Shades are created by reducing lightness, simulating the effect
|
|
173
|
+
# of adding black to the original color.
|
|
174
|
+
#
|
|
175
|
+
# @param steps [Integer] Number of shades to generate (default: 5)
|
|
176
|
+
# @param amount [Float] Total amount of darkening 0.0-1.0 (default: 0.5)
|
|
177
|
+
# @return [Array<RGB, HSL, OKLCH>] Progressively darker colors
|
|
178
|
+
#
|
|
179
|
+
# @example
|
|
180
|
+
# red = Unmagic::Color.parse("#FF0000")
|
|
181
|
+
# red.shades(steps: 3)
|
|
182
|
+
# # => [slightly darker red, darker red, darkest red]
|
|
183
|
+
def shades(steps: 5, amount: 0.5)
|
|
184
|
+
raise ArgumentError, "steps must be at least 1" if steps < 1
|
|
185
|
+
|
|
186
|
+
hsl = to_hsl
|
|
187
|
+
step_amount = amount / steps.to_f
|
|
188
|
+
|
|
189
|
+
(1..steps).map do |i|
|
|
190
|
+
new_lightness = hsl.lightness.value * (1 - (step_amount * i))
|
|
191
|
+
result = HSL.new(
|
|
192
|
+
hue: hsl.hue.value,
|
|
193
|
+
saturation: hsl.saturation.value,
|
|
194
|
+
lightness: new_lightness.clamp(0, 100),
|
|
195
|
+
alpha: hsl.alpha.value,
|
|
196
|
+
)
|
|
197
|
+
convert_harmony_result(result)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Returns an array of progressively lighter colors (tints).
|
|
202
|
+
#
|
|
203
|
+
# Tints are created by increasing lightness, simulating the effect
|
|
204
|
+
# of adding white to the original color.
|
|
205
|
+
#
|
|
206
|
+
# @param steps [Integer] Number of tints to generate (default: 5)
|
|
207
|
+
# @param amount [Float] Total amount of lightening 0.0-1.0 (default: 0.5)
|
|
208
|
+
# @return [Array<RGB, HSL, OKLCH>] Progressively lighter colors
|
|
209
|
+
#
|
|
210
|
+
# @example
|
|
211
|
+
# blue = Unmagic::Color.parse("#0000FF")
|
|
212
|
+
# blue.tints(steps: 3)
|
|
213
|
+
# # => [slightly lighter blue, lighter blue, lightest blue]
|
|
214
|
+
def tints(steps: 5, amount: 0.5)
|
|
215
|
+
raise ArgumentError, "steps must be at least 1" if steps < 1
|
|
216
|
+
|
|
217
|
+
hsl = to_hsl
|
|
218
|
+
step_amount = amount / steps.to_f
|
|
219
|
+
|
|
220
|
+
(1..steps).map do |i|
|
|
221
|
+
new_lightness = hsl.lightness.value + (100 - hsl.lightness.value) * (step_amount * i)
|
|
222
|
+
result = HSL.new(
|
|
223
|
+
hue: hsl.hue.value,
|
|
224
|
+
saturation: hsl.saturation.value,
|
|
225
|
+
lightness: new_lightness.clamp(0, 100),
|
|
226
|
+
alpha: hsl.alpha.value,
|
|
227
|
+
)
|
|
228
|
+
convert_harmony_result(result)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Returns an array of progressively desaturated colors (tones).
|
|
233
|
+
#
|
|
234
|
+
# Tones are created by reducing saturation, simulating the effect
|
|
235
|
+
# of adding gray to the original color.
|
|
236
|
+
#
|
|
237
|
+
# @param steps [Integer] Number of tones to generate (default: 5)
|
|
238
|
+
# @param amount [Float] Total amount of desaturation 0.0-1.0 (default: 0.5)
|
|
239
|
+
# @return [Array<RGB, HSL, OKLCH>] Progressively less saturated colors
|
|
240
|
+
#
|
|
241
|
+
# @example
|
|
242
|
+
# red = Unmagic::Color.parse("#FF0000")
|
|
243
|
+
# red.tones(steps: 3)
|
|
244
|
+
# # => [slightly muted red, more muted red, grayish red]
|
|
245
|
+
def tones(steps: 5, amount: 0.5)
|
|
246
|
+
raise ArgumentError, "steps must be at least 1" if steps < 1
|
|
247
|
+
|
|
248
|
+
hsl = to_hsl
|
|
249
|
+
step_amount = amount / steps.to_f
|
|
250
|
+
|
|
251
|
+
(1..steps).map do |i|
|
|
252
|
+
new_saturation = hsl.saturation.value * (1 - (step_amount * i))
|
|
253
|
+
result = HSL.new(
|
|
254
|
+
hue: hsl.hue.value,
|
|
255
|
+
saturation: new_saturation.clamp(0, 100),
|
|
256
|
+
lightness: hsl.lightness.value,
|
|
257
|
+
alpha: hsl.alpha.value,
|
|
258
|
+
)
|
|
259
|
+
convert_harmony_result(result)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
private
|
|
264
|
+
|
|
265
|
+
# Rotate the hue by the specified degrees and return a new color.
|
|
266
|
+
#
|
|
267
|
+
# @param degrees [Numeric] Degrees to rotate (positive = clockwise)
|
|
268
|
+
# @return [RGB, HSL, OKLCH] New color with rotated hue (same type as self)
|
|
269
|
+
def rotate_hue(degrees)
|
|
270
|
+
hsl = to_hsl
|
|
271
|
+
result = HSL.new(
|
|
272
|
+
hue: hsl.hue.value + degrees,
|
|
273
|
+
saturation: hsl.saturation.value,
|
|
274
|
+
lightness: hsl.lightness.value,
|
|
275
|
+
alpha: hsl.alpha.value,
|
|
276
|
+
)
|
|
277
|
+
convert_harmony_result(result)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Convert an HSL result back to the original color space.
|
|
281
|
+
#
|
|
282
|
+
# @param hsl_color [HSL] The HSL color to convert
|
|
283
|
+
# @return [RGB, HSL, OKLCH] Color in the same space as self
|
|
284
|
+
def convert_harmony_result(hsl_color)
|
|
285
|
+
case self
|
|
286
|
+
when RGB then hsl_color.to_rgb
|
|
287
|
+
when OKLCH then hsl_color.to_oklch
|
|
288
|
+
else hsl_color
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|