@@ -200,6 +200,132 @@ function deltaE(l1: any, l2: any) {
200200 return Math . sqrt ( dL * dL + da * da + db * db ) ;
201201}
202202
203+ function rgbToHsl ( [ r , g , b ] : [ number , number , number ] ) : [ number , number , number ] {
204+ const r1 = r / 255 ;
205+ const g1 = g / 255 ;
206+ const b1 = b / 255 ;
207+ const max = Math . max ( r1 , g1 , b1 ) ;
208+ const min = Math . min ( r1 , g1 , b1 ) ;
209+ const l = ( max + min ) / 2 ;
210+ const d = max - min ;
211+
212+ if ( d === 0 ) {
213+ return [ 0 , 0 , l ] ;
214+ }
215+
216+ const s = d / ( 1 - Math . abs ( 2 * l - 1 ) ) ;
217+ let h = 0 ;
218+
219+ switch ( max ) {
220+ case r1 :
221+ h = ( ( g1 - b1 ) / d + ( g1 < b1 ? 6 : 0 ) ) / 6 ;
222+ break ;
223+ case g1 :
224+ h = ( ( b1 - r1 ) / d + 2 ) / 6 ;
225+ break ;
226+ default :
227+ h = ( ( r1 - g1 ) / d + 4 ) / 6 ;
228+ break ;
229+ }
230+
231+ return [ h , s , l ] ;
232+ }
233+
234+ function circularHueDistance ( h1 : number , h2 : number ) : number {
235+ const diff = Math . abs ( h1 - h2 ) ;
236+ return Math . min ( diff , 1 - diff ) ;
237+ }
238+
239+ function clamp ( value : number , min : number , max : number ) : number {
240+ return Math . max ( min , Math . min ( max , value ) ) ;
241+ }
242+
243+ type PaletteCandidate = {
244+ rgb : [ number , number , number ] ;
245+ lab : { L : number ; a : number ; b : number } ;
246+ hsl : [ number , number , number ] ;
247+ hsv : [ number , number , number ] ;
248+ prominence : number ;
249+ chroma : number ;
250+ } ;
251+
252+ function makePaletteCandidate ( rgb : [ number , number , number ] , prominence : number ) : PaletteCandidate {
253+ const lab = rgbToLab ( rgb ) ;
254+ return {
255+ rgb,
256+ lab,
257+ hsl : rgbToHsl ( rgb ) ,
258+ hsv : rgbToHsv ( rgb ) ,
259+ prominence,
260+ chroma : Math . sqrt ( lab . a * lab . a + lab . b * lab . b ) ,
261+ } ;
262+ }
263+
264+ function dedupePalette ( colors : [ number , number , number ] [ ] ) : [ number , number , number ] [ ] {
265+ const deduped : [ number , number , number ] [ ] = [ ] ;
266+ for ( const color of colors ) {
267+ const lab = rgbToLab ( color ) ;
268+ const isDuplicate = deduped . some ( ( existing ) => deltaE ( lab , rgbToLab ( existing ) ) < 8 ) ;
269+ if ( ! isDuplicate ) {
270+ deduped . push ( color ) ;
271+ }
272+ }
273+ return deduped ;
274+ }
275+
276+ function pickAnchorColor ( colors : [ number , number , number ] [ ] ) : PaletteCandidate {
277+ const prepared = dedupePalette ( colors ) . map ( ( rgb , index , arr ) =>
278+ makePaletteCandidate ( rgb , arr . length <= 1 ? 1 : 1 - index / arr . length )
279+ ) ;
280+
281+ const vivid = prepared . filter ( ( candidate ) => {
282+ const [ , s , l ] = candidate . hsl ;
283+ return candidate . chroma >= 18 && s >= 0.12 && l >= 0.06 && l <= 0.82 ;
284+ } ) ;
285+
286+ const usable = vivid . length ? vivid : prepared ;
287+
288+ const weightedHueX = usable . reduce (
289+ ( sum , candidate ) => sum + Math . cos ( candidate . hsl [ 0 ] * Math . PI * 2 ) * candidate . prominence ,
290+ 0
291+ ) ;
292+ const weightedHueY = usable . reduce (
293+ ( sum , candidate ) => sum + Math . sin ( candidate . hsl [ 0 ] * Math . PI * 2 ) * candidate . prominence ,
294+ 0
295+ ) ;
296+ const dominantHue =
297+ weightedHueX === 0 && weightedHueY === 0
298+ ? usable [ 0 ] . hsl [ 0 ]
299+ : ( Math . atan2 ( weightedHueY , weightedHueX ) / ( Math . PI * 2 ) + 1 ) % 1 ;
300+
301+ const scored = usable . map ( ( candidate ) => {
302+ const [ , saturation , lightness ] = candidate . hsl ;
303+ const [ , , value ] = candidate . hsv ;
304+ const normalizedDepth = 1 - clamp ( lightness , 0 , 1 ) ;
305+ const darknessPreference = 1 - Math . abs ( lightness - 0.28 ) / 0.28 ;
306+ const hueCloseness = 1 - circularHueDistance ( candidate . hsl [ 0 ] , dominantHue ) / 0.5 ;
307+ const neutralPenalty = candidate . chroma < 24 ? ( 24 - candidate . chroma ) / 24 : 0 ;
308+ const washedPenalty = lightness > 0.72 ? ( lightness - 0.72 ) / 0.28 : 0 ;
309+ const crushedPenalty = value < 0.16 ? ( 0.16 - value ) / 0.16 : 0 ;
310+
311+ const score =
312+ candidate . prominence * 0.34 +
313+ saturation * 0.24 +
314+ clamp ( candidate . chroma / 90 , 0 , 1 ) * 0.2 +
315+ clamp ( darknessPreference , 0 , 1 ) * 0.14 +
316+ clamp ( normalizedDepth , 0 , 1 ) * 0.08 +
317+ clamp ( hueCloseness , 0 , 1 ) * 0.1 -
318+ neutralPenalty * 0.18 -
319+ washedPenalty * 0.12 -
320+ crushedPenalty * 0.08 ;
321+
322+ return { candidate, score } ;
323+ } ) ;
324+
325+ scored . sort ( ( a , b ) => b . score - a . score ) ;
326+ return scored [ 0 ] . candidate ;
327+ }
328+
203329export async function getAccentColorFromUrl (
204330 imageUrl : string ,
205331 targetLightness = 0 ,
@@ -276,28 +402,42 @@ export async function getAccentColorFromUrl(
276402 }
277403 }
278404
279- const paletteLab = paletteRgb . map ( rgbToLab ) ;
405+ const anchor = pickAnchorColor ( paletteRgb ) ;
280406 const candidates = COLORS . map ( ( hex ) => {
281407 const rgb = hexToRgb ( hex ) ;
282408 const lab = rgbToLab ( rgb ) ;
283- return { hex, lab, Lnorm : lab . L / 100 } ;
409+ const hsl = rgbToHsl ( rgb ) ;
410+ const chroma = Math . sqrt ( lab . a * lab . a + lab . b * lab . b ) ;
411+ return { hex, lab, hsl, chroma, Lnorm : lab . L / 100 } ;
284412 } ) ;
285413
286- const colorWeight = opts ?. colorWeight ?? 0.8 ;
287- const lightnessWeight = opts ?. lightnessWeight ?? 0.2 ;
414+ const anchorLightness = clamp ( anchor . hsl [ 2 ] , 0 , 1 ) ;
415+ const effectiveTargetLightness =
416+ targetLightness > 0 ? anchorLightness * 0.7 + targetLightness * 0.3 : anchorLightness ;
417+ const colorWeight = opts ?. colorWeight ?? 0.52 ;
418+ const lightnessWeight = opts ?. lightnessWeight ?? 0.28 ;
288419
289420 const scored = candidates . map ( ( cand ) => {
290- let minDE = Infinity ;
291- for ( const p of paletteLab ) {
292- const de = deltaE ( cand . lab , p ) ;
293- if ( de < minDE ) {
294- minDE = de ;
295- }
296- }
297- const colorDistNorm = Math . min ( 1 , minDE / 100 ) ;
298- const lightDiff = Math . abs ( cand . Lnorm - targetLightness ) ;
299- const score = colorWeight * colorDistNorm + lightnessWeight * lightDiff ;
300- return { hex : cand . hex , score, minDE, lightDiff } ;
421+ const de = deltaE ( cand . lab , anchor . lab ) ;
422+ const colorDistNorm = Math . min ( 1 , de / 100 ) ;
423+ const hueDiff = circularHueDistance ( cand . hsl [ 0 ] , anchor . hsl [ 0 ] ) ;
424+ const huePenalty = hueDiff / 0.5 ;
425+ const lightDiff = Math . abs ( cand . hsl [ 2 ] - effectiveTargetLightness ) ;
426+ const chromaDiff = Math . abs ( cand . chroma - anchor . chroma ) / 100 ;
427+ const tooBrightPenalty =
428+ cand . hsl [ 2 ] > anchor . hsl [ 2 ] + 0.12 ? cand . hsl [ 2 ] - ( anchor . hsl [ 2 ] + 0.12 ) : 0 ;
429+ const tooMutedPenalty =
430+ cand . chroma + 10 < anchor . chroma ? ( anchor . chroma - ( cand . chroma + 10 ) ) / 100 : 0 ;
431+
432+ const score =
433+ colorWeight * colorDistNorm +
434+ 0.32 * huePenalty +
435+ lightnessWeight * lightDiff +
436+ 0.18 * chromaDiff +
437+ 0.35 * tooBrightPenalty +
438+ 0.22 * tooMutedPenalty ;
439+
440+ return { hex : cand . hex , score } ;
301441 } ) ;
302442
303443 scored . sort ( ( a , b ) => a . score - b . score ) ;
@@ -393,8 +533,9 @@ export function generateTextColor(hexCover: string, hShiftDeg = 12, coeff = 0.81
393533 const rgbCover = hexToRgb ( hexCover ) ;
394534 const [ h , s , v ] = rgbToHsv ( rgbCover ) ;
395535 const newH = ( h + hShiftDeg / 360 ) % 1 ;
396- const newS = clamp01 ( v * coeff ) ;
397- const newV = 1 ;
536+ const liftedS = Math . max ( s * 0.7 , 0.28 ) ;
537+ const newS = clamp01 ( liftedS * coeff ) ;
538+ const newV = v < 0.38 ? 1 : 0.96 ;
398539 const rgbText = hsvToRgb ( [ newH , newS , newV ] ) ;
399540 return rgbToHex ( rgbText ) ;
400541}
0 commit comments