feat: 使用OKLCH颜色空间重构色阶生成逻辑

- 将HSL颜色模型替换为OKLCH,实现感知均匀的颜色过渡
- 基于提取的主色调浅色版本生成11个色阶,浅色居中显示
- 左侧5个更浅色阶,右侧5个更深色阶,亮度间隔±0.08

OKLCH优势:
- 感知上均匀的亮度过渡,避免视觉跳跃
- 保持色相(H)和色度(C)恒定,确保同色系
- 深色和浅色区域过渡更自然平滑
- 符合人眼对颜色差异的实际感知

色阶生成流程:
1. ColorThief提取图片主色调(RGB)
2. 转换为OKLCH,生成高亮度(L=0.92)浅色版本
3. 基于浅色背景生成11个感知均匀的色阶
4. 在页面标题栏中间显示,支持快速切换

技术实现:
- 实现完整的Hex ↔ OKLCH双向转换
- 转换路径:Hex → sRGB → 线性RGB → OKLab → OKLCH
- 更新rgbToLightPastel函数使用OKLCH生成浅色
This commit is contained in:
jingrow 2026-01-21 15:58:04 +08:00
parent ae7ed98808
commit dc36b4c706

View File

@ -267,54 +267,88 @@ const commonColors = ref([
'#A0A0A0' // Dark Gray
])
// HSL color utilities - efficient and modern approach
const hexToHsl = (hex: string): { h: number; s: number; l: number } => {
// OKLCH color utilities - perceptually uniform color space
const hexToOklch = (hex: string): { l: number; c: number; h: number } => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
if (!result) return { h: 0, s: 0, l: 100 }
if (!result) return { l: 1, c: 0, h: 0 }
// Convert hex to linear RGB
let r = parseInt(result[1], 16) / 255
let g = parseInt(result[2], 16) / 255
let b = parseInt(result[3], 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0, s = 0
const l = (max + min) / 2
// Apply sRGB to linear RGB conversion
r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92
g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92
b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break
case g: h = ((b - r) / d + 2) / 6; break
case b: h = ((r - g) / d + 4) / 6; break
}
}
// Convert linear RGB to OKLab
const l_ = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
const m_ = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
const s_ = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
return { h: h * 360, s: s * 100, l: l * 100 }
const l = Math.cbrt(l_)
const m = Math.cbrt(m_)
const s = Math.cbrt(s_)
const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
const bVal = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
// Convert OKLab to OKLCH
const C = Math.sqrt(a * a + bVal * bVal)
const H = Math.atan2(bVal, a) * 180 / Math.PI
return { l: L, c: C, h: H >= 0 ? H : H + 360 }
}
const hslToHex = (h: number, s: number, l: number): string => {
s /= 100
l /= 100
const a = s * Math.min(l, 1 - l)
const f = (n: number) => {
const k = (n + h / 30) % 12
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
return Math.round(255 * color).toString(16).padStart(2, '0')
}
return `#${f(0)}${f(8)}${f(4)}`.toUpperCase()
const oklchToHex = (l: number, c: number, h: number): string => {
// Convert OKLCH to OKLab
const hRad = h * Math.PI / 180
const a = c * Math.cos(hRad)
const bVal = c * Math.sin(hRad)
// Convert OKLab to linear RGB
const l_ = l + 0.3963377774 * a + 0.2158037573 * bVal
const m_ = l - 0.1055613458 * a - 0.0638541728 * bVal
const s_ = l - 0.0894841775 * a - 1.2914855480 * bVal
const lCube = l_ * l_ * l_
const mCube = m_ * m_ * m_
const sCube = s_ * s_ * s_
let r = +4.0767416621 * lCube - 3.3077115913 * mCube + 0.2309699292 * sCube
let g = -1.2684380046 * lCube + 2.6097574011 * mCube - 0.3413193965 * sCube
let bRgb = -0.0041960863 * lCube - 0.7034186147 * mCube + 1.7076147010 * sCube
// Apply linear RGB to sRGB conversion
r = r > 0.0031308 ? 1.055 * Math.pow(r, 1 / 2.4) - 0.055 : 12.92 * r
g = g > 0.0031308 ? 1.055 * Math.pow(g, 1 / 2.4) - 0.055 : 12.92 * g
bRgb = bRgb > 0.0031308 ? 1.055 * Math.pow(bRgb, 1 / 2.4) - 0.055 : 12.92 * bRgb
// Clamp values to [0, 1]
r = Math.max(0, Math.min(1, r))
g = Math.max(0, Math.min(1, g))
bRgb = Math.max(0, Math.min(1, bRgb))
// Convert to hex
const rHex = Math.round(r * 255).toString(16).padStart(2, '0')
const gHex = Math.round(g * 255).toString(16).padStart(2, '0')
const bHex = Math.round(bRgb * 255).toString(16).padStart(2, '0')
return `#${rHex}${gHex}${bHex}`.toUpperCase()
}
// Generate 11 color shades with current color in the middle (6th position)
// Generate 11 color shades with current color in the middle (6th position) using OKLCH
const generateColorShades = (baseColor: string): string[] => {
const { h, s, l } = hexToHsl(baseColor)
const { l, c, h } = hexToOklch(baseColor)
const shades: string[] = []
// Generate 5 lighter shades before current color
// Use perceptually uniform lightness steps (±0.08 intervals)
for (let i = 5; i > 0; i--) {
const lighterL = Math.min(100, l + (i * 8))
shades.push(hslToHex(h, s, lighterL))
const lighterL = Math.min(1, l + (i * 0.08))
shades.push(oklchToHex(lighterL, c, h))
}
// Add current color in the middle (6th position)
@ -322,8 +356,8 @@ const generateColorShades = (baseColor: string): string[] => {
// Generate 5 darker shades after current color
for (let i = 1; i <= 5; i++) {
const darkerL = Math.max(0, l - (i * 8))
shades.push(hslToHex(h, s, darkerL))
const darkerL = Math.max(0, l - (i * 0.08))
shades.push(oklchToHex(darkerL, c, h))
}
return shades
@ -974,35 +1008,22 @@ const resetUpload = () => {
}
}
// Convert RGB to light pastel color based on the same hue
// Convert RGB to light pastel color using OKLCH
const rgbToLightPastel = (r: number, g: number, b: number): string => {
// Convert RGB to HSL
const rNorm = r / 255
const gNorm = g / 255
const bNorm = b / 255
// Convert RGB to hex first
const rHex = Math.round(r).toString(16).padStart(2, '0')
const gHex = Math.round(g).toString(16).padStart(2, '0')
const bHex = Math.round(b).toString(16).padStart(2, '0')
const hexColor = `#${rHex}${gHex}${bHex}`
const max = Math.max(rNorm, gNorm, bNorm)
const min = Math.min(rNorm, gNorm, bNorm)
let h = 0
let s = 0
const l = (max + min) / 2
// Convert to OKLCH
const { l, c, h } = hexToOklch(hexColor)
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case rNorm: h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6; break
case gNorm: h = ((bNorm - rNorm) / d + 2) / 6; break
case bNorm: h = ((rNorm - gNorm) / d + 4) / 6; break
}
}
// Create light pastel version: high lightness (0.92), reduced chroma
const pastelL = 0.92
const pastelC = Math.min(c, 0.08) // Reduce chroma for pastel effect
// Convert to light pastel: keep hue, reduce saturation to 25-35%, increase lightness to 90-95%
const pastelS = Math.min(s * 100, 40) // Cap saturation at 40% for pastel effect
const pastelL = 92 // High lightness for light background
// Convert HSL back to RGB
return hslToHex(h * 360, pastelS, pastelL)
return oklchToHex(pastelL, pastelC, h)
}
// Extract dominant color from image using color-thief