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