lib/threshold-finder.js
"use strict";
/** @private */
const Utils = require("./color-utils").ColorUtils;
/** @private */
const Checker = require("./contrast-checker").ContrastChecker;
/** @private */
class SearchCriteria {
static shouldScanDarkerSide(fixedRgb, otherRgb) {
const fixedLuminance = Checker.relativeLuminance(fixedRgb);
const otherLuminance = Checker.relativeLuminance(otherRgb);
return fixedLuminance > otherLuminance ||
fixedLuminance === otherLuminance && Checker.isLightColor(fixedRgb);
}
static define(fixedRgb, otherRgb, level) {
const targetContrast = Checker.levelToRatio(level);
if (this.shouldScanDarkerSide(fixedRgb, otherRgb)) {
return new ToDarkerSide(targetContrast, fixedRgb);
} else {
return new ToBrighterSide(targetContrast, fixedRgb);
}
}
constructor(targetContrast, fixedRgb) {
this.targetContrast = targetContrast;
this.fixedLuminance = Checker.relativeLuminance(fixedRgb);
}
hasSufficientContrast(rgb) {
return this.contrastRatio(rgb) >= this.targetContrast;
}
contrastRatio(rgb) {
const luminance = Checker.relativeLuminance(rgb);
return Checker.luminanceToContrastRatio(this.fixedLuminance,
luminance);
}
}
/** @private */
class ToDarkerSide extends SearchCriteria {
round(ratio) {
return Math.floor(ratio * 10 ) / 10;
}
incrementCondition(contrastRatio) {
return contrastRatio > this.targetContrast;
}
}
/** @private */
class ToBrighterSide extends SearchCriteria {
round(ratio) {
return Math.ceil(ratio * 10) / 10;
}
incrementCondition(contrastRatio) {
return this.targetContrast > contrastRatio;
}
}
/** @private */
class ThresholdFinder {
/** @private */
static * binarySearchWidth(initWidth, min) {
let i = 1;
let d = initWidth / Math.pow(2, i);
while (d > min) {
yield d;
i++;
d = initWidth / Math.pow(2, i);
}
}
/**
* @private
*/
static findRatio(otherColor, criteria, initRatio, initWidth) {
let r = initRatio;
let passingRatio = null;
for (let d of this.binarySearchWidth(initWidth, 0.01)) {
const newRgb = this.rgbWithRatio(otherColor, r);
const contrast = criteria.contrastRatio(newRgb);
if (criteria.hasSufficientContrast(newRgb)) { passingRatio = r; }
if (contrast === criteria.targetContrast) { break; }
r += criteria.incrementCondition(contrast) ? d : -d;
}
return [r, passingRatio];
}
/**
* @private
*/
static rgbWithBetterRatio(color, criteria, lastRatio, passingRatio) {
const closestRgb = this.rgbWithRatio(color, lastRatio);
if (passingRatio && ! criteria.hasSufficientContrast(closestRgb)) {
return this.rgbWithRatio(color, passingRatio);
}
return closestRgb;
}
}
/** @private */
class LightnessFinder extends ThresholdFinder {
/**
* Tries to find a color whose contrast against the base color is close to
* a given level.
*
* The returned color is gained by modifying the lightness of otherRgb.
* Even when a color that satisfies the level is not found, it returns
* a new color anyway.
* @param {Array<number, number, number>} fixedRgb - RGB value which remains
* unchanged
* @param {Array<number, number, number>} otherRgb - RGB value before the
* modification of lightness
* @param {string} [level="AA"] - A, AA or AAA
* @returns {Array<number, number, number>} RGB value of a new color whose
* contrast ratio against fixedRgb is close to a specified level
*/
static find(fixedRgb, otherRgb, level = "AA") {
const criteria = SearchCriteria.define(fixedRgb, otherRgb, level);
const otherHsl = Utils.rgbToHsl(otherRgb);
const [max, min] = this.determineMinmax(fixedRgb, otherRgb, otherHsl[2]);
const boundaryRgb = this.boundaryColor(fixedRgb, max, min, criteria);
if (boundaryRgb) { return boundaryRgb; }
const [r, passingRatio] = this.findRatio(otherHsl, criteria,
(max + min) / 2, max - min);
return this.rgbWithBetterRatio(otherHsl, criteria, r, passingRatio);
}
/**
* @private
*/
static rgbWithRatio(hsl, ratio) {
if (ratio !== undefined && hsl[2] !== ratio) {
hsl = hsl.slice(0);
hsl[2] = ratio;
}
return Utils.hslToRgb(hsl);
}
/**
* @private
*/
static determineMinmax(fixedRgb, otherRgb, initL) {
if (SearchCriteria.shouldScanDarkerSide(fixedRgb, otherRgb)) {
return [initL, 0];
} else {
return [100, initL];
}
}
/**
* @private
*/
static boundaryColor(rgb, max, min, criteria) {
const black = Checker.LUMINANCE.BLACK;
const white = Checker.LUMINANCE.WHITE;
if (min === 0 && ! this.hasSufficientContrast(black, rgb, criteria)) {
return Utils.RGB.BLACK;
}
if (max === 100 && ! this.hasSufficientContrast(white, rgb, criteria)) {
return Utils.RGB.WHITE;
}
return null;
}
/**
* @private
*/
static hasSufficientContrast(refLuminance, rgb, criteria) {
const luminance = Checker.relativeLuminance(rgb);
const ratio = Checker.luminanceToContrastRatio(refLuminance, luminance);
return ratio >= criteria.targetContrast;
}
}
/** @private */
class BrightnessFinder extends ThresholdFinder {
/**
* Tries to find a color whose contrast against the base color is close
* to a given level.
*
* The returned color is gained by modifying the brightness of otherRgb.
* Even when a color that satisfies the level is not found, it returns
* a new color anyway.
* @param {Array<number, number, number>} fixedRgb - RGB value which remains
* unchanged
* @param {Array<number, number, number>} otherRgb - RGB value before the
* modification of brightness
* @param {string} [level="AA"] - A, AA or AAA
* @returns {Array<number, number, number>} RGB value of a new color whose
* contrast ratio against fixedRgb is close to a specified level
*/
static find(fixedRgb, otherRgb, level = "AA") {
const criteria = SearchCriteria.define(fixedRgb, otherRgb, level);
const w = this.calcUpperRatioLimit(otherRgb) / 2;
const upperRgb = this.rgbWithRatio(otherRgb, w * 2);
if (this.exceedUpperLimit(criteria, otherRgb, upperRgb)) {
return upperRgb;
}
const ratios = this.findRatio(otherRgb, criteria, w, w).map(criteria.round);
return this.rgbWithBetterRatio(otherRgb, criteria, ...ratios);
}
/**
* @private
*/
static rgbWithRatio(rgb, ratio) {
return Utils.BrightnessCalc.calcRgb(rgb, ratio);
}
/**
* @private
*/
static exceedUpperLimit(criteria, otherRgb, upperRgb) {
const otherLuminance = Checker.relativeLuminance(otherRgb);
return otherLuminance > criteria.fixedLuminance &&
! criteria.hasSufficientContrast(upperRgb);
}
/**
* @private
*/
static calcUpperRatioLimit(rgb) {
if (Utils.isSameRgbColor(Utils.RGB.BLACK, rgb)) {
return 100;
}
const darkest = rgb
.filter(c => c !== 0)
.reduce((a, b) => Math.min(a, b));
return Math.ceil((255 / darkest) * 100);
}
}
module.exports.LightnessFinder = LightnessFinder;
module.exports.BrightnessFinder = BrightnessFinder;