color-math
Version: 
expressions to manipulate colors
393 lines (334 loc) • 8.98 kB
JavaScript
import chroma from 'chroma-js';
import {ColorScale} from './ColorScale.js';
import {BlendMode} from './BlendMode.js';
import {ValueType} from './ValueType.js';
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;
    }
  }
}