frontend: tags
This commit is contained in:
@@ -2,6 +2,8 @@ const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
|
||||
|
||||
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
||||
|
||||
const clampRange = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const gammaEncode = (channel) =>
|
||||
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
|
||||
|
||||
@@ -77,6 +79,37 @@ const hslToHex = (h, s, l) => {
|
||||
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
|
||||
};
|
||||
|
||||
const rgbToHsl = ({ r, g, b }) => {
|
||||
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) => {
|
||||
if (!input) return null;
|
||||
const match = HEX_COLOR_PATTERN.exec(input.trim());
|
||||
@@ -102,20 +135,184 @@ export const relativeLuminance = ({ r, g, b }) => {
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
};
|
||||
|
||||
export const getReadableTextColor = (hex, { light = '#1f1f1f', dark = '#ffffff' } = {}) => {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return dark;
|
||||
const luminance = relativeLuminance(rgb);
|
||||
return luminance > 0.6 ? light : dark;
|
||||
const contrastRatio = (lumA, lumB) => {
|
||||
const [lighter, darker] = lumA >= lumB ? [lumA, lumB] : [lumB, lumA];
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
};
|
||||
|
||||
const parseCandidateColor = (candidate) => {
|
||||
const rgb = hexToRgb(candidate);
|
||||
if (!rgb) return null;
|
||||
return {
|
||||
hex: rgb.hex,
|
||||
luminance: relativeLuminance(rgb),
|
||||
};
|
||||
};
|
||||
|
||||
const contrastForPair = (backgroundHex, textHex) => {
|
||||
const background = hexToRgb(backgroundHex);
|
||||
const text = hexToRgb(textHex);
|
||||
if (!background || !text) {
|
||||
return 0;
|
||||
}
|
||||
return contrastRatio(relativeLuminance(background), relativeLuminance(text));
|
||||
};
|
||||
|
||||
export const getReadableTextColor = (
|
||||
hex,
|
||||
{ light = '#1f1f1f', dark = '#ffffff', fallback = '#1f1f1f' } = {},
|
||||
) => {
|
||||
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: rgb.hex,
|
||||
borderColor: rgb.hex,
|
||||
color: getReadableTextColor(rgb.hex),
|
||||
backgroundColor: bestBackground,
|
||||
borderColor: bestBackground,
|
||||
color: bestText,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user