← back to Dw Marketing Reels
colorwheel/index.html
216 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Designer Wallcoverings — Shop by Color</title>
<style>
:root{ --ink:#1a1a1a; --paper:#faf8f4; --line:#e6e0d6; --gold:#b9892f; }
*{box-sizing:border-box}
body{margin:0;background:radial-gradient(1200px 800px at 50% -10%, #fff, var(--paper));
color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;
min-height:100vh}
header{text-align:center;padding:34px 16px 8px}
header h1{margin:0;font-weight:300;letter-spacing:.18em;text-transform:uppercase;font-size:22px}
header p{margin:6px 0 0;color:#7a7266;font-size:13px;letter-spacing:.04em}
.stage{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:22px 16px 8px}
.wheelwrap{position:relative;width:340px;height:340px;flex:0 0 auto}
/* the spin layer carries BOTH the wheel and the selector ring, so the ring
rotates WITH the wheel and always sits over its true color */
.spin{position:absolute;inset:0;animation:spin 40s linear infinite}
.wheelwrap.dragging .spin{animation-play-state:paused}
#wheel{width:100%;height:100%;border-radius:50%;cursor:crosshair;
box-shadow:0 10px 40px rgba(0,0,0,.18), inset 0 0 0 1px rgba(0,0,0,.06)}
@keyframes spin{to{transform:rotate(360deg)}}
#pick{position:absolute;width:34px;height:34px;border-radius:50%;border:3px solid #fff;
box-shadow:0 2px 10px rgba(0,0,0,.35), 0 0 0 1px rgba(0,0,0,.25);transform:translate(-50%,-50%);
left:50%;top:14%;pointer-events:none;transition:box-shadow .2s}
.hub{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:118px;height:118px;
border-radius:50%;background:#fff;box-shadow:0 6px 22px rgba(0,0,0,.16), inset 0 0 0 1px var(--line);
display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center}
/* the center dot IS the selected color — the wheel's readout */
.hub .sw{width:78px;height:78px;border-radius:50%;box-shadow:inset 0 0 0 1px rgba(0,0,0,.12);
transition:background .2s;background:#8aa}
.side{max-width:300px}
.side h2{font-weight:400;font-size:15px;letter-spacing:.06em;text-transform:uppercase;margin:0 0 8px}
.side p{color:#7a7266;font-size:13px;line-height:1.6;margin:0 0 14px}
/* 6 color dots below the wheel, paged left/right */
.wheelcol{display:flex;flex-direction:column;align-items:center;gap:16px}
.palette{display:flex;align-items:center;justify-content:center;gap:14px}
.pnav{border:1px solid var(--line);background:#fff;color:var(--ink);width:32px;height:32px;
border-radius:50%;font-size:20px;line-height:1;cursor:pointer;display:flex;align-items:center;
justify-content:center;box-shadow:0 1px 4px rgba(0,0,0,.08);transition:transform .12s,box-shadow .12s}
.pnav:hover{transform:translateY(-2px);box-shadow:0 5px 14px rgba(0,0,0,.16)}
.pnav:disabled{opacity:.28;cursor:default;transform:none;box-shadow:none}
.dots{display:flex;gap:14px}
.pdot{width:34px;height:34px;border-radius:50%;cursor:pointer;
box-shadow:inset 0 0 0 1px rgba(0,0,0,.14),0 1px 4px rgba(0,0,0,.12);
transition:transform .14s,box-shadow .14s}
.pdot:hover{transform:translateY(-3px) scale(1.1)}
.pdot.on{box-shadow:0 0 0 3px var(--gold),inset 0 0 0 1px rgba(0,0,0,.14)}
.grid{max-width:1180px;margin:16px auto 60px;padding:0 18px;
display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:18px}
.card{position:relative;text-decoration:none;color:inherit;background:#fff;border:1px solid var(--line);
border-radius:12px;overflow:hidden;opacity:0;transform:translateY(14px) scale(.98);
animation:pop .5s forwards;box-shadow:0 1px 0 rgba(0,0,0,.03)}
@keyframes pop{to{opacity:1;transform:none}}
.card:hover{box-shadow:0 12px 30px rgba(0,0,0,.12)}
.card .im{aspect-ratio:1/1;width:100%;object-fit:cover;display:block;background:#f2efe9}
.card .cap{padding:10px 12px;font-size:12.5px;line-height:1.35}
.card .cap i{display:block;color:#9a9276;font-style:normal;font-size:11px;margin-top:3px}
.card .dot{position:absolute;top:8px;right:8px;width:14px;height:14px;border-radius:50%;
box-shadow:0 0 0 2px #fff, inset 0 0 0 1px rgba(0,0,0,.15)}
.empty{grid-column:1/-1;text-align:center;color:#9a9276;padding:40px;font-size:14px}
.loading{grid-column:1/-1;text-align:center;color:var(--gold);padding:40px;font-size:13px;letter-spacing:.1em}
.loading:after{content:'';display:inline-block;width:0;overflow:hidden;vertical-align:bottom;
animation:dots 1.2s steps(4,end) infinite}
@keyframes dots{to{width:1.2em}}
</style>
</head>
<body>
<header>
<h1>Shop by Color</h1>
<p>Spin the wheel · click a hue · discover every wallcovering in that color</p>
</header>
<div class="stage">
<div class="wheelcol">
<div class="wheelwrap" id="ww">
<div class="spin" id="spin">
<canvas id="wheel" width="680" height="680"></canvas>
<div id="pick"></div>
</div>
<div class="hub">
<div class="sw" id="sw"></div>
</div>
</div>
<!-- 6 curated color dots, paginated left/right through the full palette -->
<div class="palette">
<button class="pnav" id="pprev" aria-label="Previous colors">‹</button>
<div class="dots" id="dots"></div>
<button class="pnav" id="pnext" aria-label="More colors">›</button>
</div>
</div>
<div class="side">
<h2>Curated by hue</h2>
<p>Every color on the wheel maps to the actual dominant colors extracted from our
full catalog. Pick a shade and we surface the closest perceptual matches — the
same color intelligence behind each product page.</p>
</div>
</div>
<div class="grid" id="grid"></div>
<script>
const cv=document.getElementById('wheel'), ctx=cv.getContext('2d'), R=cv.width/2;
// paint the hue/saturation wheel (angle=hue, radius=saturation, fixed lightness)
(function paint(){
const img=ctx.createImageData(cv.width,cv.height), d=img.data;
for(let y=0;y<cv.height;y++)for(let x=0;x<cv.width;x++){
const dx=x-R,dy=y-R,r=Math.sqrt(dx*dx+dy*dy),i=(y*cv.width+x)*4;
if(r>R){d[i+3]=0;continue;}
let h=Math.atan2(dy,dx)*180/Math.PI; if(h<0)h+=360;
const s=Math.min(1,r/R), l=0.5;
const [rr,gg,bb]=hsl2rgb(h,s,l);
d[i]=rr;d[i+1]=gg;d[i+2]=bb;d[i+3]=255;
}
ctx.putImageData(img,0,0);
})();
function hsl2rgb(h,s,l){h/=360;let r,g,b;if(s===0){r=g=b=l;}else{
const q=l<.5?l*(1+s):l+s-l*s,p=2*l-q;const t=(a)=>{if(a<0)a+=1;if(a>1)a-=1;
if(a<1/6)return p+(q-p)*6*a;if(a<1/2)return q;if(a<2/3)return p+(q-p)*(2/3-a)*6;return p;};
r=t(h+1/3);g=t(h);b=t(h-1/3);}return[Math.round(r*255),Math.round(g*255),Math.round(b*255)];}
function rgbHex(r,g,b){return '#'+[r,g,b].map(x=>x.toString(16).padStart(2,'0')).join('');}
const ww=document.getElementById('ww'), spin=document.getElementById('spin'),
pick=document.getElementById('pick'), sw=document.getElementById('sw'),
grid=document.getElementById('grid');
let curHue=140, curSat=0.45;
function place(hue,sat){ // ring lives INSIDE the spin layer at the wheel-local angle,
const ang=hue*Math.PI/180, rad=sat*48; // so it rides over its own color as the wheel turns
pick.style.left=(50+Math.cos(ang)*rad)+'%';
pick.style.top=(50+Math.sin(ang)*rad)+'%';
const [r,g,b]=hsl2rgb(hue,sat,0.5), hx=rgbHex(r,g,b);
pick.style.background=hx; sw.style.background=hx; // the center dot becomes the picked color
return hx;
}
function spinDeg(){ // read the wheel's live rotation from its CSS transform matrix
const tr=getComputedStyle(spin).transform;
if(!tr||tr==='none') return 0;
const m=new DOMMatrixReadOnly(tr);
let d=Math.atan2(m.b,m.a)*180/Math.PI; if(d<0)d+=360; return d;
}
function fromEvent(e){
const rect=cv.getBoundingClientRect();
const cx=e.touches?e.touches[0].clientX:e.clientX, cy=e.touches?e.touches[0].clientY:e.clientY;
const dx=cx-(rect.left+rect.width/2), dy=cy-(rect.top+rect.height/2);
let scr=Math.atan2(dy,dx)*180/Math.PI; if(scr<0)scr+=360;
const h=(scr-spinDeg()+360)%360; // undo the spin → the TRUE hue under the pointer
const s=Math.min(1,Math.sqrt(dx*dx+dy*dy)/(rect.width/2));
curHue=h; curSat=Math.max(.12,s); return place(curHue,curSat);
}
let t=null;
function selected(hx){ clearTimeout(t); t=setTimeout(()=>load(hx),120); }
ww.addEventListener('pointerdown',e=>{ww.classList.add('dragging');const hx=fromEvent(e);selected(hx);
const mv=ev=>selected(fromEvent(ev)); const up=()=>{ww.classList.remove('dragging');
window.removeEventListener('pointermove',mv);window.removeEventListener('pointerup',up);};
window.addEventListener('pointermove',mv);window.addEventListener('pointerup',up);});
// curated palette — every entry is [hue,sat] rendered at the wheel's own lightness
// (0.5) so a dot's color exactly equals the wheel/swatch color it selects. Shown
// 6 at a time; the ‹ › arrows page through the whole set.
const PALETTE=[
[0,.62],[22,.62],[42,.6],[60,.58],[85,.55],[110,.55],
[140,.55],[165,.55],[190,.58],[210,.6],[232,.6],[255,.58],
[280,.56],[300,.56],[322,.58],[345,.6],[10,.34],[200,.34],
[320,.3],[45,.14],[150,.14],[220,.14],[0,.06],[0,0]
];
const PAGE=6, dotsEl=document.getElementById('dots'),
prevB=document.getElementById('pprev'), nextB=document.getElementById('pnext');
let pStart=0, activeDot=-1;
function renderDots(){
dotsEl.innerHTML='';
PALETTE.slice(pStart,pStart+PAGE).forEach(([h,s],k)=>{
const idx=pStart+k, el=document.createElement('div'); el.className='pdot';
const [r,g,b]=hsl2rgb(h,s,0.5), hx=rgbHex(r,g,b);
el.style.background=hx; el.title=hx.toUpperCase();
if(idx===activeDot) el.classList.add('on');
el.onclick=()=>{activeDot=idx;curHue=h;curSat=Math.max(.12,s);place(h,s);load(hx);renderDots();};
dotsEl.appendChild(el);
});
prevB.disabled = pStart<=0;
nextB.disabled = pStart+PAGE>=PALETTE.length;
}
prevB.onclick=()=>{pStart=Math.max(0,pStart-PAGE);renderDots();};
nextB.onclick=()=>{pStart=Math.min(Math.max(0,PALETTE.length-PAGE),pStart+PAGE);renderDots();};
renderDots();
async function load(hex){
grid.innerHTML='<div class="loading">Finding wallcoverings in this color</div>';
try{
const r=await fetch('/api/colors',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({hex,ceiling:15})}); // widen to a ~15% color-variation band
const j=await r.json(); const res=(j.results||[]);
if(!res.length){grid.innerHTML='<div class="empty">No close matches for this shade yet — try a neighboring hue.</div>';return;}
grid.innerHTML='';
res.forEach((p,i)=>{
if(!p.handle||!p.image)return;
const a=document.createElement('a');a.className='card';a.href='https://designerwallcoverings.com/products/'+encodeURIComponent(p.handle);
a.target='_blank';a.rel='noopener';a.style.animationDelay=Math.min(i*12,700)+'ms';
const im=document.createElement('img');im.className='im';im.loading='lazy';im.src=p.image;
im.onerror=()=>a.remove();
const dot=document.createElement('span');dot.className='dot';dot.style.background=p.hex||hex;
const cap=document.createElement('div');cap.className='cap';
cap.textContent=(p.title||'').replace(/\s*\|\s*.*$/,'');
if(p.vendor){const v=document.createElement('i');v.textContent=p.vendor;cap.appendChild(v);}
a.append(im,dot,cap);grid.appendChild(a);
});
}catch(e){grid.innerHTML='<div class="empty">Could not load right now. '+e.message+'</div>';}
}
// initial
const hx0=place(curHue,curSat); load(hx0);
</script>
</body>
</html>