| 1 | export type HsvColor = { |
| 2 | h: number; |
| 3 | s: number; |
| 4 | v: number; |
| 5 | }; |
| 6 | |
| 7 | /** Convert a normalized six-digit sRGB hex color to HSV. */ |
| 8 | export function hexToHsv(hex: string): HsvColor { |
| 9 | const numeric = Number.parseInt(hex.slice(1), 16); |
| 10 | const red = ((numeric >> 16) & 255) / 255; |
| 11 | const green = ((numeric >> 8) & 255) / 255; |
| 12 | const blue = (numeric & 255) / 255; |
| 13 | const max = Math.max(red, green, blue); |
| 14 | const min = Math.min(red, green, blue); |
| 15 | const delta = max - min; |
| 16 | |
| 17 | let hue = 0; |
| 18 | if (delta !== 0) { |
| 19 | if (max === red) { |
| 20 | hue = ((green - blue) / delta) % 6; |
| 21 | } else if (max === green) { |
| 22 | hue = (blue - red) / delta + 2; |
| 23 | } else { |
| 24 | hue = (red - green) / delta + 4; |
| 25 | } |
| 26 | hue *= 60; |
| 27 | if (hue < 0) hue += 360; |
| 28 | } |
| 29 | |
| 30 | return { |
| 31 | h: hue, |
| 32 | s: max === 0 ? 0 : delta / max, |
| 33 | v: max, |
| 34 | }; |
| 35 | } |
| 36 | |
| 37 | export function hsvToHex(hue: number, saturation: number, value: number) { |
| 38 | const normalizedHue = ((hue % 360) + 360) % 360; |
| 39 | const chroma = value * saturation; |
| 40 | const x = chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1)); |
| 41 | const match = value - chroma; |
| 42 | const [red, green, blue] = |
| 43 | normalizedHue < 60 |
| 44 | ? [chroma, x, 0] |
| 45 | : normalizedHue < 120 |
| 46 | ? [x, chroma, 0] |
| 47 | : normalizedHue < 180 |
| 48 | ? [0, chroma, x] |
| 49 | : normalizedHue < 240 |
| 50 | ? [0, x, chroma] |
| 51 | : normalizedHue < 300 |
| 52 | ? [x, 0, chroma] |
| 53 | : [chroma, 0, x]; |
| 54 | |
| 55 | return `#${[red, green, blue] |
| 56 | .map((channel) => |
| 57 | Math.round((channel + match) * 255) |
| 58 | .toString(16) |
| 59 | .padStart(2, "0"), |
| 60 | ) |
| 61 | .join("")}`; |
| 62 | } |