← 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 →