← back to Letsbegin

color-family-map.js

166 lines

/**
 * color-family-map.js — controlled vocabulary + deterministic vendor-color-word → family map.
 *
 * CANONICAL COLOR = the vendor's motif colorway word from the title (Steve, 2026-06-11),
 * NOT the AI ground-pixel color. This module maps that word to ONE controlled family.
 *
 * Strategy:
 *   1. Normalize the word (lowercase, decode &, strip qualifier adjectives).
 *   2. If it's a multi-color word ("X & Y", "multi", "rainbow", "colorful") → 'Multi'.
 *   3. Exact/keyword name→family lookup (deterministic).
 *   4. If unmapped (non-color word like "Day", "Vintage", proper names) → caller falls
 *      back to HSL-bucketing the product's custom.dominant_hex (ground-derived tiebreaker).
 */

// 22-family controlled vocabulary.
const FAMILIES = [
  'White', 'Cream/Beige', 'Grey', 'Black', 'Brown/Tan',
  'Red', 'Orange', 'Pink', 'Coral', 'Yellow/Gold',
  'Green', 'Sage/Olive', 'Teal', 'Blue', 'Navy',
  'Purple/Lavender', 'Mauve', 'Metallic/Gold', 'Silver', 'Multi',
  'Neutral', 'Terracotta',
];

// qualifier adjectives stripped for BUCKETING (kept as a note elsewhere if needed)
const QUALIFIERS = /\b(light|dark|soft|warm|cool|deep|pale|dusty|muted|bright|misty|aged|antique|vintage|crisp|pure|stark|royal|baby|sky|ice|steel|storm|slate|ash|cloud|happy|sunny|natural|rebel|adventure|tropical|jungle|meadow|forest|grass|fern|spring|summer|autumn|fall|morning|sunrise|sunshine|whitewash|pastel|alpine|cotswold|cambridge|oxford|richmond|london|indian|brazilian|morocco|porcelain|antique|aged|sterling)\b/g;

function decode(s) {
  return String(s || '')
    .replace(/&/gi, '&')
    .replace(/\bamp\b;?/gi, '&')   // catches leaked "&Amp;" → "& &" handled below
    .replace(/&/g, ' & ')
    .replace(/\s+/g, ' ')
    .trim();
}

// Multi-color detection: any conjunction of two colors, or an explicit multi word.
function isMulti(raw) {
  const d = decode(raw).toLowerCase();
  if (/\b(multi|multicolor|multicolour|rainbow|colorful|colourful|harlequin|lollipop|bubblegum|valentine)\b/.test(d)) return true;
  // "X & Y" with an ampersand separating two tokens
  if (/&/.test(d)) {
    const parts = d.split('&').map(p => p.trim()).filter(Boolean);
    if (parts.length >= 2 && parts.every(p => p.length > 1)) return true;
  }
  return false;
}

// keyword → family. Order matters: more specific keys first within the scan.
// Each entry: [regex tested against normalized lowercased word, family].
const RULES = [
  // multi handled separately before this
  // metallic / precious
  [/\b(gold|golden|brass|bronze)\b/, 'Metallic/Gold'],
  [/\b(silver|sterling|pewter|chrome|platinum)\b/, 'Silver'],
  // navy before blue
  [/\b(navy|midnight|nightfall|deep space|oxford blue|sapphire|indigo|cobalt|royal blue|deep navy|charcoal navy)\b/, 'Navy'],
  // teal before blue/green
  [/\b(teal|petrol|turquoise|aqua|ocean|cyan)\b/, 'Teal'],
  // blue
  [/\b(blue|blueberry|cornflower|periwinkle|cerulean|azure|denim|breeze)\b/, 'Blue'],
  // sage / olive before green
  [/\b(sage|olive|moss|fern|khaki|pistachio|eucalyptus|greige)\b/, 'Sage/Olive'],
  // green
  [/\b(green|emerald|forest|jungle|meadow|grass|lush|mint|jade|verde|spring)\b/, 'Green'],
  // mauve before purple/pink
  [/\b(mauve|greige)\b/, 'Mauve'],
  // purple / lavender
  [/\b(purple|lilac|lavender|violet|plum|aubergine|grape|orchid|amethyst)\b/, 'Purple/Lavender'],
  // coral before pink/orange
  [/\b(coral)\b/, 'Coral'],
  // pink
  [/\b(pink|blush|rose|fuchsia|magenta|raspberry|rosy|bubblegum|happy pink|rebel pink)\b/, 'Pink'],
  // terracotta / rust before orange/brown
  [/\b(terracotta|terra cotta|rust|clay|sienna|brick|adobe)\b/, 'Terracotta'],
  // orange
  [/\b(orange|peach|apricot|tangerine|amber|saffron|mustard|marigold|pumpkin)\b/, 'Orange'],
  // red
  [/\b(red|crimson|burgundy|maroon|scarlet|wine|cherry|ruby|cardinal|valentine)\b/, 'Red'],
  // yellow
  [/\b(yellow|lemon|canary|sunflower|honey|butter|sunshine)\b/, 'Yellow/Gold'],
  // brown / tan
  [/\b(brown|tan|taupe|chocolate|coffee|mocha|espresso|walnut|chestnut|toffee|caramel|nougat|sepia|hazelnut|cocoa|pine|birch|sandstone|desert|stone|nutmeg|cinnamon|wood)\b/, 'Brown/Tan'],
  // cream / beige
  [/\b(cream|creme|beige|ivory|sand|sandy|linen|oatmeal|vanilla|parchment|nude|wheat|biscuit|champagne|buff|ecru|bone|eggshell|alabaster|cotswold|antique cream|whitewash|plaster)\b/, 'Cream/Beige'],
  // white
  [/\b(white|snow|chalk|porcelain|milky|pearl|frost|cloud|oxygen)\b/, 'White'],
  // grey
  [/\b(gray|grey|graphite|charcoal|slate|ash|stone|cement|pewter|smoke|storm|fog|gunmetal|steel|silver gray|shadow)\b/, 'Grey'],
  // black
  [/\b(black|onyx|ebony|jet|ink|coal|licorice|liquorice|noir|raven|blackboard|obsidian|midnight black)\b/, 'Black'],
  // neutral catch
  [/\b(neutral|natural)\b/, 'Neutral'],
];

/**
 * mapVendorColorToFamily(word) → { family, method } | { family:null }
 *   method: 'multi' | 'name' | null (caller must hex-fallback when null)
 */
function mapVendorColorToFamily(word) {
  if (!word) return { family: null, method: null };
  const raw = String(word).trim();
  if (isMulti(raw)) return { family: 'Multi', method: 'multi' };
  const norm = decode(raw).toLowerCase();
  for (const [re, fam] of RULES) {
    if (re.test(norm)) return { family: fam, method: 'name' };
  }
  return { family: null, method: null };
}

// ---- HSL hex bucketing (safety-net tiebreaker on ground-derived dominant_hex) ----
function hexToHsl(hex) {
  if (!hex) return null;
  const m = String(hex).replace('#', '').trim();
  if (!/^[0-9a-fA-F]{6}$/.test(m)) return null;
  const r = parseInt(m.slice(0, 2), 16) / 255;
  const g = parseInt(m.slice(2, 4), 16) / 255;
  const b = parseInt(m.slice(4, 6), 16) / 255;
  const max = Math.max(r, g, b), min = Math.min(r, g, b);
  let h = 0, s = 0; const l = (max + min) / 2;
  if (max !== min) {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    switch (max) {
      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 };
}

function hexToFamily(hex) {
  const hsl = hexToHsl(hex);
  if (!hsl) return 'Neutral';
  const { h, s, l } = hsl;
  // achromatic ladder
  if (s < 12) {
    if (l > 88) return 'White';
    if (l > 72) return 'Cream/Beige';   // very light neutral reads as cream
    if (l < 14) return 'Black';
    return 'Grey';
  }
  // low-sat warm muted → beige/brown/taupe
  if (s < 22) {
    if (l > 78) return 'Cream/Beige';
    if (l < 30) return (h >= 20 && h <= 60) ? 'Brown/Tan' : 'Grey';
    if (h >= 20 && h <= 55) return 'Brown/Tan';
  }
  // chromatic by hue
  if (h < 12 || h >= 345) return 'Red';
  if (h < 22) return (l < 45 ? 'Terracotta' : 'Coral');
  if (h < 40) return (l < 40 ? 'Brown/Tan' : 'Orange');
  if (h < 50) return (s < 45 ? 'Brown/Tan' : 'Yellow/Gold');
  if (h < 70) return 'Yellow/Gold';
  if (h < 95) return 'Sage/Olive';
  if (h < 160) return 'Green';
  if (h < 195) return 'Teal';
  if (h < 240) return (l < 35 ? 'Navy' : 'Blue');
  if (h < 270) return (l < 35 ? 'Navy' : 'Purple/Lavender');
  if (h < 315) return 'Purple/Lavender';
  return 'Pink';
}

module.exports = { FAMILIES, mapVendorColorToFamily, hexToFamily, isMulti, decode };