[object Object]

← back to Dw Pairs Well

Design Coordinate v3: stripe/plaid/check filter + palette-vs-palette ΔE scoring

95a5934b769041165033e7b4e539b025e349bca3 · 2026-05-13 09:11:09 -0700 · Steve Abrams

- Hard motif filter on candidates (stripe/plaid/check/tartan/gingham/ticking)
- For each source palette color, find best ΔE2000 in candidate palette
- 'Picks up: ochre, navy, cream' explanation per card
- Vendor cap 2/6, motif cap 4/6 so plaids + stripes both surface

Files touched

Diff

commit 95a5934b769041165033e7b4e539b025e349bca3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 13 09:11:09 2026 -0700

    Design Coordinate v3: stripe/plaid/check filter + palette-vs-palette ΔE scoring
    
    - Hard motif filter on candidates (stripe/plaid/check/tartan/gingham/ticking)
    - For each source palette color, find best ΔE2000 in candidate palette
    - 'Picks up: ochre, navy, cream' explanation per card
    - Vendor cap 2/6, motif cap 4/6 so plaids + stripes both surface
---
 server.js | 183 +++++++++++++++++++++++++++++++++++++++++++-------------------
 1 file changed, 129 insertions(+), 54 deletions(-)

diff --git a/server.js b/server.js
index a76c58d..186a7cd 100644
--- a/server.js
+++ b/server.js
@@ -74,26 +74,19 @@ async function loadSource({ dw_sku, handle }) {
   return r.rows[0] || null;
 }
 
-async function loadCandidates(source, limit = 400) {
-  const tags = parseTags(source.tags);
-  const cls = classify(tags);
-  const colorList = [...cls.colors];
-  const eraList = [...cls.eras];
-
-  // Broad SQL filter: ACTIVE, has image, has handle, not the source itself,
-  // AND tags overlap on at least one color OR era OR same vendor.
-  // Then we score in JS for the final ranking.
-  const tokens = [...colorList, ...eraList];
-  const patterns = tokens.map(t => `%${t}%`);
-  const params = [source.dw_sku || '', source.vendor || ''];
-  let whereExtras = `(sp.vendor = $2 AND $2 <> '')`;
-  if (patterns.length) {
-    const startIdx = params.length + 1;
-    const placeholders = patterns.map((_, i) => `lower(sp.tags) LIKE lower($${startIdx + i})`).join(' OR ');
-    whereExtras = `(${whereExtras} OR ${placeholders})`;
-    params.push(...patterns);
-  }
-  params.push(limit);
+// "Coordinate" patterns in the interior-design sense — what a designer uses to
+// COORDINATE a hero pattern. Stripes/plaids/checks/tartans/gingham/ticking only.
+// Florals coordinate against florals visually compete; stripes never do.
+const COORDINATE_MOTIFS = ['stripe', 'striped', 'plaid', 'check', 'checked', 'checkered', 'tartan', 'gingham', 'ticking'];
+
+async function loadCandidates(source, limit = 600) {
+  // Hard motif filter: candidate MUST have a stripe/plaid/check tag.
+  // We don't filter by color here — color overlap is the scoring signal, but
+  // we want a wide candidate pool so the algorithm can pick the best palette match.
+  const motifClauses = COORDINATE_MOTIFS.map((_, i) => `lower(sp.tags) LIKE $${i + 3}`).join(' OR ');
+  const motifPatterns = COORDINATE_MOTIFS.map(m => `%${m}%`);
+
+  const params = [source.dw_sku || '', limit, ...motifPatterns];
 
   const q = `
     SELECT sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.image_url, sp.tags, sp.pattern_name,
@@ -105,14 +98,75 @@ async function loadCandidates(source, limit = 400) {
       AND sp.handle IS NOT NULL
       AND sp.dw_sku IS NOT NULL
       AND sp.dw_sku <> $1
-      AND ${whereExtras}
-    ORDER BY (sce.dominant_hex IS NOT NULL) DESC, sp.synced_at DESC NULLS LAST
-    LIMIT $${params.length}
+      AND (${motifClauses})
+    ORDER BY (sce.dominant_hex IS NOT NULL) DESC,
+             (sce.hex_codes IS NOT NULL)    DESC,
+             sp.synced_at DESC NULLS LAST
+    LIMIT $2
   `;
   const r = await pool.query(q, params);
   return r.rows;
 }
 
+function detectCoordinateMotif(tags) {
+  if (!tags) return null;
+  const lower = String(tags).toLowerCase();
+  for (const m of ['plaid','tartan','gingham','check','ticking','stripe','striped','checked','checkered']) {
+    if (lower.indexOf(m) !== -1) return m === 'striped' ? 'stripe' : (m === 'checked' || m === 'checkered' ? 'check' : m);
+  }
+  return null;
+}
+
+const COLOR_NAME = [
+  // hue-name lookup keyed by approximate Lab — used only for the "Picks up:" explanation
+  { name: 'black',    L: 10  },
+  { name: 'white',    L: 95  },
+  { name: 'cream',    L: 92, a: 3,  b: 14 },
+  { name: 'beige',    L: 80, a: 6,  b: 18 },
+  { name: 'taupe',    L: 60, a: 4,  b: 8  },
+  { name: 'brown',    L: 38, a: 14, b: 22 },
+  { name: 'ochre',    L: 65, a: 12, b: 50 },
+  { name: 'gold',     L: 70, a: 8,  b: 60 },
+  { name: 'mustard',  L: 70, a: 10, b: 70 },
+  { name: 'olive',    L: 50, a: -8, b: 35 },
+  { name: 'sage',     L: 65, a: -15, b: 12 },
+  { name: 'green',    L: 50, a: -40, b: 25 },
+  { name: 'teal',     L: 50, a: -25, b: -10 },
+  { name: 'blue',     L: 45, a: 0,  b: -45 },
+  { name: 'navy',     L: 20, a: 0,  b: -35 },
+  { name: 'lavender', L: 70, a: 12, b: -18 },
+  { name: 'purple',   L: 35, a: 30, b: -35 },
+  { name: 'pink',     L: 75, a: 25, b: 8  },
+  { name: 'rose',     L: 60, a: 35, b: 12 },
+  { name: 'red',      L: 45, a: 60, b: 35 },
+  { name: 'rust',     L: 45, a: 35, b: 40 },
+  { name: 'orange',   L: 65, a: 35, b: 60 },
+  { name: 'gray',     L: 60, a: 0,  b: 0  }
+];
+
+function nameHex(hex) {
+  const lab = hexToLab(hex);
+  if (!lab) return null;
+  // grayscale shortcut
+  const sat = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
+  if (sat < 6) {
+    if (lab.L > 90) return 'white';
+    if (lab.L > 70) return 'light gray';
+    if (lab.L > 35) return 'gray';
+    if (lab.L > 15) return 'charcoal';
+    return 'black';
+  }
+  let best = null, bestD = Infinity;
+  for (const c of COLOR_NAME) {
+    const dL = lab.L - c.L;
+    const da = (c.a == null ? 0 : lab.a - c.a);
+    const db = (c.b == null ? 0 : lab.b - c.b);
+    const d = dL*dL + da*da + db*db;
+    if (d < bestD) { bestD = d; best = c.name; }
+  }
+  return best;
+}
+
 function paletteOf(row) {
   // Build a clean hex list: dominant first, then bg, then the rest of hex_codes,
   // de-duped (case-insensitive), capped at 5.
@@ -130,63 +184,84 @@ function paletteOf(row) {
   return out.slice(0, 5);
 }
 
-function hexScore(sourceHex, candHex) {
-  // 0–6 bonus based on perceptual color similarity of dominant hex.
-  // ΔE2000 ≤ 10 = essentially the same color
-  // ΔE2000 ≤ 20 = clearly related
-  // ΔE2000 ≤ 35 = same color family
-  // ΔE2000 > 50 = different colors
-  var sL = hexToLab(sourceHex), cL = hexToLab(candHex);
-  if (!sL || !cL) return { bonus: 0, dE: null };
-  var dE = deltaE2000(sL, cL);
-  var bonus;
-  if (dE <= 10) bonus = 6;
-  else if (dE <= 20) bonus = 4;
-  else if (dE <= 35) bonus = 2;
-  else if (dE <= 50) bonus = 0;
-  else bonus = -2;
-  return { bonus: bonus, dE: dE };
+// Palette-vs-palette color overlap score.
+// For each source color, find the BEST ΔE2000 match in the candidate palette.
+// Award a bonus per color and report which source colors the candidate "picks up".
+function paletteOverlap(sourcePalette, candPalette) {
+  if (!sourcePalette || !candPalette) return { score: 0, pickedUp: [], avgDE: null };
+  const sourceLabs = sourcePalette.map(h => ({ hex: h, lab: hexToLab(h), name: nameHex(h) }));
+  const candLabs   = candPalette.map(h => ({ hex: h, lab: hexToLab(h) }));
+  if (!sourceLabs.length || !candLabs.length) return { score: 0, pickedUp: [], avgDE: null };
+
+  let total = 0;
+  const pickedUp = [];
+  const allDE = [];
+  for (const s of sourceLabs) {
+    if (!s.lab) continue;
+    let bestDE = Infinity;
+    for (const c of candLabs) {
+      if (!c.lab) continue;
+      const dE = deltaE2000(s.lab, c.lab);
+      if (dE < bestDE) bestDE = dE;
+    }
+    if (!Number.isFinite(bestDE)) continue;
+    allDE.push(bestDE);
+    // perceptual bands → bonus
+    let bonus;
+    if      (bestDE <= 10) bonus = 4;
+    else if (bestDE <= 20) bonus = 2;
+    else if (bestDE <= 35) bonus = 1;
+    else                   bonus = 0;
+    total += bonus;
+    if (bonus > 0 && s.name && !pickedUp.includes(s.name)) pickedUp.push(s.name);
+  }
+  const avgDE = allDE.length ? (allDE.reduce((a, b) => a + b, 0) / allDE.length) : null;
+  return { score: total, pickedUp, avgDE };
 }
 
 function pickPairs(source, candidates, k = 6) {
   const sCls = classify(parseTags(source.tags));
   const sourcePalette = paletteOf(source);
-  const sourceDom = sourcePalette[0] || null;
 
   const scored = candidates.map(c => {
     const cCls = classify(parseTags(c.tags));
     const tagScore = score(sCls, cCls, c.vendor, source.vendor, c.pattern_name, source.pattern_name);
     const candPalette = paletteOf(c);
-    const candDom = candPalette[0] || null;
-    const hex = hexScore(sourceDom, candDom);
-    const totalScore = tagScore.score + hex.bonus;
-
-    const why = tagScore.why.slice();
-    if (hex.dE != null && candDom) {
-      const tag = hex.dE <= 10 ? 'identical color tone'
-                : hex.dE <= 20 ? 'closely related color'
-                : hex.dE <= 35 ? 'same color family'
-                : 'distant tone';
-      why.unshift(`${tag} (ΔE ${hex.dE.toFixed(1)})`);
-    }
+    const motif = detectCoordinateMotif(c.tags);
+    const overlap = paletteOverlap(sourcePalette, candPalette);
+
+    // Strong bias: motif is the whole point of this feature.
+    const motifBonus = motif ? 6 : 0;
+    const totalScore = tagScore.score + overlap.score + motifBonus;
+
+    const why = [];
+    if (motif)            why.push(`Coordinating ${motif}`);
+    if (overlap.pickedUp.length) why.push(`Picks up: ${overlap.pickedUp.slice(0, 4).join(', ')}`);
+    if (overlap.avgDE != null)   why.push(`palette ΔE avg ${overlap.avgDE.toFixed(1)}`);
+    if (tagScore.why.length) why.push(tagScore.why[0]);
 
-    return { ...c, _score: totalScore, _why: why, _palette: candPalette, _dE: hex.dE };
+    return { ...c, _score: totalScore, _why: why, _palette: candPalette, _dE: overlap.avgDE, _motif: motif, _pickedUp: overlap.pickedUp };
   });
 
   scored.sort((a, b) => b._score - a._score);
 
+  // Diversity: aim for variety of stripe TYPES + vendors + colorways.
   const out = [];
   const seenPatterns = new Set();
+  const motifCount = new Map();
   const vendorCount = new Map();
   for (const x of scored) {
     if (out.length >= k) break;
     const pat = (x.pattern_name || x.handle || '').toLowerCase();
     if (pat && seenPatterns.has(pat)) continue;
     const vc = vendorCount.get(x.vendor) || 0;
-    if (vc >= 3) continue;
+    if (vc >= 2) continue;                       // cap at 2 from any vendor
+    const mc = motifCount.get(x._motif) || 0;
+    if (mc >= 4) continue;                        // cap one motif at 4 of 6 so plaids show too
     out.push(x);
     if (pat) seenPatterns.add(pat);
     vendorCount.set(x.vendor, vc + 1);
+    motifCount.set(x._motif, mc + 1);
   }
   return out;
 }

← 72fbd05 Design Coordinate v2: palette + SW/DE paint match per produc  ·  back to Dw Pairs Well  ·  Design Coordinate v4: density slider 3-12 (default 8), 24 ca d2062b0 →