unmagic-color 0.1.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 +7 -0
- data/README.md +274 -0
- data/data/rgb.txt +164 -0
- data/lib/unmagic/color/hsl.rb +421 -0
- data/lib/unmagic/color/oklch.rb +433 -0
- data/lib/unmagic/color/rgb/hex.rb +104 -0
- data/lib/unmagic/color/rgb/named.rb +112 -0
- data/lib/unmagic/color/rgb.rb +390 -0
- data/lib/unmagic/color/string/hash_function.rb +316 -0
- data/lib/unmagic/color/util/percentage.rb +143 -0
- data/lib/unmagic/color.rb +402 -0
- data/lib/unmagic_color.rb +3 -0
- metadata +55 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
# `RGB` (Red, Green, Blue) color representation.
|
|
6
|
+
#
|
|
7
|
+
# ## Understanding RGB
|
|
8
|
+
#
|
|
9
|
+
# RGB is how your computer screen creates colors. Every color you see on a screen
|
|
10
|
+
# is made by combining three lights: Red, Green, and Blue. Each light can be set
|
|
11
|
+
# from `0` (off) to `255` (full brightness).
|
|
12
|
+
#
|
|
13
|
+
# Think of it like mixing three flashlights:
|
|
14
|
+
#
|
|
15
|
+
# - `Red=255, Green=0, Blue=0` → Pure red light
|
|
16
|
+
# - `Red=0, Green=255, Blue=0` → Pure green light
|
|
17
|
+
# - `Red=255, Green=255, Blue=0` → Yellow (red + green)
|
|
18
|
+
# - `Red=255, Green=255, Blue=255` → White (all lights on)
|
|
19
|
+
# - `Red=0, Green=0, Blue=0` → Black (all lights off)
|
|
20
|
+
#
|
|
21
|
+
# ## Why 0-255?
|
|
22
|
+
#
|
|
23
|
+
# Computers store each color component in 8 bits (one byte), which can hold
|
|
24
|
+
# 256 different values (`0-255`). This gives us `256³ = 16,777,216` possible colors.
|
|
25
|
+
#
|
|
26
|
+
# ## Common Formats
|
|
27
|
+
#
|
|
28
|
+
# RGB colors can be written in different ways:
|
|
29
|
+
#
|
|
30
|
+
# - Hex: `#FF5733` (2 hex digits per component: `FF=255, 57=87, 33=51`)
|
|
31
|
+
# - Short hex: `#F73` (expanded to `#FF7733`)
|
|
32
|
+
# - RGB function: `rgb(255, 87, 51)`
|
|
33
|
+
# - Named colors: `goldenrod`, `red`, `blue` (see {RGB::Named} for X11 color names)
|
|
34
|
+
#
|
|
35
|
+
# ## Usage Examples
|
|
36
|
+
#
|
|
37
|
+
# # Parse from different formats
|
|
38
|
+
# color = Unmagic::Color::RGB.parse("#FF5733")
|
|
39
|
+
# color = Unmagic::Color::RGB.parse("rgb(255, 87, 51)")
|
|
40
|
+
# color = Unmagic::Color::RGB.parse("F73")
|
|
41
|
+
#
|
|
42
|
+
# # Parse named colors (via RGB::Named or Color.parse)
|
|
43
|
+
# color = Unmagic::Color::RGB::Named.parse("goldenrod")
|
|
44
|
+
# color = Unmagic::Color.parse("goldenrod") # Also works
|
|
45
|
+
#
|
|
46
|
+
# # Create directly
|
|
47
|
+
# color = Unmagic::Color::RGB.new(red: 255, green: 87, blue: 51)
|
|
48
|
+
#
|
|
49
|
+
# # Access components
|
|
50
|
+
# color.red.value #=> 255
|
|
51
|
+
# color.green.value #=> 87
|
|
52
|
+
# color.blue.value #=> 51
|
|
53
|
+
#
|
|
54
|
+
# # Convert to other formats
|
|
55
|
+
# color.to_hex #=> "#ff5733"
|
|
56
|
+
# color.to_hsl #=> HSL color
|
|
57
|
+
# color.to_oklch #=> OKLCH color
|
|
58
|
+
#
|
|
59
|
+
# # Generate deterministic colors from text
|
|
60
|
+
# Unmagic::Color::RGB.derive("user@example.com".hash) #=> Consistent color for this string
|
|
61
|
+
class RGB < Color
|
|
62
|
+
# Error raised when parsing RGB color strings fails
|
|
63
|
+
class ParseError < Color::Error; end
|
|
64
|
+
|
|
65
|
+
attr_reader :red, :green, :blue
|
|
66
|
+
|
|
67
|
+
# Create a new RGB color.
|
|
68
|
+
#
|
|
69
|
+
# @param red [Integer] Red component (0-255), values outside this range are clamped
|
|
70
|
+
# @param green [Integer] Green component (0-255), values outside this range are clamped
|
|
71
|
+
# @param blue [Integer] Blue component (0-255), values outside this range are clamped
|
|
72
|
+
#
|
|
73
|
+
# @example Create a red color
|
|
74
|
+
# RGB.new(red: 255, green: 0, blue: 0)
|
|
75
|
+
#
|
|
76
|
+
# @example Values are automatically clamped
|
|
77
|
+
# RGB.new(red: 300, green: -10, blue: 128)
|
|
78
|
+
def initialize(red:, green:, blue:)
|
|
79
|
+
super()
|
|
80
|
+
@red = Color::Red.new(value: red)
|
|
81
|
+
@green = Color::Green.new(value: green)
|
|
82
|
+
@blue = Color::Blue.new(value: blue)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class << self
|
|
86
|
+
# Parse an RGB color from a string.
|
|
87
|
+
#
|
|
88
|
+
# Accepts multiple formats:
|
|
89
|
+
# - Hex with hash: "#FF8800", "#F80"
|
|
90
|
+
# - Hex without hash: "FF8800", "F80"
|
|
91
|
+
# - RGB function: "rgb(255, 128, 0)"
|
|
92
|
+
# - Raw values: "255, 128, 0"
|
|
93
|
+
#
|
|
94
|
+
# @param input [String] The color string to parse
|
|
95
|
+
# @return [RGB] The parsed RGB color
|
|
96
|
+
# @raise [ParseError] If the input format is invalid
|
|
97
|
+
#
|
|
98
|
+
# @example Parse hex colors
|
|
99
|
+
# RGB.parse("#FF8800")
|
|
100
|
+
#
|
|
101
|
+
# RGB.parse("F80")
|
|
102
|
+
#
|
|
103
|
+
# @example Parse RGB function
|
|
104
|
+
# RGB.parse("rgb(255, 128, 0)")
|
|
105
|
+
def parse(input)
|
|
106
|
+
raise ParseError, "Input must be a string" unless input.is_a?(::String)
|
|
107
|
+
|
|
108
|
+
input = input.strip
|
|
109
|
+
|
|
110
|
+
# Check if it looks like a hex color (starts with # or only contains hex digits)
|
|
111
|
+
if input.start_with?("#") || input.match?(/\A[0-9A-Fa-f]{3,6}\z/)
|
|
112
|
+
return Hex.parse(input)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Try to parse as RGB format
|
|
116
|
+
parse_rgb_format(input)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Generate a deterministic RGB color from an integer seed.
|
|
120
|
+
#
|
|
121
|
+
# This creates consistent, visually distinct colors from hash values or IDs.
|
|
122
|
+
# The same seed always produces the same color, making it useful for:
|
|
123
|
+
# - User avatars (hash their email/username)
|
|
124
|
+
# - Syntax highlighting (hash the token type)
|
|
125
|
+
# - Data visualization (hash category names)
|
|
126
|
+
#
|
|
127
|
+
# @param seed [Integer] The seed value (typically from a hash function)
|
|
128
|
+
# @param brightness [Integer] Target average brightness (0-255, default 180)
|
|
129
|
+
# @param saturation [Float] Color intensity (0.0-1.0, default 0.7)
|
|
130
|
+
# @return [RGB] A deterministic color based on the seed
|
|
131
|
+
# @raise [ArgumentError] If seed is not an integer
|
|
132
|
+
#
|
|
133
|
+
# @example Generate color from email
|
|
134
|
+
# email = "user@example.com"
|
|
135
|
+
# RGB.derive(email.hash)
|
|
136
|
+
#
|
|
137
|
+
# @example Low saturation for subtle colors
|
|
138
|
+
# RGB.derive(12345, saturation: 0.3)
|
|
139
|
+
#
|
|
140
|
+
# @example High brightness for light colors
|
|
141
|
+
# RGB.derive(12345, brightness: 230)
|
|
142
|
+
def derive(seed, brightness: 180, saturation: 0.7)
|
|
143
|
+
raise ArgumentError, "Seed must be an integer" unless seed.is_a?(Integer)
|
|
144
|
+
|
|
145
|
+
h32 = seed & 0xFFFFFFFF # Ensure 32-bit
|
|
146
|
+
|
|
147
|
+
# Extract RGB components from different parts of the hash
|
|
148
|
+
r_base = (h32 & 0xFF)
|
|
149
|
+
g_base = ((h32 >> 8) & 0xFF)
|
|
150
|
+
b_base = ((h32 >> 16) & 0xFF)
|
|
151
|
+
|
|
152
|
+
# Apply brightness and saturation adjustments
|
|
153
|
+
# Brightness controls the average RGB value
|
|
154
|
+
# Saturation controls how much the channels differ from each other
|
|
155
|
+
|
|
156
|
+
avg = (r_base + g_base + b_base) / 3.0
|
|
157
|
+
|
|
158
|
+
# Adjust each channel relative to average
|
|
159
|
+
r = avg + (r_base - avg) * saturation
|
|
160
|
+
g = avg + (g_base - avg) * saturation
|
|
161
|
+
b = avg + (b_base - avg) * saturation
|
|
162
|
+
|
|
163
|
+
# Scale to target brightness
|
|
164
|
+
scale = brightness / 127.5 # 127.5 is middle of 0-255
|
|
165
|
+
r = (r * scale).clamp(0, 255).round
|
|
166
|
+
g = (g * scale).clamp(0, 255).round
|
|
167
|
+
b = (b * scale).clamp(0, 255).round
|
|
168
|
+
|
|
169
|
+
new(red: r, green: g, blue: b)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Parse RGB format like "rgb(255, 128, 0)" or "255, 128, 0"
|
|
173
|
+
#
|
|
174
|
+
# @param input [String] RGB string to parse
|
|
175
|
+
# @return [RGB] Parsed RGB color
|
|
176
|
+
# @raise [ParseError] If format is invalid
|
|
177
|
+
def parse_rgb_format(input)
|
|
178
|
+
# Remove rgb() wrapper if present
|
|
179
|
+
clean = input.gsub(/^rgb\s*\(\s*|\s*\)$/, "").strip
|
|
180
|
+
|
|
181
|
+
# Split values
|
|
182
|
+
values = clean.split(/\s*,\s*/)
|
|
183
|
+
unless values.length == 3
|
|
184
|
+
raise ParseError, "Expected 3 RGB values, got #{values.length}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Check if all values are numeric (allow negative for clamping)
|
|
188
|
+
values.each_with_index do |v, i|
|
|
189
|
+
unless v.match?(/\A-?\d+\z/)
|
|
190
|
+
component = ["red", "green", "blue"][i]
|
|
191
|
+
raise ParseError, "Invalid #{component} value: #{v.inspect} (must be a number)"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Convert to integers (constructor will clamp)
|
|
196
|
+
parsed = values.map(&:to_i)
|
|
197
|
+
|
|
198
|
+
new(red: parsed[0], green: parsed[1], blue: parsed[2])
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Convert to RGB color space.
|
|
203
|
+
#
|
|
204
|
+
# Since this is already an RGB color, returns self.
|
|
205
|
+
#
|
|
206
|
+
# @return [RGB] self
|
|
207
|
+
def to_rgb
|
|
208
|
+
self
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Convert to hexadecimal color string.
|
|
212
|
+
#
|
|
213
|
+
# Returns a lowercase hex string with hash prefix, always 6 characters
|
|
214
|
+
# (2 per component).
|
|
215
|
+
#
|
|
216
|
+
# @return [String] Hex color string like "#ff5733"
|
|
217
|
+
#
|
|
218
|
+
# @example
|
|
219
|
+
# rgb = RGB.new(red: 255, green: 87, blue: 51)
|
|
220
|
+
# rgb.to_hex
|
|
221
|
+
# # => "#ff5733"
|
|
222
|
+
def to_hex
|
|
223
|
+
format("#%02x%02x%02x", @red.value, @green.value, @blue.value)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Convert to HSL color space.
|
|
227
|
+
#
|
|
228
|
+
# Converts this RGB color to HSL (Hue, Saturation, Lightness).
|
|
229
|
+
# HSL is often more intuitive for color manipulation.
|
|
230
|
+
#
|
|
231
|
+
# @return [HSL] The color in HSL color space
|
|
232
|
+
#
|
|
233
|
+
# @example
|
|
234
|
+
# rgb = RGB.parse("#FF5733")
|
|
235
|
+
# hsl = rgb.to_hsl
|
|
236
|
+
#
|
|
237
|
+
# hsl.hue.value # => 11.0
|
|
238
|
+
# hsl.saturation.value # => 100.0
|
|
239
|
+
# hsl.lightness.value # => 60.0
|
|
240
|
+
def to_hsl
|
|
241
|
+
r = @red.value / 255.0
|
|
242
|
+
g = @green.value / 255.0
|
|
243
|
+
b = @blue.value / 255.0
|
|
244
|
+
|
|
245
|
+
max = [r, g, b].max
|
|
246
|
+
min = [r, g, b].min
|
|
247
|
+
delta = max - min
|
|
248
|
+
|
|
249
|
+
# Lightness
|
|
250
|
+
l = (max + min) / 2.0
|
|
251
|
+
|
|
252
|
+
if delta == 0
|
|
253
|
+
# Achromatic
|
|
254
|
+
h = 0
|
|
255
|
+
s = 0
|
|
256
|
+
else
|
|
257
|
+
# Saturation
|
|
258
|
+
s = l > 0.5 ? delta / (2.0 - max - min) : delta / (max + min)
|
|
259
|
+
|
|
260
|
+
# Hue
|
|
261
|
+
h = case max
|
|
262
|
+
when r then ((g - b) / delta + (g < b ? 6 : 0)) / 6.0
|
|
263
|
+
when g then ((b - r) / delta + 2) / 6.0
|
|
264
|
+
when b then ((r - g) / delta + 4) / 6.0
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
Unmagic::Color::HSL.new(hue: (h * 360).round, saturation: (s * 100).round, lightness: (l * 100).round)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Convert to OKLCH color space.
|
|
272
|
+
#
|
|
273
|
+
# Converts this RGB color to OKLCH (Lightness, Chroma, Hue).
|
|
274
|
+
#
|
|
275
|
+
# @return [OKLCH] The color in OKLCH color space
|
|
276
|
+
# @note This is currently a simplified approximation.
|
|
277
|
+
def to_oklch
|
|
278
|
+
# For now, simple approximation based on RGB -> HSL -> OKLCH
|
|
279
|
+
# This is a simplified placeholder
|
|
280
|
+
require_relative "oklch"
|
|
281
|
+
# Convert lightness roughly from RGB luminance
|
|
282
|
+
l = luminance
|
|
283
|
+
# Approximate chroma from saturation and lightness
|
|
284
|
+
hsl = to_hsl
|
|
285
|
+
c = (hsl.saturation / 100.0) * 0.2 * (1 - (l - 0.5).abs * 2)
|
|
286
|
+
h = hsl.hue
|
|
287
|
+
Unmagic::Color::OKLCH.new(lightness: l, chroma: c, hue: h)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Calculate the relative luminance.
|
|
291
|
+
#
|
|
292
|
+
# This is the perceived brightness of the color according to the WCAG
|
|
293
|
+
# specification, accounting for how the human eye responds differently
|
|
294
|
+
# to red, green, and blue light.
|
|
295
|
+
#
|
|
296
|
+
# @return [Float] Luminance from 0.0 (black) to 1.0 (white)
|
|
297
|
+
#
|
|
298
|
+
# @example Check if text will be readable
|
|
299
|
+
# bg = Unmagic::Color::RGB.parse("#336699")
|
|
300
|
+
# bg.luminance.round(2)
|
|
301
|
+
# # => 0.13
|
|
302
|
+
#
|
|
303
|
+
# text_color = bg.luminance > 0.5 ? "dark" : "light"
|
|
304
|
+
# # => "light"
|
|
305
|
+
def luminance
|
|
306
|
+
r = @red.value / 255.0
|
|
307
|
+
g = @green.value / 255.0
|
|
308
|
+
b = @blue.value / 255.0
|
|
309
|
+
|
|
310
|
+
r = r <= 0.03928 ? r / 12.92 : ((r + 0.055) / 1.055)**2.4
|
|
311
|
+
g = g <= 0.03928 ? g / 12.92 : ((g + 0.055) / 1.055)**2.4
|
|
312
|
+
b = b <= 0.03928 ? b / 12.92 : ((b + 0.055) / 1.055)**2.4
|
|
313
|
+
|
|
314
|
+
0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Blend this color with another color.
|
|
318
|
+
#
|
|
319
|
+
# Blends in RGB space by linearly interpolating each component.
|
|
320
|
+
#
|
|
321
|
+
# @param other [Color] The color to blend with (automatically converted to RGB)
|
|
322
|
+
# @param amount [Float] How much of the other color to mix in (0.0-1.0)
|
|
323
|
+
# @return [RGB] A new color that is a blend of the two
|
|
324
|
+
#
|
|
325
|
+
# @example Mix two colors equally
|
|
326
|
+
# red = RGB.parse("#FF0000")
|
|
327
|
+
# blue = RGB.parse("#0000FF")
|
|
328
|
+
# purple = red.blend(blue, 0.5)
|
|
329
|
+
# purple.to_hex # => "#800080"
|
|
330
|
+
#
|
|
331
|
+
# @example Tint with 10% white
|
|
332
|
+
# base = RGB.parse("#336699")
|
|
333
|
+
# lighter = base.blend(RGB.new(red: 255, green: 255, blue: 255), 0.1)
|
|
334
|
+
def blend(other, amount = 0.5)
|
|
335
|
+
amount = amount.to_f.clamp(0, 1)
|
|
336
|
+
other_rgb = other.respond_to?(:to_rgb) ? other.to_rgb : other
|
|
337
|
+
|
|
338
|
+
Unmagic::Color::RGB.new(
|
|
339
|
+
red: (@red.value * (1 - amount) + other_rgb.red.value * amount).round,
|
|
340
|
+
green: (@green.value * (1 - amount) + other_rgb.green.value * amount).round,
|
|
341
|
+
blue: (@blue.value * (1 - amount) + other_rgb.blue.value * amount).round,
|
|
342
|
+
)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Create a lighter version by blending with white.
|
|
346
|
+
#
|
|
347
|
+
# @param amount [Float] How much white to mix in (0.0-1.0, default 0.1)
|
|
348
|
+
# @return [RGB] A lighter version of this color
|
|
349
|
+
#
|
|
350
|
+
# @example Make a color 20% lighter
|
|
351
|
+
# dark = RGB.parse("#003366")
|
|
352
|
+
# light = dark.lighten(0.2)
|
|
353
|
+
def lighten(amount = 0.1)
|
|
354
|
+
blend(Unmagic::Color::RGB.new(red: 255, green: 255, blue: 255), amount)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Create a darker version by blending with black.
|
|
358
|
+
#
|
|
359
|
+
# @param amount [Float] How much black to mix in (0.0-1.0, default 0.1)
|
|
360
|
+
# @return [RGB] A darker version of this color
|
|
361
|
+
#
|
|
362
|
+
# @example Make a color 30% darker
|
|
363
|
+
# bright = RGB.parse("#FF9966")
|
|
364
|
+
# dark = bright.darken(0.3)
|
|
365
|
+
def darken(amount = 0.1)
|
|
366
|
+
blend(Unmagic::Color::RGB.new(red: 0, green: 0, blue: 0), amount)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Check if two RGB colors are equal.
|
|
370
|
+
#
|
|
371
|
+
# @param other [Object] The object to compare with
|
|
372
|
+
# @return [Boolean] true if both colors have the same RGB values
|
|
373
|
+
def ==(other)
|
|
374
|
+
other.is_a?(Unmagic::Color::RGB) &&
|
|
375
|
+
@red == other.red &&
|
|
376
|
+
@green == other.green &&
|
|
377
|
+
@blue == other.blue
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Convert to string representation.
|
|
381
|
+
#
|
|
382
|
+
# Returns the hex representation of the color.
|
|
383
|
+
#
|
|
384
|
+
# @return [String] Hex color string like "#ff5733"
|
|
385
|
+
def to_s
|
|
386
|
+
to_hex
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/unmagic/color/string/hash_function.rb
|
|
4
|
+
module Unmagic
|
|
5
|
+
class Color
|
|
6
|
+
# String utilities for color generation
|
|
7
|
+
class String
|
|
8
|
+
# Hash functions for generating deterministic colors from strings
|
|
9
|
+
module HashFunction
|
|
10
|
+
# Simple sum of character codes. Adds up the ASCII/Unicode value of each character.
|
|
11
|
+
# Distribution: Poor - similar strings get similar hashes, anagrams get identical hashes.
|
|
12
|
+
# Colors: Tends to cluster in mid-range hues, very predictable.
|
|
13
|
+
#
|
|
14
|
+
# @example Anagrams produce identical hashes
|
|
15
|
+
# SUM.call("cat")
|
|
16
|
+
# #=> 312
|
|
17
|
+
# SUM.call("act")
|
|
18
|
+
# #=> 312
|
|
19
|
+
#
|
|
20
|
+
# Use when you want anagrams to have the same color.
|
|
21
|
+
SUM = ->(str) {
|
|
22
|
+
str.chars.sum(&:ord)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Dan Bernstein's DJB2 algorithm. Starts with 5381, then for each byte: hash = hash * 33 + byte.
|
|
26
|
+
# Distribution: Good spread across the number space with few collisions.
|
|
27
|
+
# Colors: Well-distributed hues, good variety even for similar strings.
|
|
28
|
+
#
|
|
29
|
+
# @example Similar strings produce different hashes
|
|
30
|
+
# DJB2.call("hello")
|
|
31
|
+
# #=> 210676686985
|
|
32
|
+
# DJB2.call("hallo")
|
|
33
|
+
# #=> 210676864905
|
|
34
|
+
#
|
|
35
|
+
# Use when you need general purpose hashing, good default choice.
|
|
36
|
+
DJB2 = ->(str) {
|
|
37
|
+
str.bytes.reduce(5381) do |hash, byte|
|
|
38
|
+
((hash << 5) + hash) + byte
|
|
39
|
+
end.abs
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Brian Kernighan & Dennis Ritchie's BKDR hash. Multiplies hash by prime number (131) and adds each byte.
|
|
43
|
+
# Distribution: Excellent - one of the best distributions for hash tables.
|
|
44
|
+
# Colors: Very uniform color distribution across entire spectrum.
|
|
45
|
+
#
|
|
46
|
+
# @example Single character changes create vastly different hashes
|
|
47
|
+
# BKDR.call("test")
|
|
48
|
+
# #=> 2996398963
|
|
49
|
+
# BKDR.call("tast")
|
|
50
|
+
# #=> 2996267891
|
|
51
|
+
#
|
|
52
|
+
# Use when you need the most random-looking, well-distributed colors.
|
|
53
|
+
BKDR = ->(str) {
|
|
54
|
+
seed = 131
|
|
55
|
+
str.bytes.reduce(0) do |hash, byte|
|
|
56
|
+
(hash * seed + byte) & 0xFFFFFFFF
|
|
57
|
+
end
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Fowler-Noll-Vo 1a hash (32-bit). XORs each byte with hash, then multiplies by prime 16777619.
|
|
61
|
+
# Distribution: Excellent avalanche effect - tiny changes cascade throughout hash.
|
|
62
|
+
# Colors: Extremely sensitive to input changes, neighboring strings get distant colors.
|
|
63
|
+
#
|
|
64
|
+
# @example Sequential strings get unrelated hashes
|
|
65
|
+
# FNV1A.call("test1")
|
|
66
|
+
# #=> 1951951766
|
|
67
|
+
# FNV1A.call("test2")
|
|
68
|
+
# #=> 1968729175
|
|
69
|
+
#
|
|
70
|
+
# Use when you want maximum color variety for sequential/numbered items.
|
|
71
|
+
FNV1A = ->(str) {
|
|
72
|
+
fnv_prime = 16777619
|
|
73
|
+
offset_basis = 2166136261
|
|
74
|
+
|
|
75
|
+
str.bytes.reduce(offset_basis) do |hash, byte|
|
|
76
|
+
((hash ^ byte) * fnv_prime) & 0xFFFFFFFF
|
|
77
|
+
end
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# SDBM hash algorithm (used in Berkeley DB). Combines bit shifting (6 and 16 positions) with subtraction.
|
|
81
|
+
# Distribution: Good distribution with interesting bit patterns.
|
|
82
|
+
# Colors: Tends to create slightly warmer hues due to bit pattern biases.
|
|
83
|
+
#
|
|
84
|
+
# @example Works well for database keys
|
|
85
|
+
# SDBM.call("user_123")
|
|
86
|
+
# #=> 1642793946939
|
|
87
|
+
# SDBM.call("order_456")
|
|
88
|
+
# #=> 1414104772796
|
|
89
|
+
#
|
|
90
|
+
# Use when you're hashing database IDs or system identifiers.
|
|
91
|
+
SDBM = ->(str) {
|
|
92
|
+
str.bytes.reduce(0) do |hash, byte|
|
|
93
|
+
byte + (hash << 6) + (hash << 16) - hash
|
|
94
|
+
end.abs
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Java-style string hashCode. Multiplies hash by 31 and adds character code (polynomial rolling).
|
|
98
|
+
# Distribution: Decent but can cluster with short strings.
|
|
99
|
+
# Colors: Predictable patterns for sequential strings, good for related items.
|
|
100
|
+
#
|
|
101
|
+
# @example Sequential items get progressively shifting hashes
|
|
102
|
+
# JAVA.call("item1")
|
|
103
|
+
# #=> 100475638
|
|
104
|
+
# JAVA.call("item2")
|
|
105
|
+
# #=> 100475639
|
|
106
|
+
#
|
|
107
|
+
# Use when you want compatibility with Java systems or predictable gradients.
|
|
108
|
+
JAVA = ->(str) {
|
|
109
|
+
str.chars.reduce(0) do |hash, char|
|
|
110
|
+
31 * hash + char.ord
|
|
111
|
+
end.abs
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# CRC32 (Cyclic Redundancy Check). Polynomial division for error detection, highly mathematical.
|
|
115
|
+
# Distribution: Excellent - designed to detect even single-bit changes.
|
|
116
|
+
# Colors: Extremely uniform distribution, appears most "random".
|
|
117
|
+
#
|
|
118
|
+
# @example Swapping characters produces different hashes
|
|
119
|
+
# CRC32.call("abc")
|
|
120
|
+
# #=> 891568578
|
|
121
|
+
# CRC32.call("bac")
|
|
122
|
+
# #=> 1294269411
|
|
123
|
+
#
|
|
124
|
+
# Use when you need the most uniform, professional-looking color distribution.
|
|
125
|
+
CRC32 = ->(str) {
|
|
126
|
+
require "zlib"
|
|
127
|
+
Zlib.crc32(str)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# MD5-based hash (truncated to 32 bits). Cryptographic hash truncated to first 8 hex characters.
|
|
131
|
+
# Distribution: Perfect distribution but computationally expensive.
|
|
132
|
+
# Colors: Absolutely uniform distribution, no patterns whatsoever.
|
|
133
|
+
#
|
|
134
|
+
# @example Cryptographic hash provides perfect distribution
|
|
135
|
+
# MD5.call("secret")
|
|
136
|
+
# #=> 1528250989
|
|
137
|
+
# MD5.call("secrat")
|
|
138
|
+
# #=> 2854876444
|
|
139
|
+
#
|
|
140
|
+
# Use when color security matters or you need perfect randomness.
|
|
141
|
+
MD5 = ->(str) {
|
|
142
|
+
require "digest"
|
|
143
|
+
Digest::MD5.hexdigest(str)[0..7].to_i(16)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Position-weighted hash. Each character's value is multiplied by its position squared.
|
|
147
|
+
# Distribution: Order-sensitive - rearranging characters changes the hash.
|
|
148
|
+
# Colors: "ABC" and "CBA" get different colors, early characters have more impact.
|
|
149
|
+
#
|
|
150
|
+
# @example Character order affects the hash
|
|
151
|
+
# POSITION.call("ABC")
|
|
152
|
+
# #=> 1629
|
|
153
|
+
# POSITION.call("CBA")
|
|
154
|
+
# #=> 1773
|
|
155
|
+
#
|
|
156
|
+
# Use when character order matters (like initials or codes).
|
|
157
|
+
POSITION = ->(str) {
|
|
158
|
+
str.chars.map.with_index do |char, index|
|
|
159
|
+
char.ord * ((index + 1)**2)
|
|
160
|
+
end.sum
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
# Case-insensitive perceptual hash. Normalizes string, counts character frequency, squares char codes.
|
|
164
|
+
# Distribution: Groups similar strings together regardless of case or punctuation.
|
|
165
|
+
# Colors: "Hello!" and "hello" get same color, focuses on letter content.
|
|
166
|
+
#
|
|
167
|
+
# @example Case-insensitive hashing
|
|
168
|
+
# PERCEPTUAL.call("JohnDoe")
|
|
169
|
+
# #=> 610558
|
|
170
|
+
# PERCEPTUAL.call("johndoe")
|
|
171
|
+
# #=> 610558
|
|
172
|
+
#
|
|
173
|
+
# Use when you want visual similarity for perceptually similar strings.
|
|
174
|
+
PERCEPTUAL = ->(str) {
|
|
175
|
+
normalized = str.downcase.gsub(/[^a-z0-9]/, "")
|
|
176
|
+
char_freq = normalized.chars.tally
|
|
177
|
+
|
|
178
|
+
char_freq.reduce(0) do |hash, (char, count)|
|
|
179
|
+
hash + (char.ord**2) * count
|
|
180
|
+
end
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Color-aware hash (detects color names in string). Searches for color words and biases hash toward that hue.
|
|
184
|
+
# Distribution: Biased toward mentioned colors, otherwise uses DJB2.
|
|
185
|
+
# Colors: "red_apple" gets reddish hue, "blue_sky" gets bluish hue.
|
|
186
|
+
#
|
|
187
|
+
# @example Biases toward mentioned colors
|
|
188
|
+
# COLOR_AWARE.call("green_team")
|
|
189
|
+
# #=> 120042
|
|
190
|
+
# COLOR_AWARE.call("purple_hearts")
|
|
191
|
+
# #=> 270047
|
|
192
|
+
#
|
|
193
|
+
# Use when text might contain color names (usernames, team names, tags).
|
|
194
|
+
COLOR_AWARE = ->(str) {
|
|
195
|
+
color_hues = {
|
|
196
|
+
"red" => 0,
|
|
197
|
+
"scarlet" => 10,
|
|
198
|
+
"crimson" => 5,
|
|
199
|
+
"orange" => 30,
|
|
200
|
+
"amber" => 45,
|
|
201
|
+
"yellow" => 60,
|
|
202
|
+
"gold" => 50,
|
|
203
|
+
"green" => 120,
|
|
204
|
+
"lime" => 90,
|
|
205
|
+
"emerald" => 140,
|
|
206
|
+
"cyan" => 180,
|
|
207
|
+
"teal" => 165,
|
|
208
|
+
"turquoise" => 175,
|
|
209
|
+
"blue" => 240,
|
|
210
|
+
"navy" => 235,
|
|
211
|
+
"azure" => 210,
|
|
212
|
+
"purple" => 270,
|
|
213
|
+
"violet" => 280,
|
|
214
|
+
"indigo" => 255,
|
|
215
|
+
"pink" => 330,
|
|
216
|
+
"magenta" => 300,
|
|
217
|
+
"rose" => 345,
|
|
218
|
+
"brown" => 25,
|
|
219
|
+
"tan" => 35,
|
|
220
|
+
"gray" => 0,
|
|
221
|
+
"grey" => 0,
|
|
222
|
+
"black" => 0,
|
|
223
|
+
"white" => 0,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
detected_hue = color_hues.find do |word, _|
|
|
227
|
+
str.downcase.include?(word)
|
|
228
|
+
end&.last
|
|
229
|
+
|
|
230
|
+
base_hash = DJB2.call(str)
|
|
231
|
+
|
|
232
|
+
if detected_hue && detected_hue > 0
|
|
233
|
+
# Bias toward detected color with ±30° variation
|
|
234
|
+
(detected_hue * 1000) + (base_hash % 60) - 30
|
|
235
|
+
else
|
|
236
|
+
base_hash
|
|
237
|
+
end
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
# MurmurHash3 (32-bit version). Uses multiplication, rotation, and XOR for mixing.
|
|
241
|
+
# Distribution: Excellent - designed for hash tables by Google.
|
|
242
|
+
# Colors: Very uniform, fast, good avalanche properties.
|
|
243
|
+
#
|
|
244
|
+
# @example Fast and high quality hash function
|
|
245
|
+
# MURMUR3.call("database_key")
|
|
246
|
+
# #=> 3208616715
|
|
247
|
+
# MURMUR3.call("cache_entry")
|
|
248
|
+
# #=> 1882174324
|
|
249
|
+
#
|
|
250
|
+
# Use when performance matters and you need excellent distribution.
|
|
251
|
+
MURMUR3 = ->(str) {
|
|
252
|
+
c1 = 0xcc9e2d51
|
|
253
|
+
c2 = 0x1b873593
|
|
254
|
+
seed = 0
|
|
255
|
+
|
|
256
|
+
hash = seed
|
|
257
|
+
|
|
258
|
+
str.bytes.each_slice(4) do |chunk|
|
|
259
|
+
k = 0
|
|
260
|
+
chunk.each_with_index { |byte, i| k |= byte << (i * 8) }
|
|
261
|
+
|
|
262
|
+
k = (k * c1) & 0xFFFFFFFF
|
|
263
|
+
k = ((k << 15) | (k >> 17)) & 0xFFFFFFFF
|
|
264
|
+
k = (k * c2) & 0xFFFFFFFF
|
|
265
|
+
|
|
266
|
+
hash ^= k
|
|
267
|
+
hash = ((hash << 13) | (hash >> 19)) & 0xFFFFFFFF
|
|
268
|
+
hash = (hash * 5 + 0xe6546b64) & 0xFFFFFFFF
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
hash ^= str.bytesize
|
|
272
|
+
hash ^= hash >> 16
|
|
273
|
+
hash = (hash * 0x85ebca6b) & 0xFFFFFFFF
|
|
274
|
+
hash ^= hash >> 13
|
|
275
|
+
hash = (hash * 0xc2b2ae35) & 0xFFFFFFFF
|
|
276
|
+
hash ^= hash >> 16
|
|
277
|
+
|
|
278
|
+
hash
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
# Default algorithm - BKDR for its excellent distribution
|
|
282
|
+
DEFAULT = BKDR
|
|
283
|
+
|
|
284
|
+
class << self
|
|
285
|
+
# Call the default hash function
|
|
286
|
+
#
|
|
287
|
+
# @param str [String] String to hash
|
|
288
|
+
# @return [Integer] Hash value
|
|
289
|
+
def call(str)
|
|
290
|
+
DEFAULT.call(str)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Get all available algorithms
|
|
294
|
+
#
|
|
295
|
+
# @return [Hash] Hash of algorithm names to procs
|
|
296
|
+
def all
|
|
297
|
+
constants.select { |c| const_get(c).is_a?(Proc) }
|
|
298
|
+
.map { |c| [c.to_s.downcase.to_sym, const_get(c)] }
|
|
299
|
+
.to_h
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Get a hash function by name
|
|
303
|
+
#
|
|
304
|
+
# @param name [String, Symbol] Name of the hash function
|
|
305
|
+
# @return [Proc] The hash function
|
|
306
|
+
# @raise [ArgumentError] If hash function not found
|
|
307
|
+
def [](name)
|
|
308
|
+
const_get(name.to_s.upcase)
|
|
309
|
+
rescue NameError
|
|
310
|
+
raise ArgumentError, "Unknown hash function: #{name}"
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|