← 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">&lsaquo;</button>
        <div class="dots" id="dots"></div>
        <button class="pnav" id="pnext" aria-label="More colors">&rsaquo;</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>