← back to Dw Pairs Well

lib/color.js

105 lines

// hex <-> CIE Lab + ΔE2000

function hexToRgb(hex) {
  if (!hex) return null;
  var h = String(hex).trim().replace(/^#/, '');
  if (h.length === 3) h = h.split('').map(function (c) { return c + c; }).join('');
  if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
  return {
    r: parseInt(h.slice(0, 2), 16),
    g: parseInt(h.slice(2, 4), 16),
    b: parseInt(h.slice(4, 6), 16)
  };
}

function rgbToXyz(rgb) {
  function f(c) {
    c = c / 255;
    return c > 0.04045 ? Math.pow((c + 0.055) / 1.055, 2.4) : c / 12.92;
  }
  var r = f(rgb.r), g = f(rgb.g), b = f(rgb.b);
  return {
    x: (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100,
    y: (r * 0.2126729 + g * 0.7151522 + b * 0.0721750) * 100,
    z: (r * 0.0193339 + g * 0.1191920 + b * 0.9503041) * 100
  };
}

function xyzToLab(xyz) {
  // D65 reference white
  var Xn = 95.0489, Yn = 100.0, Zn = 108.8840;
  function f(t) { return t > 0.008856 ? Math.cbrt(t) : (7.787 * t) + (16 / 116); }
  var fx = f(xyz.x / Xn), fy = f(xyz.y / Yn), fz = f(xyz.z / Zn);
  return { L: (116 * fy) - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
}

function hexToLab(hex) {
  var rgb = hexToRgb(hex);
  if (!rgb) return null;
  return xyzToLab(rgbToXyz(rgb));
}

// ΔE2000 — perceptual color distance. Lower = more similar.
function deltaE2000(lab1, lab2) {
  if (!lab1 || !lab2) return Infinity;
  var L1 = lab1.L, a1 = lab1.a, b1 = lab1.b;
  var L2 = lab2.L, a2 = lab2.a, b2 = lab2.b;
  var avgL = (L1 + L2) / 2;
  var C1 = Math.sqrt(a1 * a1 + b1 * b1);
  var C2 = Math.sqrt(a2 * a2 + b2 * b2);
  var avgC = (C1 + C2) / 2;
  var G = 0.5 * (1 - Math.sqrt(Math.pow(avgC, 7) / (Math.pow(avgC, 7) + Math.pow(25, 7))));
  var a1p = (1 + G) * a1, a2p = (1 + G) * a2;
  var C1p = Math.sqrt(a1p * a1p + b1 * b1), C2p = Math.sqrt(a2p * a2p + b2 * b2);
  var avgCp = (C1p + C2p) / 2;
  function hp(ap, bp) {
    if (ap === 0 && bp === 0) return 0;
    var h = Math.atan2(bp, ap) * 180 / Math.PI;
    return h >= 0 ? h : h + 360;
  }
  var h1p = hp(a1p, b1), h2p = hp(a2p, b2);
  var dhp;
  if (C1p * C2p === 0) dhp = 0;
  else {
    dhp = h2p - h1p;
    if (dhp > 180) dhp -= 360;
    if (dhp < -180) dhp += 360;
  }
  var dLp = L2 - L1, dCp = C2p - C1p;
  var dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp / 2) * Math.PI / 180);
  var avgHp;
  if (C1p * C2p === 0) avgHp = h1p + h2p;
  else if (Math.abs(h1p - h2p) <= 180) avgHp = (h1p + h2p) / 2;
  else if (h1p + h2p < 360) avgHp = (h1p + h2p + 360) / 2;
  else avgHp = (h1p + h2p - 360) / 2;
  var T = 1
    - 0.17 * Math.cos((avgHp - 30) * Math.PI / 180)
    + 0.24 * Math.cos((2 * avgHp) * Math.PI / 180)
    + 0.32 * Math.cos((3 * avgHp + 6) * Math.PI / 180)
    - 0.20 * Math.cos((4 * avgHp - 63) * Math.PI / 180);
  var dTheta = 30 * Math.exp(-Math.pow((avgHp - 275) / 25, 2));
  var Rc = 2 * Math.sqrt(Math.pow(avgCp, 7) / (Math.pow(avgCp, 7) + Math.pow(25, 7)));
  var Sl = 1 + ((0.015 * Math.pow(avgL - 50, 2)) / Math.sqrt(20 + Math.pow(avgL - 50, 2)));
  var Sc = 1 + 0.045 * avgCp;
  var Sh = 1 + 0.015 * avgCp * T;
  var Rt = -Math.sin(2 * dTheta * Math.PI / 180) * Rc;
  return Math.sqrt(
    Math.pow(dLp / Sl, 2) +
    Math.pow(dCp / Sc, 2) +
    Math.pow(dHp / Sh, 2) +
    Rt * (dCp / Sc) * (dHp / Sh)
  );
}

function safeParseHexArray(value) {
  if (!value) return [];
  if (Array.isArray(value)) return value.filter(Boolean);
  try {
    var parsed = JSON.parse(value);
    if (Array.isArray(parsed)) return parsed.filter(Boolean);
  } catch (_) { /* not JSON */ }
  return [];
}

module.exports = { hexToRgb, hexToLab, deltaE2000, safeParseHexArray };