Files
papercrate/frontend/src/utils/colors.ts
T
2025-11-13 01:07:55 +01:00

307 lines
8.3 KiB
TypeScript

const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
const clampRange = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value));
const hslToHex = (h: number, s: number, l: number): string => {
const normalizedH = ((h % 360) + 360) % 360;
const sat = clamp01(s);
const light = clamp01(l);
const chroma = (1 - Math.abs(2 * light - 1)) * sat;
const hPrime = normalizedH / 60;
const x = chroma * (1 - Math.abs((hPrime % 2) - 1));
let r1 = 0;
let g1 = 0;
let b1 = 0;
if (hPrime >= 0 && hPrime < 1) {
r1 = chroma;
g1 = x;
} else if (hPrime >= 1 && hPrime < 2) {
r1 = x;
g1 = chroma;
} else if (hPrime >= 2 && hPrime < 3) {
g1 = chroma;
b1 = x;
} else if (hPrime >= 3 && hPrime < 4) {
g1 = x;
b1 = chroma;
} else if (hPrime >= 4 && hPrime < 5) {
r1 = x;
b1 = chroma;
} else {
r1 = chroma;
b1 = x;
}
const m = light - chroma / 2;
const r = clamp01(r1 + m);
const g = clamp01(g1 + m);
const b = clamp01(b1 + m);
const sr = Math.round(r * 255);
const sg = Math.round(g * 255);
const sb = Math.round(b * 255);
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
};
interface RgbColor {
r: number;
g: number;
b: number;
}
const rgbToHsl = ({ r, g, b }: RgbColor) => {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const delta = max - min;
let hue = 0;
if (delta !== 0) {
if (max === rn) {
hue = ((gn - bn) / delta) % 6;
} else if (max === gn) {
hue = (bn - rn) / delta + 2;
} else {
hue = (rn - gn) / delta + 4;
}
hue *= 60;
if (hue < 0) hue += 360;
}
const lightness = (max + min) / 2;
let saturation = 0;
if (delta !== 0) {
saturation = delta / (1 - Math.abs(2 * lightness - 1));
}
return { h: hue, s: clamp01(saturation), l: clamp01(lightness) };
};
export const hexToRgb = (input: string | null | undefined): (RgbColor & { hex: string }) | null => {
if (!input) return null;
const match = HEX_COLOR_PATTERN.exec(input.trim());
if (!match) return null;
const value = parseInt(match[1], 16);
return {
r: (value >> 16) & 0xff,
g: (value >> 8) & 0xff,
b: value & 0xff,
hex: `#${match[1].toLowerCase()}`,
};
};
export const relativeLuminance = ({ r, g, b }: RgbColor): number => {
const toLinear = (channel) => {
const normalized = channel / 255;
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
};
const [red, green, blue] = [toLinear(r), toLinear(g), toLinear(b)];
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
};
const contrastRatio = (lumA: number, lumB: number): number => {
const [lighter, darker] = lumA >= lumB ? [lumA, lumB] : [lumB, lumA];
return (lighter + 0.05) / (darker + 0.05);
};
const parseCandidateColor = (candidate: string) => {
const rgb = hexToRgb(candidate);
if (!rgb) return null;
return {
hex: rgb.hex,
luminance: relativeLuminance(rgb),
};
};
const contrastForPair = (backgroundHex: string, textHex: string): number => {
const background = hexToRgb(backgroundHex);
const text = hexToRgb(textHex);
if (!background || !text) {
return 0;
}
return contrastRatio(relativeLuminance(background), relativeLuminance(text));
};
export const getReadableTextColor = (
hex: string,
{ light = '#1f1f1f', dark = '#ffffff', fallback = '#1f1f1f' }: { light?: string; dark?: string; fallback?: string } = {},
): string => {
const background = hexToRgb(hex);
if (!background) return fallback;
const backgroundLuminance = relativeLuminance(background);
const backgroundHsl = rgbToHsl(background);
const hueShift = 180;
const textHue = (backgroundHsl.h + hueShift) % 360;
const targetSaturation = clampRange(backgroundHsl.s * 1.15, 0.4, 0.85);
const minLightness = 0.05;
const maxLightness = 0.95;
const sampleCount = 24;
const buildCandidate = (lightness) => {
const light = clampRange(lightness, minLightness, maxLightness);
const hexValue = hslToHex(textHue, targetSaturation, light);
return parseCandidateColor(hexValue);
};
const candidates = new Map();
for (let index = 0; index < sampleCount; index += 1) {
const t = index / (sampleCount - 1);
const candidateLightness = minLightness + t * (maxLightness - minLightness);
const candidate = buildCandidate(candidateLightness);
if (candidate) {
candidates.set(candidate.hex, candidate);
}
}
[light, dark].forEach((preset) => {
const parsed = parseCandidateColor(preset);
if (parsed) {
candidates.set(parsed.hex, parsed);
}
});
if (candidates.size === 0) {
return fallback;
}
let best = null;
let bestRatio = -Infinity;
candidates.forEach((candidate) => {
const ratio = contrastRatio(backgroundLuminance, candidate.luminance);
if (ratio > bestRatio) {
bestRatio = ratio;
best = candidate;
}
});
const MIN_CONTRAST = 4.5;
if (bestRatio < MIN_CONTRAST) {
const extremeLight = buildCandidate(maxLightness);
const extremeDark = buildCandidate(minLightness);
const extremes = [extremeLight, extremeDark].filter(Boolean);
extremes.forEach((candidate) => {
const ratio = contrastRatio(backgroundLuminance, candidate.luminance);
if (ratio > bestRatio) {
bestRatio = ratio;
best = candidate;
}
});
}
return best?.hex || fallback;
};
export const getTagColorStyle = (hex) => {
const rgb = hexToRgb(hex);
if (!rgb) return null;
const baseHex = rgb.hex;
const baseHsl = rgbToHsl(rgb);
const baseText = getReadableTextColor(baseHex);
const baseRatio = contrastForPair(baseHex, baseText);
const TARGET_RATIO = 8;
const MIN_LIGHTNESS = 0.12;
const MAX_LIGHTNESS = 0.88;
const adjustments = [-0.18, -0.12, -0.08, -0.04, 0.04, 0.08, 0.12, 0.18];
const seen = new Map();
const registerCandidate = (lightness) => {
const clamped = clampRange(lightness, MIN_LIGHTNESS, MAX_LIGHTNESS);
const hexValue = hslToHex(baseHsl.h, baseHsl.s, clamped);
if (!seen.has(hexValue)) {
seen.set(hexValue, clamped);
}
};
registerCandidate(baseHsl.l);
adjustments.forEach((delta) => registerCandidate(baseHsl.l + delta));
let bestBackground = baseHex;
let bestText = baseText;
let bestRatio = baseRatio;
if (bestRatio >= TARGET_RATIO) {
return {
backgroundColor: bestBackground,
borderColor: bestBackground,
color: bestText,
};
}
let compliantBackground = null;
let compliantText = null;
let compliantDelta = Infinity;
let compliantRatio = -Infinity;
seen.forEach((lightness, candidateHex) => {
const textHex = getReadableTextColor(candidateHex);
const ratio = contrastForPair(candidateHex, textHex);
const delta = Math.abs(lightness - baseHsl.l);
if (ratio > bestRatio) {
bestBackground = candidateHex;
bestText = textHex;
bestRatio = ratio;
}
if (ratio >= TARGET_RATIO) {
const deltaEpsilon = 0.0025;
const ratioEpsilon = 0.01;
const isCloser = delta + deltaEpsilon < compliantDelta;
const isSimilarDistance = Math.abs(delta - compliantDelta) <= deltaEpsilon;
const improvesRatio = ratio > compliantRatio + ratioEpsilon;
if (
compliantBackground === null
|| isCloser
|| (isSimilarDistance && improvesRatio)
) {
compliantBackground = candidateHex;
compliantText = textHex;
compliantDelta = delta;
compliantRatio = ratio;
}
}
});
if (compliantBackground) {
return {
backgroundColor: compliantBackground,
borderColor: compliantBackground,
color: compliantText,
};
}
return {
backgroundColor: bestBackground,
borderColor: bestBackground,
color: bestText,
};
};
export const generateRandomTagColor = () => {
const bucketCount = 8;
const bucketWidth = 360 / bucketCount;
const bucket = Math.floor(Math.random() * bucketCount);
const baseHue = bucket * bucketWidth;
const hueJitter = bucketWidth * 0.35;
const hue = baseHue + (Math.random() * 2 - 1) * hueJitter;
const saturation = 0.45 + Math.random() * 0.2; // 0.45 - 0.65
const lightness = 0.55 + Math.random() * 0.1; // 0.55 - 0.65
return hslToHex(hue, saturation, lightness);
};
export { HEX_COLOR_PATTERN };