From d1ded48119e97ede1b4cdf39d91f0ef98eb9c125 Mon Sep 17 00:00:00 2001 From: Vojtech Simetka Date: Fri, 21 Jul 2023 00:12:43 +0200 Subject: [PATCH] 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 --- .github/dependabot.yml | 2 +- .npmrc | 2 + knip.ts | 8 ++ pnpm-lock.yaml | 2 +- src/index.ts | 207 +++++++++++++++++++++++++++++++++++++++++ test/index.spec.ts | 150 +++++++++++++++++++++++++++++ 6 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 .npmrc create mode 100644 knip.ts create mode 100644 src/index.ts create mode 100644 test/index.spec.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index be78d87..586c58e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,4 +3,4 @@ updates: - package-ecosystem: github-actions directory: / schedule: - interval: 'weekly' \ No newline at end of file + interval: 'weekly' diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..16720db --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +engine-strict=true +auto-install-peers=false diff --git a/knip.ts b/knip.ts new file mode 100644 index 0000000..9fd7ef3 --- /dev/null +++ b/knip.ts @@ -0,0 +1,8 @@ +import type { KnipConfig } from 'knip' + +const config: KnipConfig = { + ignoreBinaries: ['publish'], + ignoreExportsUsedInFile: true +} + +export default config diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 586d650..fc9176f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,7 +1,7 @@ lockfileVersion: '6.0' settings: - autoInstallPeers: true + autoInstallPeers: false excludeLinksFromLockfile: false dependencies: diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e73c641 --- /dev/null +++ b/src/index.ts @@ -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 +} diff --git a/test/index.spec.ts b/test/index.spec.ts new file mode 100644 index 0000000..79e24b9 --- /dev/null +++ b/test/index.spec.ts @@ -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) + }) + }) +})