-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: find closest color to luminance and calculate luminance (#1)
* feat: find closest color to luminance and calculate luminance * chore: disable pnpm auto install peers * chore: pnpm format * chore: disable `publish` script being reported by knip
- Loading branch information
1 parent
d37f32e
commit d1ded48
Showing
6 changed files
with
369 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,4 +3,4 @@ updates: | |
- package-ecosystem: github-actions | ||
directory: / | ||
schedule: | ||
interval: 'weekly' | ||
interval: 'weekly' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
engine-strict=true | ||
auto-install-peers=false |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import type { KnipConfig } from 'knip' | ||
|
||
const config: KnipConfig = { | ||
ignoreBinaries: ['publish'], | ||
ignoreExportsUsedInFile: true | ||
} | ||
|
||
export default config |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,207 @@ | ||
export interface RGB { | ||
r: number | ||
g: number | ||
b: number | ||
} | ||
|
||
export interface HSL { | ||
h: number | ||
s: number | ||
l: number | ||
} | ||
|
||
interface HUE { | ||
p: number | ||
q: number | ||
t: number | ||
} | ||
|
||
export function hexToRgb(hex: string): RGB { | ||
const bigint = parseInt(hex.substring(1), 16) | ||
const r = (bigint >> 16) & 255 | ||
const g = (bigint >> 8) & 255 | ||
const b = bigint & 255 | ||
|
||
return { r, g, b } | ||
} | ||
|
||
export function rgbToHex({ r, g, b }: RGB) { | ||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b | ||
.toString(16) | ||
.padStart(2, '0')}` | ||
} | ||
|
||
// Converts an HSL color value to RGB. | ||
// h, s, and l are contained in the set [0, 1] and | ||
// returns r, g, and b in the set [0, 255]. | ||
export function hslToRgb({ h, s, l }: HSL): RGB { | ||
let r, g, b | ||
|
||
if (s == 0) { | ||
r = g = b = l // achromatic | ||
} else { | ||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s | ||
const p = 2 * l - q | ||
r = hue2rgb({ p, q, t: h + 1 / 3 }) | ||
g = hue2rgb({ p, q, t: h }) | ||
b = hue2rgb({ p, q, t: h - 1 / 3 }) | ||
} | ||
|
||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) } | ||
} | ||
|
||
// Converts an RGB color value to HSL. | ||
// r, g, and b are contained in the set [0, 255] and | ||
// returns h, s, and l in the set [0, 1]. | ||
export function rgbToHsl({ r, g, b }: RGB): HSL { | ||
r /= 255 | ||
g /= 255 | ||
b /= 255 | ||
const max = Math.max(r, g, b) | ||
const min = Math.min(r, g, b) | ||
const l = (max + min) / 2 | ||
let h, | ||
s = l | ||
|
||
if (max == min) { | ||
h = s = 0 // achromatic | ||
} else { | ||
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) | ||
break | ||
case g: | ||
h = (b - r) / d + 2 | ||
break | ||
default: // case b: | ||
h = (r - g) / d + 4 | ||
break | ||
} | ||
h /= 6 | ||
} | ||
|
||
return { h, s, l } | ||
} | ||
|
||
function hue2rgb({ p, q, t }: HUE) { | ||
if (t < 0) t += 1 | ||
if (t > 1) t -= 1 | ||
if (t < 1 / 6) return p + (q - p) * 6 * t | ||
if (t < 1 / 2) return q | ||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 | ||
return p | ||
} | ||
|
||
function gammaCorrect(color: number) { | ||
return color <= 0.03928 ? color / 12.92 : ((color + 0.055) / 1.055) ** 2.4 | ||
} | ||
|
||
export function calculateLuminance(rgbOrHex: RGB | string) { | ||
if (typeof rgbOrHex === 'string') rgbOrHex = hexToRgb(rgbOrHex) | ||
let { r, g, b } = rgbOrHex | ||
|
||
// Normalized to values between 0 and 1 | ||
r /= 255 | ||
g /= 255 | ||
b /= 255 | ||
|
||
// apply gamma correction | ||
r = gammaCorrect(r) | ||
g = gammaCorrect(g) | ||
b = gammaCorrect(b) | ||
|
||
// calculate luminance | ||
return 0.2126 * r + 0.7152 * g + 0.0722 * b | ||
} | ||
|
||
export function getClosestColor( | ||
hex: string, | ||
targetLuminance: number, | ||
targetPrecision = 0.01, | ||
maxSteps = 100 | ||
) { | ||
return getClosestColorBisection(hex, targetLuminance, targetPrecision, maxSteps) | ||
} | ||
|
||
export function getClosestColorNewton( | ||
hex: string, | ||
targetLuminance: number, | ||
targetPrecision = 0.01, | ||
maxSteps = 100 | ||
) { | ||
// // Just a shortcut for all colors these are 0 and 100 luminance | ||
if (targetLuminance <= 0) return '#000000' | ||
if (targetLuminance >= 1) return '#ffffff' | ||
|
||
const hsl = rgbToHsl(hexToRgb(hex)) | ||
const damping = 15 // Damping factor to avoid instability | ||
|
||
for (let steps = 0; steps < maxSteps; ++steps) { | ||
// Limit iterations to prevent infinite loop | ||
const rgb = hslToRgb(hsl) | ||
const currentLuminance = calculateLuminance(rgb) | ||
const error = currentLuminance - targetLuminance | ||
|
||
if (Math.abs(error) / targetLuminance <= targetPrecision) { | ||
return rgbToHex(rgb) // Found suitable luminance | ||
} | ||
|
||
// If the color is white and the target luminance is less than the current, start decreasing the brightness | ||
if (rgb.r === 255 && rgb.g === 255 && rgb.b === 255 && targetLuminance < currentLuminance) { | ||
hsl.l -= 0.01 // decrement brightness | ||
continue | ||
} | ||
|
||
// Newton-Raphson update | ||
const tmpRgb = hslToRgb({ ...hsl, l: hsl.l + 0.01 }) | ||
const derivative = (calculateLuminance(tmpRgb) - currentLuminance) / 0.01 | ||
|
||
if (error) hsl.l -= error / derivative / damping | ||
|
||
// Clamp brightness to [0,1] | ||
if (hsl.l < 0) hsl.l = 0 | ||
if (hsl.l > 1) hsl.l = 1 | ||
} | ||
|
||
return rgbToHex(hslToRgb(hsl)) // Suitable luminance wasn't found in time, return | ||
} | ||
|
||
export function getClosestColorBisection( | ||
hex: string, | ||
targetLuminance: number, | ||
targetPrecision = 0.01, | ||
maxSteps = 100 | ||
) { | ||
// Just a shortcut for all colors these are 0 and 100 luminance | ||
if (targetLuminance <= 0) return '#000000' | ||
if (targetLuminance >= 1) return '#ffffff' | ||
|
||
const hsl = rgbToHsl(hexToRgb(hex)) | ||
|
||
let low = 0, | ||
high = 1, | ||
steps = 0 | ||
let lowLum = 0, | ||
highLum = 1 | ||
for ( | ||
steps = 0; | ||
(highLum - lowLum) / targetLuminance > targetPrecision && steps < maxSteps; | ||
steps += 1 | ||
) { | ||
// Stop when interval size is within target precision | ||
hsl.l = (low + high) / 2 // Take the midpoint as the new guess | ||
const currentLuminance = calculateLuminance(hslToRgb(hsl)) | ||
|
||
if (currentLuminance > targetLuminance) { | ||
high = hsl.l | ||
highLum = currentLuminance | ||
} else { | ||
low = hsl.l | ||
lowLum = currentLuminance | ||
} | ||
} | ||
|
||
return rgbToHex(hslToRgb(hsl)) // Return the hex color for the best luminance match | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,150 @@ | ||
import { describe, it, expect } from 'vitest' | ||
import { | ||
hexToRgb, | ||
rgbToHex, | ||
hslToRgb, | ||
calculateLuminance, | ||
getClosestColor, | ||
getClosestColorNewton, | ||
type RGB, | ||
type HSL, | ||
rgbToHsl | ||
} from '../src/index' | ||
|
||
function formatRgb(rgb: RGB) { | ||
return `{r=${rgb.r.toString().padStart(3, ' ')}, g=${rgb.g.toString().padStart(3, ' ')}, b=${rgb.b | ||
.toString() | ||
.padStart(3, ' ')}}` | ||
} | ||
function formatHsl(hsl: HSL) { | ||
return `{h=${hsl.h.toString().padStart(3, ' ')}, s=${hsl.s.toString().padStart(3, ' ')}, l=${hsl.l | ||
.toString() | ||
.padStart(3, ' ')}}` | ||
} | ||
|
||
describe('hexToRgb', () => { | ||
const testValues = [ | ||
{ hex: '#000000', rgb: { r: 0, g: 0, b: 0 } }, | ||
{ hex: '#ffffff', rgb: { r: 255, g: 255, b: 255 } }, | ||
{ hex: '#ff0000', rgb: { r: 255, g: 0, b: 0 } }, | ||
{ hex: '#00ff00', rgb: { r: 0, g: 255, b: 0 } }, | ||
{ hex: '#0000ff', rgb: { r: 0, g: 0, b: 255 } } | ||
] | ||
|
||
testValues.forEach(({ hex, rgb }) => { | ||
it(`with hex=${hex} should return ${formatRgb(rgb)}`, () => { | ||
const res = hexToRgb(hex) | ||
expect(res.r).toEqual(rgb.r) | ||
expect(res.g).toEqual(rgb.g) | ||
expect(res.b).toEqual(rgb.b) | ||
}) | ||
}) | ||
}) | ||
|
||
describe('rgbToHex', () => { | ||
const testValues = [ | ||
{ rgb: { r: 0, g: 0, b: 0 }, hex: '#000000' }, | ||
{ rgb: { r: 255, g: 255, b: 255 }, hex: '#ffffff' }, | ||
{ rgb: { r: 255, g: 0, b: 0 }, hex: '#ff0000' }, | ||
{ rgb: { r: 0, g: 255, b: 0 }, hex: '#00ff00' }, | ||
{ rgb: { r: 0, g: 0, b: 255 }, hex: '#0000ff' } | ||
] | ||
|
||
testValues.forEach(({ rgb, hex }) => { | ||
it(`with rgb = ${formatRgb(rgb)} should return ${hex}`, () => { | ||
const res = rgbToHex(rgb) | ||
expect(res).toEqual(hex) | ||
}) | ||
}) | ||
}) | ||
|
||
describe('hslToRgb', () => { | ||
const testValues = [ | ||
{ hsl: { h: 0, s: 0, l: 0 }, rgb: { r: 0, g: 0, b: 0 } }, | ||
{ hsl: { h: 0, s: 0, l: 1 }, rgb: { r: 255, g: 255, b: 255 } }, | ||
{ rgb: { r: 255, g: 0, b: 0 }, hsl: { h: 0, s: 1, l: 0.5 } }, | ||
{ rgb: { r: 0, g: 255, b: 0 }, hsl: { h: 0.3333333333333333, s: 1, l: 0.5 } }, | ||
{ rgb: { r: 0, g: 0, b: 255 }, hsl: { h: 0.6666666666666666, s: 1, l: 0.5 } } | ||
] | ||
|
||
testValues.forEach(({ hsl, rgb }) => { | ||
it(`with hsl=${formatHsl(hsl)} should return ${formatRgb(rgb)}`, () => { | ||
const res = hslToRgb(hsl) | ||
expect(res.r).toEqual(rgb.r) | ||
expect(res.g).toEqual(rgb.g) | ||
expect(res.b).toEqual(rgb.b) | ||
}) | ||
}) | ||
}) | ||
|
||
describe('rgbToHsl', () => { | ||
const testValues = [ | ||
{ rgb: { r: 0, g: 0, b: 0 }, hsl: { h: 0, s: 0, l: 0 } }, | ||
{ rgb: { r: 255, g: 255, b: 255 }, hsl: { h: 0, s: 0, l: 1 } }, | ||
{ rgb: { r: 255, g: 0, b: 0 }, hsl: { h: 0, s: 1, l: 0.5 } }, | ||
{ rgb: { r: 0, g: 255, b: 0 }, hsl: { h: 0.3333333333333333, s: 1, l: 0.5 } }, | ||
{ rgb: { r: 0, g: 0, b: 255 }, hsl: { h: 0.6666666666666666, s: 1, l: 0.5 } } | ||
] | ||
|
||
testValues.forEach(({ rgb, hsl }) => { | ||
it(`with rgb = ${formatRgb(rgb)} should return ${formatHsl(hsl)}`, () => { | ||
const res = rgbToHsl(rgb) | ||
expect(res.h).toEqual(hsl.h) | ||
expect(res.s).toEqual(hsl.s) | ||
expect(res.l).toEqual(hsl.l) | ||
}) | ||
}) | ||
}) | ||
|
||
describe('calculateLuminance', () => { | ||
const testValues = [ | ||
{ rgb: { r: 255, g: 255, b: 255 }, luminance: 1 }, | ||
{ rgb: { r: 0, g: 0, b: 0 }, luminance: 0 }, | ||
{ rgb: { r: 255, g: 0, b: 0 }, luminance: 0.2126 }, | ||
{ rgb: { r: 0, g: 255, b: 0 }, luminance: 0.7152 }, | ||
{ rgb: { r: 0, g: 0, b: 255 }, luminance: 0.0722 } | ||
] | ||
|
||
testValues.forEach(({ rgb, luminance }) => { | ||
it(`with rgb = ${formatRgb(rgb)} should return ${luminance}`, () => { | ||
const res = calculateLuminance(rgb) | ||
expect(res).toEqual(luminance) | ||
}) | ||
}) | ||
}) | ||
|
||
describe('getClosestColor', () => { | ||
const testValues = [ | ||
{ hex: '#000000', luminance: 0, hexOut: '#000000' }, | ||
{ hex: '#000000', luminance: 1, hexOut: '#ffffff' }, | ||
{ hex: '#000000', luminance: 0.5, hexOut: '#bcbcbc' }, | ||
{ hex: '#ff0000', luminance: 0.5, hexOut: '#ffa2a2' }, | ||
{ hex: '#00ff00', luminance: 0.5, hexOut: '#00d900' }, | ||
{ hex: '#0000ff', luminance: 0.5, hexOut: '#b5b5ff' } | ||
] | ||
|
||
testValues.forEach(({ hex, luminance, hexOut }) => { | ||
it(`with hex=${hex} and luminance target ${luminance} should return ${hexOut}`, () => { | ||
const res = getClosestColor(hex, luminance) | ||
expect(res).toEqual(hexOut) | ||
}) | ||
}) | ||
}) | ||
|
||
describe('getClosestColorNewton', () => { | ||
const testValues = [ | ||
{ hex: '#000000', luminance: 0, hexOut: '#000000' }, | ||
{ hex: '#000000', luminance: 1, hexOut: '#ffffff' }, | ||
{ hex: '#000000', luminance: 0.5, hexOut: '#bbbbbb' }, | ||
{ hex: '#ff0000', luminance: 0.5, hexOut: '#ffa2a2' }, | ||
{ hex: '#00ff00', luminance: 0.5, hexOut: '#00d900' }, | ||
{ hex: '#0000ff', luminance: 0.5, hexOut: '#b4b4ff' } | ||
] | ||
|
||
testValues.forEach(({ hex, luminance, hexOut }) => { | ||
it(`with hex=${hex} and luminance target ${luminance} should return ${hexOut}`, () => { | ||
const res = getClosestColorNewton(hex, luminance) | ||
expect(res).toEqual(hexOut) | ||
}) | ||
}) | ||
}) |