[object Object]

← back to CelebritySignatures

wear: hover-to-zoom 3x lens on the signature only (TK-10286)

764899138d0f6e16b01aefd9102a40f8ea359d6e · 2026-09-09 09:18:51 -0700 · Steve Abrams

Only activates over the printed signature (not the whole garment), and
repaints fresh from the source photo + signature images at the zoomed
resolution instead of CSS-scaling the already-rendered canvas (which
just stretched existing pixels and looked blurry). Zoom level adapts
per garment box so wide signatures never clip out of the lens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmxcNKtFg77wP47uZq5Zsm

Files touched

Diff

commit 764899138d0f6e16b01aefd9102a40f8ea359d6e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 9 09:18:51 2026 -0700

    wear: hover-to-zoom 3x lens on the signature only (TK-10286)
    
    Only activates over the printed signature (not the whole garment), and
    repaints fresh from the source photo + signature images at the zoomed
    resolution instead of CSS-scaling the already-rendered canvas (which
    just stretched existing pixels and looked blurry). Zoom level adapts
    per garment box so wide signatures never clip out of the lens.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01AmxcNKtFg77wP47uZq5Zsm
---
 public/wear.html | 135 +++++++++++++++++++++++++++++++++++++++++++++----------
 1 file changed, 111 insertions(+), 24 deletions(-)

diff --git a/public/wear.html b/public/wear.html
index deaa85a..a5ce0f3 100644
--- a/public/wear.html
+++ b/public/wear.html
@@ -58,7 +58,11 @@
   .scrim.on { display:flex; }
   .panel { background:#fff; border-radius:16px; max-width:860px; width:100%; max-height:92vh; overflow:auto; display:grid; grid-template-columns:1fr 1fr; }
   @media (max-width:720px){ .panel{ grid-template-columns:1fr; } .grid{ } }
-  .preview { background:#efece5; display:flex; align-items:center; justify-content:center; padding:22px; border-radius:16px 0 0 16px; position:relative; }
+  .preview { background:#efece5; display:flex; align-items:center; justify-content:center; padding:22px; border-radius:16px 0 0 16px; position:relative; overflow:hidden; }
+  canvas#stage { cursor: zoom-in; }
+  #zoomLens { display:none; position:absolute; width:220px; height:220px; border-radius:12px; border:2px solid #fff; box-shadow:0 8px 24px rgba(0,0,0,.28); pointer-events:none; z-index:5; background:#efece5; }
+  .zoom-hint { position:absolute; bottom:14px; left:14px; font-size:11px; color:#6b6b6b; background:rgba(255,255,255,.85); border-radius:999px; padding:4px 10px; pointer-events:none; transition:opacity .15s; }
+  .zoom-hint.hide { opacity:0; }
   #stage3d { width:100%; max-width:340px; aspect-ratio:1/1; cursor:grab; }
   #stage3d:active { cursor:grabbing; }
   #stage3d canvas { width:100%!important; height:100%!important; display:block; border-radius:8px; }
@@ -127,6 +131,8 @@
     <div class="preview">
       <canvas id="stage" width="400" height="480"></canvas>
       <div id="stage3d" style="display:none"></div>
+      <canvas id="zoomLens"></canvas>
+      <span class="zoom-hint" id="zoomHint">🔍 hover the signature to zoom</span>
       <button class="view3d-btn" id="view3dToggle" hidden>View in 3D</button>
     </div>
     <div class="buy">
@@ -439,6 +445,34 @@ function loadPhoto(src, cb){
   if (!img.src) img.src = src;
 }
 
+// Composites the signature onto ctx for every overlay box on garment g,
+// color col — shared by the main draw() (tx=0,ty=0,s=1) and the zoom lens
+// (tx/ty = -crop origin * s, s = zoom factor) so the lens paints FRESH from
+// the original source images at the zoomed target size instead of CSS-
+// stretching an already-rasterized bitmap (which is what made the first
+// hover-zoom pass blurry — canvas is a raster surface, so scaling the
+// finished bitmap via CSS transform just magnifies its existing pixels).
+function paintSignatures(ctx, g, col, tx, ty, s){
+  if (!(sigImg && sigImg.width)) return;
+  for (const ob of g.overlayBoxes || [g.overlayBox]) {
+    if (!ob) continue;
+    const pad = ob.w * 0.08, bw = ob.w - 2*pad, bh = ob.h - 2*pad;
+    const ar = sigImg.width/sigImg.height; let w=bw, h=w/ar; if(h>bh){ h=bh; w=h*ar; }
+    const x = ob.x + (ob.w-w)/2, y = ob.y + (ob.h-h)/2;
+    const cx = ob.x + ob.w/2, cy = ob.y + ob.h/2;
+    ctx.save();
+    ctx.translate(tx, ty); ctx.scale(s, s);
+    if (ob.rotation) { ctx.translate(cx, cy); ctx.rotate(ob.rotation * Math.PI / 180); ctx.translate(-cx, -cy); }
+    if (luminance(col.hex) < 0.4){
+      ctx.drawImage(recolorToLight(sigImg), x, y, w, h);
+    } else {
+      ctx.globalCompositeOperation='multiply';
+      ctx.drawImage(sigImg, x, y, w, h);
+    }
+    ctx.restore();
+  }
+}
+
 function draw(){
   const cv = document.getElementById('stage'), ctx = cv.getContext('2d');
   const g = state.garment, col = state.color;
@@ -456,34 +490,87 @@ function draw(){
     if (state.garment !== g || state.color !== col) return;
     ctx.clearRect(0, 0, cv.width, cv.height);
     if (photoImg) ctx.drawImage(photoImg, 0, 0, cv.width, cv.height);
-    // signature into the overlay box, contain-fit, against the REAL product
-    // photo. Light cloth: 'multiply' so the dark ink reads naturally against
-    // the photo (fabric shading still shows through, same as a real print).
+    // Light cloth: 'multiply' so the dark ink reads naturally against the
+    // photo (fabric shading still shows through, same as a real print).
     // Dark cloth: multiply would render invisible (it can only darken), so
     // recolor the signature to a light ink and composite normally instead.
-    for (const ob of g.overlayBoxes || [g.overlayBox]) {
-    if (ob && sigImg && sigImg.width){
-      const pad = ob.w * 0.08, bw = ob.w - 2*pad, bh = ob.h - 2*pad;
-      const ar = sigImg.width/sigImg.height; let w=bw, h=w/ar; if(h>bh){ h=bh; w=h*ar; }
-      const x = ob.x + (ob.w-w)/2, y = ob.y + (ob.h-h)/2;
-      ctx.save();
-      if (ob.rotation) {
-        ctx.translate(ob.x + ob.w/2, ob.y + ob.h/2);
-        ctx.rotate(ob.rotation * Math.PI / 180);
-        ctx.translate(-(ob.x + ob.w/2), -(ob.y + ob.h/2));
-      }
-      if (luminance(col.hex) < 0.4){
-        ctx.drawImage(recolorToLight(sigImg), x, y, w, h);
-      } else {
-        ctx.globalCompositeOperation='multiply';
-        ctx.drawImage(sigImg, x, y, w, h);
-      }
-      ctx.restore();
-    }
-    }
+    paintSignatures(ctx, g, col, 0, 0, 1);
   });
 }
 
+// Hover 3x magnifier LENS on the signature only (2026-09-09, v2 — the first
+// pass CSS-transform-scaled the whole already-rendered canvas, which just
+// blows up its existing pixels and looks blurry). This version only
+// activates when the cursor is actually over the printed signature (not the
+// whole garment photo), and repaints a small lens canvas FRESH from the
+// original photo + signature source images at the zoomed resolution, so it
+// stays as crisp as those source images allow instead of stretching a
+// bitmap. Lens backing store is 2x its CSS size for extra sharpness on
+// retina displays.
+(function initStageZoom(){
+  const cv = document.getElementById('stage');
+  const hint = document.getElementById('zoomHint');
+  const lens = document.getElementById('zoomLens');
+  const lctx = lens.getContext('2d');
+  const MAX_ZOOM = 3, LENS_CSS = 220, LENS_BACK = LENS_CSS * 2;
+  lens.width = LENS_BACK; lens.height = LENS_BACK;
+
+  function overlayHitRect(cvRect){
+    // Screen-space rect(s) of the garment's signature overlay box(es),
+    // padded generously so the zoom is easy to trigger without pixel-hunting.
+    const g = state.garment; if (!g) return [];
+    const scaleX = cvRect.width / cv.width, scaleY = cvRect.height / cv.height;
+    return (g.overlayBoxes || [g.overlayBox]).filter(Boolean).map(ob => {
+      const padX = ob.w * 0.5, padY = ob.h * 0.9;
+      return {
+        left: cvRect.left + (ob.x - padX) * scaleX, top: cvRect.top + (ob.y - padY) * scaleY,
+        right: cvRect.left + (ob.x + ob.w + padX) * scaleX, bottom: cvRect.top + (ob.y + ob.h + padY) * scaleY,
+        ob,
+      };
+    });
+  }
+
+  function paintLens(ob){
+    // Locks the crop to the SIGNATURE's own center (not the raw cursor
+    // position) — once you're hovering anywhere in its hit-zone, the lens
+    // always shows the full signature centered, never clipped at an edge.
+    const g = state.garment, col = state.color;
+    const photoImg = photoCache[col.photo];
+    lctx.clearRect(0, 0, lens.width, lens.height);
+    lctx.imageSmoothingEnabled = true; lctx.imageSmoothingQuality = 'high';
+    // Zoom as much as MAX_ZOOM allows without clipping the box (padded 15%)
+    // out of frame — a wide signature on a narrow box gets a lower zoom so
+    // the whole thing stays visible instead of running off the lens edge.
+    const zoom = Math.min(MAX_ZOOM, lens.width / (ob.w * 1.15), lens.height / (ob.h * 1.15));
+    const sampleW = lens.width / zoom, sampleH = lens.height / zoom;
+    let sx = (ob.x + ob.w/2) - sampleW/2, sy = (ob.y + ob.h/2) - sampleH/2;
+    sx = Math.max(0, Math.min(cv.width - sampleW, sx));
+    sy = Math.max(0, Math.min(cv.height - sampleH, sy));
+    if (photoImg && photoImg.complete) lctx.drawImage(photoImg, sx, sy, sampleW, sampleH, 0, 0, lens.width, lens.height);
+    paintSignatures(lctx, g, col, -sx * zoom, -sy * zoom, zoom);
+  }
+
+  function onMove(e){
+    if (in3DMode) { hide(); return; }
+    const cvRect = cv.getBoundingClientRect();
+    const hits = overlayHitRect(cvRect);
+    const hit = hits.find(h => e.clientX >= h.left && e.clientX <= h.right && e.clientY >= h.top && e.clientY <= h.bottom);
+    if (!hit) { hide(); return; }
+    if (hint) hint.classList.add('hide');
+    paintLens(hit.ob);
+    const prect = cv.closest('.preview').getBoundingClientRect();
+    let lx = e.clientX - prect.left - LENS_CSS/2, ly = e.clientY - prect.top - LENS_CSS - 16;
+    lx = Math.max(4, Math.min(prect.width - LENS_CSS - 4, lx));
+    ly = Math.max(4, ly < 4 ? e.clientY - prect.top + 16 : ly);
+    lens.style.left = lx + 'px'; lens.style.top = ly + 'px';
+    lens.style.display = 'block';
+  }
+  function hide(){ lens.style.display = 'none'; if (hint) hint.classList.remove('hide'); }
+
+  cv.addEventListener('mousemove', onMove);
+  cv.addEventListener('mouseleave', hide);
+})();
+
 document.getElementById('sort').onchange = e => { localStorage.setItem('wear_sort', e.target.value); render(); };
 document.getElementById('density').oninput = e => { document.documentElement.style.setProperty('--cols', e.target.value); localStorage.setItem('wear_cols', e.target.value); };
 document.getElementById('close').onclick = ()=>document.getElementById('scrim').classList.remove('on');

← e546ddf Center the product preview text and options  ·  back to CelebritySignatures  ·  wear: re-center trucker-cap signature overlay (TK-10286) a15a0cd →