color-math
Version:
expressions to manipulate colors
312 lines (253 loc) • 8.7 kB
JavaScript
const Chroma = require('chroma-js')
import ColorScale from './ColorScale'
import BlendMode from './BlendMode'
import ValueType from './ValueType'
const getColorBlender = (() => {
const bs = {}
bs[BlendMode.None] = (a) => a
bs[BlendMode.Replace] = (a, b) => b
bs[BlendMode.Add] = (a, b) => Math.min(a + b, 255)
bs[BlendMode.ColorBurn] = (a, b) => b <= 0 ? 0 : Math.max(255 - (255 - a) * 255 / b, 0)
bs[BlendMode.ColorDodge] = (a, b) => b >= 255 ? 255 : Math.min(a * 255 / (255 - b), 255)
bs[BlendMode.Darken] = (a, b) => Math.min(a, b)
bs[BlendMode.Difference] = (a, b) => Math.abs(a - b)
bs[BlendMode.Divide] = (a, b) => Math.min((a / 255) / (b / 255) * 255, 255)
bs[BlendMode.Exclusion] = (a, b) => 255 - (((255 - a) * (255 - b) / 255) + (a * b / 255))
bs[BlendMode.HardLight] = (a, b) => b < 128 ? (2 * a * b) / 255 : 255 - ((2 * (255 - a) * (255 - b)) / 255)
bs[BlendMode.Lighten] = (a, b) => Math.max(a, b)
bs[BlendMode.LinearBurn] = (a, b) => Math.max(0, a + b - 255)
bs[BlendMode.LinearDodge] = (a, b) => Math.min(a + b, 255)
bs[BlendMode.Multiply] = (a, b) => a * b / 255
bs[BlendMode.Negate] = (a, b) => 255 - Math.abs(255 - a - b)
bs[BlendMode.Overlay] = (a, b) => a < 128 ? (2 * a * b / 255) : (255 - (2 * (255 - a) * (255 - b) / 255))
bs[BlendMode.Screen] = (a, b) => 255 - ((255 - a) * (255 - b)) / 255
bs[BlendMode.SoftLight] = (a, b) => a < 128 ? (((b >> 1) + 64) * a * (2 / 255)) : (255 - (191 - (b >> 1)) * (255 - a) * (2 / 255))
bs[BlendMode.Subtract] = (a, b) => Math.max(a - b, 0)
return (mode) => bs[mode]
})()
export function blendColors(bg, fg, mode) {
const blender = getColorBlender(mode)
const bgComps = bg.rgba()
const fgComps = fg.rgba()
const resComps = [void 0, void 0, void 0, fgComps[3] + bgComps[3] * (1 - fgComps[3])]
for (let i = 0; i < 3; i++) {
const c1 = bgComps[i] / 255
const c2 = fgComps[i] / 255
let c = blender(bgComps[i], fgComps[i]) / 255
if (resComps[3]) {c = (fgComps[3] * c2 + bgComps[3] * (c1 - fgComps[3] * (c1 + c2 - c))) / resComps[3]}
resComps[i] = c * 255
}
const color = Chroma(resComps)
return color
}
export function cmyToCmykArray(valuesOrC, m, y) {
let k = 1
let c
if (!Array.isArray(valuesOrC)) {c = valuesOrC} else {[c, m, y] = valuesOrC}
if (c < k) {k = c}
if (m < k) {k = m}
if (y < k) {k = y}
if (k === 1) {
c = 0
m = 0
y = 0
} else {
c = (c - k) / (1 - k)
m = (m - k) / (1 - k)
y = (y - k) / (1 - k)
}
return [c, m, y, k]
}
export function cmyToCmyk(c, m, y) {
const values = cmyToCmykArray(c, m, y)
const color = Chroma(values, 'cmyk')
return color
}
export function inverseColor(color) {
let color2 = cloneValue(color)
color2 = color2.set('rgb.r', 255 - color.get('rgb.r'))
color2 = color2.set('rgb.g', 255 - color.get('rgb.g'))
color2 = color2.set('rgb.b', 255 - color.get('rgb.b'))
return color2
}
export function colorArithmeticOp(value1, value2, op) {
let color
let n
if (isColor(value1)) {
[color, n] = [value1, value2]
} else {
[n, color] = [value1, value2]
}
color = cloneValue(color)
color = color.set('rgb.r', op + n)
color = color.set('rgb.g', op + n)
color = color.set('rgb.b', op + n)
return color
}
export function roundColorComps(color, space = 'rgb') {
const ranges = getColorSpaceParamsValidRanges(space)
const comps = color.get(space)
for (let i = 0; i < ranges.length; i++) {
if (ranges[i][1] - ranges[i][0] > 2) {
comps[i] = Math.round(comps[i])
}
}
let res = Chroma(comps, space)
res = res.alpha(color.alpha())
return res
}
export function getColorSpaceParamsValidRanges(space) {
switch (space) {
case 'rgb': return [[0, 255], [0, 255], [0, 255]]
case 'cmy': return [[0, 1], [0, 1], [0, 1]]
case 'cmyk': return [[0, 1], [0, 1], [0, 1], [0, 1]]
case 'hsl': return [[0, 360], [0, 1], [0, 1]]
case 'hsv': return [[0, 360], [0, 1], [0, 1]]
case 'hsi': return [[0, 360], [0, 1], [0, 1]]
case 'lab': return [[0, 100], [-128, 127], [-128, 127]]
case 'lch': return [[0, 100], [0, 140], [0, 360]]
case 'hcl': return [[0, 360], [0, 140], [0, 100]]
default:
throw new SyntaxError(`unknown namespace: ${space.toUpperCase()}.`)
}
}
export function isColor(value) {
return value
&& Array.isArray(value._rgb)
&& value._rgb.length === 4
}
export function formatColor(color, appendName = false) {
color = roundColorComps(color)
const hex = color.hex()
const hex8 = color.hex('rgba')
const alpha = color.alpha()
const name = color.name()
let s = alpha === 1 || isNaN(alpha) ? hex : hex8
if (appendName && name !== hex) {
s += ` (${name})`
}
return s
}
export function colorFromWavelength(wl) {
let r = 0
let g = 0
let b = 0
let a = 1
if (wl >= 380 && wl < 440) {
r = -1 * (wl - 440) / (440 - 380)
g = 0
b = 1
} else if (wl >= 440 && wl < 490) {
r = 0
g = (wl - 440) / (490 - 440)
b = 1
} else if (wl >= 490 && wl < 510) {
r = 0
g = 1
b = -1 * (wl - 510) / (510 - 490)
} else if (wl >= 510 && wl < 580) {
r = (wl - 510) / (580 - 510)
g = 1
b = 0
} else if (wl >= 580 && wl < 645) {
r = 1
g = -1 * (wl - 645) / (645 - 580)
b = 0.0
} else if (wl >= 645 && wl <= 780) {
r = 1
g = 0
b = 0
}
if (wl > 780 || wl < 380) {
a = 0
} else if (wl > 700) {
a = (780 - wl) / (780 - 700)
} else if (wl < 420) {
a = (wl - 380) / (420 - 380)
}
const color = Chroma([r, g, b, a], 'gl')
return color
}
export function throwError(error, loc) {
const locStr = loc ? ` (${loc})` : ''
throw `Error${locStr}: ${error}.`
}
export function getType(value) {
if (typeof value === 'number') {
return ValueType.Number
} else if (isColor(value)) {
return ValueType.Color
} else if (Array.isArray(value)) {
if (value.length) {
if (value.every(v => getType(v) === ValueType.Number)) {
return ValueType.NumberArray
} else if (value.every(v => getType(v) === ValueType.Color)) {
return ValueType.ColorArray
}
}
return ValueType.Array
} else if (value instanceof ColorScale) {
return ValueType.ColorScale
}
}
export function forceType(value, type, loc) {
const valueType = getType(value)
const types = Array.isArray(type) ? type : [type]
if (types.every(t => (t & valueType) !== t)) {
const strs = types.map(t => {
switch (t) {
case ValueType.Number: return 'a number'
case ValueType.Color: return 'a color'
case ValueType.ColorScale: return 'a color scale'
case ValueType.Array: return 'an array'
case ValueType.NumberArray: return 'a number array'
case ValueType.ColorArray: return 'a color array'
default: throw new Error('invalid value type')
}
})
let msg = strs[0]
if (strs.length > 1) {
msg = strs.slice(0, -1).join(', ') + ' or ' + strs.slice(-1)
}
throwError(`value is not ${msg}`, loc)
}
return value
}
export function forceRange(value, loc) {
if (getType(value) !== ValueType.NumberArray || value.length !== 2) {
throwError('operand is not valid numeric range', loc)
}
return value
}
export function cloneValue(value) {
const type = getType(value)
switch (type) {
case ValueType.Color:
return Chroma(value.rgba())
case ValueType.ColorScale:
return value.clone()
case ValueType.Array:
case ValueType.NumberArray:
case ValueType.ColorArray:
return value.map(v => cloneValue(v))
default:
return value
}
}
export function forceNumInRange(value, minOrRange, maxOrLoc, loc) {
const min = Array.isArray(minOrRange) ? minOrRange[0] : minOrRange
const max = Array.isArray(minOrRange) ? minOrRange[1] : maxOrLoc
loc = Array.isArray(minOrRange) ? maxOrLoc : loc
const n = forceType(value, ValueType.Number, loc)
if (n < min || n > max) {
throwError(`number in a range [${min}..${max}] is expected, you provided: ${n}`, loc)
}
return n
}
export function getObjKey(obj, value) {
for (const key in obj) {
if ((!obj.hasOwnProperty || key in obj) && obj[key] === value) {
return key
}
}
}