← back to Wallco Ai

lib/color.js

131 lines

// lib/color.js — color-space helpers used across server.js for sort/filter,
// "more like this", neon-aesthetic gates, and snapshot-style title generation.
//
// REFACTOR-1b (2026-05-23): extracted verbatim from server.js. No behavior
// change — same return shapes ({h,s,l} object vs [h,s,l] array), same
// hue-bucket cutoffs, same neon thresholds.
//
// Two HSL variants exist on purpose:
//   _hexToHsl   → {h, s, l}  in degrees / percent / percent — used by the
//                bucket / neon helpers.
//   hexToHSL    → [h, s, l]  in degrees / 0..1 / 0..1     — used by the
//                sort path (matches the snapshot-py port).
// They cannot be merged without auditing every caller — the .s scale differs
// (0..100 vs 0..1). Left distinct.

'use strict';

// ── HSL variant A: object shape, s/l in percent (0..100).
function _hexToHsl(hex) {
  if (!hex || typeof hex !== 'string') return null;
  const m = hex.trim().match(/^#?([0-9a-f]{6})$/i);
  if (!m) return null;
  const n = parseInt(m[1], 16);
  const r = ((n >> 16) & 0xff) / 255;
  const g = ((n >> 8)  & 0xff) / 255;
  const b = ( n        & 0xff) / 255;
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
  const l  = (mx + mn) / 2;
  let h = 0, s = 0;
  if (mx !== mn) {
    const d = mx - mn;
    s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
    switch (mx) {
      case r: h = (g - b) / d + (g < b ? 6 : 0); break;
      case g: h = (b - r) / d + 2; break;
      case b: h = (r - g) / d + 4; break;
    }
    h *= 60;
  }
  return { h, s: s * 100, l: l * 100 };
}

// Coarse 12-bucket hue classifier. Returns 'neutral' for low-sat or near-black/white.
function _hueBucket(hex) {
  const hsl = _hexToHsl(hex);
  if (!hsl) return 'unknown';
  if (hsl.s < 12 || hsl.l < 10 || hsl.l > 90) return 'neutral';
  const h = hsl.h;
  if (h < 15) return 'red'; if (h < 45) return 'orange'; if (h < 65) return 'yellow';
  if (h < 90) return 'chartreuse'; if (h < 150) return 'green'; if (h < 180) return 'teal';
  if (h < 210) return 'cyan'; if (h < 250) return 'blue'; if (h < 280) return 'indigo';
  if (h < 310) return 'violet'; if (h < 340) return 'magenta'; return 'red';
}

// Neon-aesthetic flag: high saturation + mid lightness + (optional) palette percentage.
function _isNeonHex(hex, pct) {
  const hsl = _hexToHsl(hex);
  if (!hsl) return false;
  return hsl.s >= 70 && hsl.l >= 45 && hsl.l <= 75 && (pct == null || pct >= 5);
}

// CIELAB conversion for ΔE color-distance ("more like this in this palette").
function _hexToLab(hex) {
  let h = String(hex || '').replace('#','').trim();
  if (h.length === 3) h = h.split('').map(c => c+c).join('');
  if (!/^[0-9a-f]{6}$/i.test(h)) return null;
  let r = parseInt(h.slice(0,2),16)/255, g = parseInt(h.slice(2,4),16)/255, b = parseInt(h.slice(4,6),16)/255;
  // sRGB → linear
  [r,g,b] = [r,g,b].map(v => v <= 0.04045 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4));
  // linear → XYZ (D65)
  const X = (r*0.4124564 + g*0.3575761 + b*0.1804375) / 0.95047;
  const Y = (r*0.2126729 + g*0.7151522 + b*0.0721750) / 1.00000;
  const Z = (r*0.0193339 + g*0.1191920 + b*0.9503041) / 1.08883;
  // XYZ → Lab
  const f = (t) => t > 0.008856 ? Math.cbrt(t) : (7.787*t + 16/116);
  const fx = f(X), fy = f(Y), fz = f(Z);
  return [ 116*fy - 16, 500*(fx-fy), 200*(fy-fz) ];
}

// Plain CIE76 ΔE (Euclidean). Good enough for ranking; not CIEDE2000.
function _dE(a, b) {
  const dL = a[0]-b[0], dA = a[1]-b[1], dB = a[2]-b[2];
  return Math.sqrt(dL*dL + dA*dA + dB*dB);
}

// ── HSL variant B: array shape, s/l in 0..1. Mirrors refresh_designs_snapshot.py.
function hexToHSL(hex) {
  if (!hex) return [0, 0, 0.5];
  const h = hex.replace('#', '');
  const r = parseInt(h.slice(0,2),16)/255;
  const g = parseInt(h.slice(2,4),16)/255;
  const b = parseInt(h.slice(4,6),16)/255;
  const max = Math.max(r,g,b), min = Math.min(r,g,b);
  const l = (max+min)/2;
  let hue = 0, sat = 0;
  if (max !== min) {
    const d = max - min;
    sat = l > 0.5 ? d/(2-max-min) : d/(max+min);
    if (max===r) hue = (g-b)/d + (g<b?6:0);
    else if (max===g) hue = (b-r)/d + 2;
    else hue = (r-g)/d + 4;
    hue *= 60;
  }
  return [hue, sat, l];
}

function hexSaturation(hex) { return Math.round(hexToHSL(hex)[1] * 1000) / 1000; }

// HUE_NAMES table used by hueName + snapshot-style title generator.
const HUE_NAMES = [
  [  0, 18, 'Crimson'], [ 18, 38, 'Amber'],  [ 38, 58, 'Honey'],
  [ 58, 78, 'Olive'],   [ 78,160, 'Sage'],   [160,200, 'Teal'],
  [200,255, 'Sapphire'],[255,295, 'Violet'], [295,335, 'Rose'],
  [335,360, 'Crimson']
];

function hueName(hex) {
  if (!hex) return 'Bone';
  const [h, s] = hexToHSL(hex);
  if (s < 0.06) return 'Noir';
  for (const r of HUE_NAMES) if (h >= r[0] && h < r[1]) return r[2];
  return 'Bone';
}

module.exports = {
  _hexToHsl, _hueBucket, _isNeonHex,
  _hexToLab, _dE,
  hexToHSL, hexSaturation, hueName,
  HUE_NAMES,
};