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.
@@ -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