← 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 };