export type HsvColor = { h: number; s: number; v: number; }; function normalizeHue(value: number): number { if (!Number.isFinite(value)) return 0; return ((value % 360) + 360) % 360; } export function clampNumber(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; return Math.max(min, Math.min(max, value)); } export function normalizeHexColor(hex: string | null | undefined): string | null { if (typeof hex !== "string") return null; const raw = hex.trim(); if (!raw) return null; const hashless = raw.startsWith("#") ? raw.slice(1) : raw; if (![3, 6].includes(hashless.length)) return null; if (!/^[\da-f]+$/i.test(hashless)) return null; if (hashless.length === 3) { const expanded = hashless .split("") .map((char) => `${char}${char}`) .join(""); return `#${expanded.toLowerCase()}`; } return `#${hashless.toLowerCase()}`; } export function hexToHsv(hex: string): HsvColor { const normalized = normalizeHexColor(hex); if (!normalized) { throw new Error(`Invalid hex color: ${hex}`); } const value = normalized.slice(1); const r = Number.parseInt(value.slice(0, 2), 16) / 255; const g = Number.parseInt(value.slice(2, 4), 16) / 255; const b = Number.parseInt(value.slice(4, 6), 16) / 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const delta = max - min; let hue = 0; if (delta !== 0) { if (max === r) { hue = ((g - b) / delta) % 6; } else if (max === g) { hue = (b - r) / delta + 2; } else { hue = (r - g) / delta + 4; } } hue = normalizeHue(hue * 60); return { h: hue, s: max === 0 ? 0 : delta / max, v: max, }; } export function hsvToHex(color: HsvColor): string { const hue = normalizeHue(color.h); const saturation = clampNumber(color.s, 0, 1); const value = clampNumber(color.v, 0, 1); const chroma = value * saturation; const hueSegment = hue / 60; const x = chroma * (1 - Math.abs((hueSegment % 2) - 1)); const match = value - chroma; let red = 0; let green = 0; let blue = 0; if (hueSegment >= 0 && hueSegment < 1) { red = chroma; green = x; } else if (hueSegment >= 1 && hueSegment < 2) { red = x; green = chroma; } else if (hueSegment >= 2 && hueSegment < 3) { green = chroma; blue = x; } else if (hueSegment >= 3 && hueSegment < 4) { green = x; blue = chroma; } else if (hueSegment >= 4 && hueSegment < 5) { red = x; blue = chroma; } else { red = chroma; blue = x; } const toHex = (channel: number) => Math.round((channel + match) * 255) .toString(16) .padStart(2, "0"); return `#${toHex(red)}${toHex(green)}${toHex(blue)}`; }