Last active
April 17, 2025 07:02
-
-
Save nberlette/33a1a21f66a0b9814d73006cd080ce5f to your computer and use it in GitHub Desktop.
colorhash
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @fileoverview Unit tests for the ColorHash utility using BDD-style with describe and it. | |
*/ | |
import { describe, it } from "jsr:@std/testing@~1/bdd"; | |
import { expect } from "jsr:@std/expect@~1"; | |
import { ColorHash, ColorHashOptions, ColorFormat, ColorRange } from "colorhash"; | |
/** | |
* Test suite for the ColorHash class. | |
*/ | |
describe("ColorHash", () => { | |
/** | |
* Test the default initialization of ColorHash. | |
*/ | |
describe("Initialization", () => { | |
it("should initialize with default options", () => { | |
const colorHash = new ColorHash(); | |
expect(colorHash).toBeDefined(); | |
}); | |
it("should initialize with custom hue, saturation, and lightness ranges", () => { | |
const options: ColorHashOptions = { | |
hue: { min: 100, max: 200 }, | |
saturation: { min: 60, max: 80 }, | |
lightness: { min: 30, max: 50 }, | |
}; | |
const colorHash = new ColorHash(options); | |
expect(colorHash).toBeDefined(); | |
}); | |
it("should initialize with a custom format", () => { | |
const options: ColorHashOptions = { | |
format: "rgb", | |
}; | |
const colorHash = new ColorHash(options); | |
expect(colorHash).toBeDefined(); | |
}); | |
it("should initialize with a custom cache", () => { | |
class CustomCache implements CacheStorage { | |
private store: Map<string, string> = new Map(); | |
get(key: string): string | undefined { | |
return this.store.get(key); | |
} | |
set(key: string, value: string): void { | |
this.store.set(key, value); | |
} | |
} | |
const options: ColorHashOptions = { | |
cache: new CustomCache(), | |
}; | |
const colorHash = new ColorHash(options); | |
expect(colorHash).toBeDefined(); | |
}); | |
it("should initialize with a custom hash function", () => { | |
const customHash = (input: string): number => { | |
return input.length; | |
}; | |
const options: ColorHashOptions = { | |
hashFunction: customHash, | |
}; | |
const colorHash = new ColorHash(options); | |
expect(colorHash).toBeDefined(); | |
}); | |
}); | |
/** | |
* Test the getColor method. | |
*/ | |
describe("getColor", () => { | |
it("should generate the same color for the same input", () => { | |
const colorHash = new ColorHash(); | |
const color1 = colorHash.getColor("test-string"); | |
const color2 = colorHash.getColor("test-string"); | |
expect(color1).toBe(color2); | |
}); | |
it("should generate different colors for different inputs", () => { | |
const colorHash = new ColorHash(); | |
const color1 = colorHash.getColor("test-string-1"); | |
const color2 = colorHash.getColor("test-string-2"); | |
expect(color1).not.toBe(color2); | |
}); | |
it("should generate colors in the specified format (hex)", () => { | |
const options: ColorHashOptions = { | |
format: "hex", | |
}; | |
const colorHash = new ColorHash(options); | |
const color = colorHash.getColor("hex-format"); | |
expect(color).toMatch(/^#[0-9a-fA-F]{6}$/); | |
}); | |
it("should generate colors in the specified format (rgb)", () => { | |
const options: ColorHashOptions = { | |
format: "rgb", | |
}; | |
const colorHash = new ColorHash(options); | |
const color = colorHash.getColor("rgb-format"); | |
expect(color).toMatch(/^rgb\(\d{1,3}, \d{1,3}, \d{1,3}\)$/); | |
}); | |
it("should generate colors in the specified format (numeric)", () => { | |
const options: ColorHashOptions = { | |
format: "numeric", | |
}; | |
const colorHash = new ColorHash(options); | |
const color = colorHash.getColor("numeric-format"); | |
expect(() => parseInt(color, 10)).not.toThrow(); | |
}); | |
it("should generate colors in the specified format (ansi24)", () => { | |
const options: ColorHashOptions = { | |
format: "ansi24", | |
}; | |
const colorHash = new ColorHash(options); | |
const color = colorHash.getColor("ansi24-format"); | |
expect(color).toMatch(/^\x1b\[38;2;\d{1,3};\d{1,3};\d{1,3}m$/); | |
}); | |
it("should generate colors in the specified format (ansi8)", () => { | |
const options: ColorHashOptions = { | |
format: "ansi8", | |
}; | |
const colorHash = new ColorHash(options); | |
const color = colorHash.getColor("ansi8-format"); | |
expect(color).toMatch(/^\x1b\[38;5;\d{1,3}m$/); | |
}); | |
}); | |
/** | |
* Test the static color conversion methods. | |
*/ | |
describe("Static Methods", () => { | |
describe("hslToRgb", () => { | |
it("should convert HSL to RGB correctly", () => { | |
const rgb = ColorHash.hslToRgb(0, 100, 50); | |
expect(rgb).toEqual([255, 0, 0]); | |
const rgb2 = ColorHash.hslToRgb(120, 100, 50); | |
expect(rgb2).toEqual([0, 255, 0]); | |
const rgb3 = ColorHash.hslToRgb(240, 100, 50); | |
expect(rgb3).toEqual([0, 0, 255]); | |
const rgb4 = ColorHash.hslToRgb(60, 100, 50); | |
expect(rgb4).toEqual([255, 255, 0]); | |
}); | |
}); | |
describe("rgbToHex", () => { | |
it("should convert RGB to HEX correctly", () => { | |
const hex = ColorHash.rgbToHex([255, 0, 0]); | |
expect(hex).toBe("#ff0000"); | |
const hex2 = ColorHash.rgbToHex([0, 255, 0]); | |
expect(hex2).toBe("#00ff00"); | |
const hex3 = ColorHash.rgbToHex([0, 0, 255]); | |
expect(hex3).toBe("#0000ff"); | |
const hex4 = ColorHash.rgbToHex([255, 255, 0]); | |
expect(hex4).toBe("#ffff00"); | |
}); | |
}); | |
describe("rgbToNumber", () => { | |
it("should convert RGB to numeric correctly", () => { | |
const num = ColorHash.rgbToNumber([255, 0, 0]); | |
expect(num).toBe(0xff0000); | |
const num2 = ColorHash.rgbToNumber([0, 255, 0]); | |
expect(num2).toBe(0x00ff00); | |
const num3 = ColorHash.rgbToNumber([0, 0, 255]); | |
expect(num3).toBe(0x0000ff); | |
const num4 = ColorHash.rgbToNumber([255, 255, 0]); | |
expect(num4).toBe(0xffff00); | |
}); | |
}); | |
describe("rgbToAnsi24", () => { | |
it("should convert RGB to 24-bit ANSI correctly", () => { | |
const ansi = ColorHash.rgbToAnsi24([255, 0, 0]); | |
expect(ansi).toBe("\x1b[38;2;255;0;0m"); | |
const ansi2 = ColorHash.rgbToAnsi24([0, 255, 0]); | |
expect(ansi2).toBe("\x1b[38;2;0;255;0m"); | |
const ansi3 = ColorHash.rgbToAnsi24([0, 0, 255]); | |
expect(ansi3).toBe("\x1b[38;2;0;0;255m"); | |
const ansi4 = ColorHash.rgbToAnsi24([255, 255, 0]); | |
expect(ansi4).toBe("\x1b[38;2;255;255;0m"); | |
}); | |
}); | |
describe("rgbToAnsi8", () => { | |
it("should convert RGB to 8-bit ANSI correctly", () => { | |
const ansi = ColorHash.rgbToAnsi8([255, 0, 0]); | |
expect(ansi).toBe("\x1b[38;5;196m"); | |
const ansi2 = ColorHash.rgbToAnsi8([0, 255, 0]); | |
expect(ansi2).toBe("\x1b[38;5;46m"); | |
const ansi3 = ColorHash.rgbToAnsi8([0, 0, 255]); | |
expect(ansi3).toBe("\x1b[38;5;21m"); | |
const ansi4 = ColorHash.rgbToAnsi8([255, 255, 0]); | |
expect(ansi4).toBe("\x1b[38;5;226m"); | |
}); | |
}); | |
describe("rgbToAnsi256", () => { | |
it("should convert RGB to 256-bit ANSI correctly for colors", () => { | |
const ansi = ColorHash.rgbToAnsi256([255, 0, 0]); | |
expect(ansi).toBe(196); | |
const ansi2 = ColorHash.rgbToAnsi256([0, 255, 0]); | |
expect(ansi2).toBe(46); | |
const ansi3 = ColorHash.rgbToAnsi256([0, 0, 255]); | |
expect(ansi3).toBe(21); | |
const ansi4 = ColorHash.rgbToAnsi256([255, 255, 0]); | |
expect(ansi4).toBe(226); | |
}); | |
it("should convert RGB to 256-bit ANSI correctly for grayscale", () => { | |
const ansi = ColorHash.rgbToAnsi256([8, 8, 8]); | |
expect(ansi).toBe(232); | |
const ansi2 = ColorHash.rgbToAnsi256([248, 248, 248]); | |
expect(ansi2).toBe(231); | |
}); | |
}); | |
}); | |
/** | |
* Test the caching mechanism. | |
*/ | |
describe("Caching", () => { | |
it("should cache generated colors", () => { | |
const colorHash = new ColorHash(); | |
const spySet = jest.spyOn(colorHash['#cache'], 'set'); | |
const color1 = colorHash.getColor("cache-test"); | |
const color2 = colorHash.getColor("cache-test"); | |
expect(color1).toBe(color2); | |
expect(spySet).toHaveBeenCalledTimes(1); | |
}); | |
it("should evict least recently used items when capacity is exceeded", () => { | |
const capacity = 3; | |
const colorHash = new ColorHash({ cache: new DefaultCache(capacity) }); | |
colorHash.getColor("a"); | |
colorHash.getColor("b"); | |
colorHash.getColor("c"); | |
// Access 'a' to make it recently used | |
colorHash.getColor("a"); | |
// Add 'd', should evict 'b' | |
colorHash.getColor("d"); | |
const cache = colorHash['#cache'] as DefaultCache; | |
expect(cache.get("a")).toBeDefined(); | |
expect(cache.get("b")).toBeUndefined(); | |
expect(cache.get("c")).toBeDefined(); | |
expect(cache.get("d")).toBeDefined(); | |
}); | |
}); | |
/** | |
* Test custom hash functions. | |
*/ | |
describe("Custom Hash Function", () => { | |
it("should use the custom hash function for color generation", () => { | |
const customHash = (input: string): number => { | |
return input.length; | |
}; | |
const colorHash = new ColorHash({ hashFunction: customHash }); | |
const color1 = colorHash.getColor("test"); | |
const color2 = colorHash.getColor("four"); | |
// Both inputs have the same length, should produce the same color | |
expect(color1).toBe(color2); | |
const color3 = colorHash.getColor("five"); | |
// Different length, should produce different color | |
expect(color1).not.toBe(color3); | |
}); | |
}); | |
/** | |
* Test custom cache implementations. | |
*/ | |
describe("Custom Cache", () => { | |
it("should use the provided custom cache implementation", () => { | |
class InMemoryCache implements CacheStorage { | |
store: Map<string, string> = new Map(); | |
get(key: string): string | undefined { | |
return this.store.get(key); | |
} | |
set(key: string, value: string): void { | |
this.store.set(key, value); | |
} | |
} | |
const customCache = new InMemoryCache(); | |
const colorHash = new ColorHash({ cache: customCache }); | |
colorHash.getColor("custom-cache-test"); | |
expect(customCache.get("custom-cache-test")).toBeDefined(); | |
}); | |
}); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as ansi from "jsr:@std/fmt@1/colors"; | |
const GLOBAL_CACHE = new Map<string, Rgb>(); | |
export interface Rgb { | |
r: number; | |
g: number; | |
b: number; | |
} | |
export interface ColorHashOptions { | |
/** Lightness of the color (0-100) */ | |
lightness?: number; | |
/** Saturation of the color (0-100) */ | |
saturation?: number; | |
/** Generate a background color instead of a foreground color */ | |
background?: boolean; | |
/** Force the color to be generated, even if it is already cached */ | |
force?: boolean; | |
/** | |
* Controls the format of the generated color code. | |
* | |
* - `ansi` (default): ANSI color codes (e.g. `\x1b[38;2;255;255;255m`) | |
* - `hex`: Hex color codes (e.g. `#ffffff`) | |
* - `rgb`: RGB color codes (e.g. `rgb(255, 255, 255)`) | |
*/ | |
format?: "ansi" | "hex" | "rgb"; | |
/** Cache for storing generated colors. Must be a Map-like object. */ | |
cache?: Map<string, Rgb>; | |
} | |
/** | |
* Generates a deterministic ANSI color code based on the given string `s` and | |
* an optional set of `options`. The color is generated with the hash of the | |
* string's characters, ensuring that the same string will always generate the | |
* same color. Colors are cached for improved performance on subsequent calls. | |
* | |
* @param string The string to generate a color for. | |
* @param [options] Optional color generation options. | |
* @returns the input string `s` wrapped in the ANSI color code. | |
*/ | |
export function colorhash(string: string, options?: ColorHashOptions): string { | |
const { background = false, cache = GLOBAL_CACHE, ...opts } = options ??= {}; | |
let { lightness = 60, saturation = 60, format = "ansi" } = opts; | |
let key = String(string ??= "").trim().replace(/\W+/g, "_"); | |
key += `:${lightness}:${saturation}:${background ? "bg" : "fg"}`; | |
let color = cache.get(key); | |
if (!color) { | |
const hash = Array.from(key).reduce( | |
(acc, c) => c.charCodeAt(0) + ((acc << 5) - acc), | |
0, | |
); | |
const { abs, max, min, } = Math; | |
let hue = hash % 360; | |
if (hue < 0) hue += 360; | |
// ensure hue is not too close to white or black | |
if (hue > 60 && hue < 240) hue += hue > 180 ? -60 : 60; | |
// ensure lightness/saturation are not too close to the extremes | |
[lightness, saturation] = [lightness, saturation].map( | |
(n) => max(25, min(75, n >= 0 && n <= 1 ? n * 100 : n <= 100 ? n : 50)), | |
); | |
// convert the hsl into rgb for terminal color codes | |
lightness /= 100, saturation /= 100; | |
const c = (1 - abs(2 * lightness - 1)) * saturation; | |
const x = c * (1 - abs((hue / 60) % 2 - 1)); | |
const m = lightness - c / 2; | |
let r = 0, g = 0, b = 0; | |
// deno-fmt-ignore | |
switch (true) { | |
case hue >= 0 && hue < 60: [r, g, b] = [c, x, 0]; break; | |
case hue >= 60 && hue < 120: [r, g, b] = [x, c, 0]; break; | |
case hue >= 120 && hue < 180: [r, g, b] = [0, c, x]; break; | |
case hue >= 180 && hue < 240: [r, g, b] = [0, x, c]; break; | |
case hue >= 240 && hue < 300: [r, g, b] = [x, 0, c]; break; | |
case hue >= 300 && hue < 360: [r, g, b] = [c, 0, x]; break; | |
} | |
cache.set( | |
key, | |
color = { | |
r: ((r + m) * 255) >>> 0, | |
g: ((g + m) * 255) >>> 0, | |
b: ((b + m) * 255) >>> 0, | |
}, | |
); | |
} | |
const { r, g, b } = color; | |
switch (format) { | |
case "hex": { | |
let hex = ((r << 16) | (g << 8) | b).toString(16); | |
if (hex.length === 3) hex = hex.replace(/./g, "$&$&"); | |
hex = hex.padStart(6, "0"); | |
return `#${hex}`; | |
} | |
case "rgb": return `rgb(${r}, ${g}, ${b})`; | |
case "ansi": // fallthrough | |
default: { | |
return ansi[background ? "bgRgb24" : "rgb24"](string, { r, g, b }); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
The MIT License (MIT) | |
Copyright (c) 2023-2025+ Nicholas Berlette (https://github.com/nberlette) | |
Permission is hereby granted, free of charge, to any person obtaining a copy of | |
this software and associated documentation files (the "Software"), to deal in | |
the Software without restriction, including without limitation the rights to | |
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of | |
the Software, and to permit persons to whom the Software is furnished to do so, | |
subject to the following conditions: | |
The above copyright notice and this permission notice shall be included in all | |
copies or substantial portions of the Software. | |
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS | |
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR | |
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER | |
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN | |
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment