HSV color picker

I've created a HSV color picker, complete with color wheel. The color picker can also be switched to RGB seamlessly.

gear iconCheckout the live demo of Color picker

Code

Sadly I cannot really link any code, as it is not on a repository, but here some snippets:

Get HSV values, based on X / Y

export default function getHSVForPositionOnWheel(
	x: number,
	y: number,
	radius: number,
): [hue: number, saturation: number, value: number] {
	const distance = Math.sqrt(x * x + y * y);
	const radian = Math.atan2(y, x);
	const degrees = ((radian + Math.PI) / (2 * Math.PI)) * 360;

	return [degrees, distance / radius, 1];
}

Color utility

Here is the color class with meany useful things in it. Feel free to use it for educational purposes.

export default class Color {
	public static readonly BLACK = new Color(0, 0, 0, 255);
	public static readonly WHITE = new Color(255, 255, 255, 255);
	public static readonly HEX_RGBA_REGEX = /#([0-9]|[a-f]){8}/i;

	/**
	 * Creates a color from HSV
	 * @param hue value from `0` to `360`
	 * @param saturation value from `0` to `1`
	 * @param value value from `0` to `1`
	 * @param alpha value from `0` to `255`
	 */
	public static fromHSV(
		hue: number,
		saturation: number,
		value: number,
		alpha = 255,
	): Color {
		if (!this.isValidHSV(hue, saturation, value)) {
			throw new Error(
				`HSV values hue: ${hue}, saturation: ${saturation} value: ${value} are incorrect`,
			);
		}

		const chroma = saturation * value;
		const hue1 = hue / 60;
		const x = chroma * (1 - Math.abs((hue1 % 2) - 1));
		const m = value - chroma;

		let r1, g1, b1;

		if (hue1 >= 0 && hue1 <= 1) [r1, g1, b1] = [chroma, x, 0];
		else if (hue1 >= 1 && hue1 <= 2) [r1, g1, b1] = [x, chroma, 0];
		else if (hue1 >= 2 && hue1 <= 3) [r1, g1, b1] = [0, chroma, x];
		else if (hue1 >= 3 && hue1 <= 4) [r1, g1, b1] = [0, x, chroma];
		else if (hue1 >= 4 && hue1 <= 5) [r1, g1, b1] = [x, 0, chroma];
		else if (hue1 >= 5 && hue1 <= 6) [r1, g1, b1] = [chroma, 0, x];
		else [r1, g1, b1] = [0, 0, 0];

		return new Color((r1 + m) * 255, (g1 + m) * 255, (b1 + m) * 255, alpha);
	}
	/**
	 * Parses a hex rgba string to a color object. It also accepts values without
	 * the alpha component.
	 * @param value the string which should be parsed.
	 */
	public static fromHexRgbA(value: string): Color {
		if (value.length === 7) value = value.padEnd(9, "FF");

		if (!Color.HEX_RGBA_REGEX.test(value))
			throw new Error(`Opaque hex-rgb string "${value}" is invalid`);

		const red = Color.parseHexColorComponent(value.slice(1, 3));
		const green = Color.parseHexColorComponent(value.slice(3, 5));
		const blue = Color.parseHexColorComponent(value.slice(5, 7));
		const alpha = Color.parseHexColorComponent(value.slice(7, 9));

		return new Color(red, green, blue, alpha);
	}
	/**
	 * Checks if the provided HSV color is valid
	 * @param hue value from `0` to `360`
	 * @param saturation value from `0` to `1`
	 * @param value value from `0` to `1`
	 */
	public static isValidHSV(
		hue: number,
		saturation: number,
		value: number,
	): boolean {
		return (
			hue >= 0 &&
			hue <= 360 &&
			saturation >= 0 &&
			saturation <= 1 &&
			value >= 0 &&
			value <= 1
		);
	}
	/**
	 * Parses a color component into it's integer representation.
	 * @param component Component to parse, eg `0F` or `00` or `12`.
	 * @param which Optional parameter to enrich the error message with the
	 * information of what component actually failed to parse.
	 */
	public static parseHexColorComponent(
		component: string,
		which?: string,
	): number {
		try {
			return parseInt(component, 16);
		} catch {
			let msg = `Failed to parse hex color comp "${component}".`;

			if (which !== undefined)
				msg += ` Error occurred whilst parsing "${which}".`;

			throw new Error(msg);
		}
	}
	/**
	 * Generates a random color. Note that the opacity is **never** randomizes,
	 * thus always opaque.
	 * @returns A random color.
	 */
	public static get random(): Color {
		const random = () => Math.floor(Math.random() * 256);

		return new Color(random(), random(), random(), 255);
	}

	private _red;
	private _green;
	private _blue;
	private _alpha;

	/**
	 * Generates a new color object using RGBA
	 * @param red Red component of the value `0` to `255`
	 * @param green Green component of the value `0` to `255`
	 * @param blue Blue component of the value `0` to `255`
	 * @param alpha Alpha component of the value `0` to `255`
	 */
	public constructor(red: number, green: number, blue: number, alpha: number) {
		this._red = red;
		this._green = green;
		this._blue = blue;
		this._alpha = alpha;
	}

	public get clone(): Color {
		return new Color(...this.rgba);
	}
	public get red(): number {
		return this._red;
	}
	public set red(val: number) {
		this._red = val;
	}
	public setRed(val: number): Color {
		this._red = val;
		return this;
	}
	public get green(): number {
		return this._green;
	}
	public set green(val: number) {
		this._green = val;
	}
	public setGreen(val: number): Color {
		this._green = val;
		return this;
	}
	public get blue(): number {
		return this._blue;
	}
	public set blue(val: number) {
		this._blue = val;
	}
	public setBlue(val: number): Color {
		this._blue = val;
		return this;
	}
	public get alpha(): number {
		return this._alpha;
	}
	public set alpha(val: number) {
		this._alpha = val;
	}
	public setAlpha(val: number): Color {
		this._alpha = val;
		return this;
	}
	/**
	 * Returns the hsva (hue, saturation, value, alpha) values of the color.
	 *
	 * @returns HSVA values. Note that the alpha values are normalized (`0` to
	 * `1`), not `0` to `255`.
	 */
	public get hsva(): [
		hue: number,
		saturation: number,
		value: number,
		alpha: number,
	] {
		const r = this._red / 255;
		const g = this._green / 255;
		const b = this._blue / 255;
		const a = this._alpha / 255;
		const compMax = Math.max(r, g, b);
		const compMin = Math.min(r, g, b);
		const delta = compMax - compMin;

		let h: number;
		if (delta === 0) h = 0;
		else {
			switch (compMax) {
				default:
					throw new Error(`Invalid compMax: "${compMax}"`);
				case r:
					h = (g - b) / delta;
					break;
				case g:
					h = (b - r) / delta + 2;
					break;
				case b:
					h = (r - g) / delta + 4;
					break;
			}
		}

		const v = compMax;
		const s = v === 0 ? 0 : delta / v;
		const clampedH = h % 6;
		const normalizedH = clampedH < 0 ? clampedH + 6 : clampedH;

		return [normalizedH * 60, s, v, a];
	}
	/**
	 * @returns the RGBA representation of the color.
	 *
	 * Example: ```const [red, green, blue, alpha] = color.toRGBA()```
	 */
	public get rgba(): [number, number, number, number] {
		return [this._red, this._green, this._blue, this._alpha];
	}
	/**
	 * Returns the hex string representation ignoring alpha. For example `red`
	 * would be `#FF0000`.
	 * @returns Hex string color representation ignoring alpha.
	 */
	public get hexRgb(): string {
		const { _red, _green, _blue, toHexDigit } = this;

		return "#" + [_red, _green, _blue].map(toHexDigit).join("");
	}
	/**
	 * Returns the hex string representation including alpha. For example `red`,
	 * 50% transparent would be `#FF00007F`
	 * @returns Hex string color representation including alpha.
	 */
	public get hexRgba(): string {
		const { _red, _green, _blue, _alpha, toHexDigit } = this;

		return "#" + [_red, _green, _blue, _alpha].map(toHexDigit).join("");
	}
	public get isDark(): boolean {
		// "average"
		const weightedAverage = Math.sqrt(
			0.299 * this._red * this._red +
				0.587 * this._green * this._green +
				0.114 * this._blue * this._blue,
		);

		return weightedAverage < 127.5;
	}

	/**
	 * Takes a number between `0` and `255` and converts it to hexadecimal.
	 * @param n number between `0` and `255`
	 */
	private toHexDigit(n: number): string {
		return Math.trunc(n).toString(16).padStart(2, "0");
	}
}