Skip to content

Instantly share code, notes, and snippets.

@nberlette
Last active April 17, 2025 07:02
Show Gist options
  • Select an option

  • Save nberlette/33a1a21f66a0b9814d73006cd080ce5f to your computer and use it in GitHub Desktop.

Select an option

Save nberlette/33a1a21f66a0b9814d73006cd080ce5f to your computer and use it in GitHub Desktop.
colorhash
/**
* @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();
});
});
});
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 });
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment