핀치 줌

Pinch to Zoom

두 손가락을 벌리거나 모아서 이미지를 확대·축소하는 터치 제스처. Pointer Events로 두 포인터의 거리 변화를 추적해서 구현합니다.

다른 이름: Two-finger zoom
···
html
<div class="pz" id="pzstage">
  <div class="img" id="pzimg"></div>
  <div class="pzdot a" id="pzdotA"></div>
  <div class="pzdot b" id="pzdotB"></div>
</div>
css
.pz{position:absolute;inset:0;overflow:hidden;touch-action:none;background:var(--bg)}
.img{position:absolute;left:50%;top:50%;width:56%;aspect-ratio:1/1;border-radius:16px;
  background:conic-gradient(from 0deg, var(--accent), var(--accent-2), var(--accent-3), var(--accent));
  transform:translate(-50%,-50%) scale(1)}
.pzdot{position:absolute;left:0;top:0;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;
  background:var(--accent);border:2px solid var(--surface);box-shadow:0 2px 8px rgba(0,0,0,.3);
  pointer-events:none;z-index:30;opacity:0;transition:opacity .2s}
.pzdot.show{opacity:1}
js
const stage=document.getElementById('pzstage');
const img=document.getElementById('pzimg');
const dotA=document.getElementById('pzdotA');
const dotB=document.getElementById('pzdotB');
let auto=true, scale=1;
function applyScale(s){ scale=Math.min(2.4,Math.max(0.5,s)); img.style.transform='translate(-50%,-50%) scale('+scale+')'; }
function hideDots(){ dotA.classList.remove('show'); dotB.classList.remove('show'); }
const pointers=new Map();
let startDist=null, startScale=1;
function dist(p1,p2){ return Math.hypot(p1.x-p2.x,p1.y-p2.y); }
addEventListener('pointerdown',e=>{
  auto=false; hideDots();
  pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
  if(pointers.size===2){ const vals=[...pointers.values()]; startDist=dist(vals[0],vals[1]); startScale=scale; }
});
addEventListener('pointermove',e=>{
  auto=false; hideDots();
  if(!pointers.has(e.pointerId)) return;
  pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
  if(pointers.size===2 && startDist){
    const vals=[...pointers.values()];
    applyScale(startScale*(dist(vals[0],vals[1])/startDist));
  }
});
function clearPointer(e){ pointers.delete(e.pointerId); startDist=null; }
addEventListener('pointerup',clearPointer);
addEventListener('pointercancel',clearPointer);
stage.addEventListener('wheel',e=>{ auto=false; hideDots(); e.preventDefault(); applyScale(scale-e.deltaY*0.0015); },{passive:false});
function wait(ms){ return new Promise(res=>{ const s=performance.now(); (function st(now){ if(!auto) return res(); if(now-s<ms) requestAnimationFrame(st); else res(); })(performance.now()); }); }
async function autoSeq(){
  while(auto){
    const r=stage.getBoundingClientRect();
    const cx=r.left+r.width/2, cy=r.top+r.height/2;
    dotA.classList.add('show'); dotB.classList.add('show');
    const n=30;
    for(let i=0;i<=n;i++){
      if(!auto) break;
      const p=i/n, spread=10+p*70;
      dotA.style.transform='translate('+(cx-spread)+'px,'+cy+'px)';
      dotB.style.transform='translate('+(cx+spread)+'px,'+cy+'px)';
      applyScale(1+p);
      await new Promise(res=>requestAnimationFrame(res));
    }
    if(!auto) break;
    await wait(500); if(!auto) break;
    for(let i=n;i>=0;i--){
      if(!auto) break;
      const p=i/n, spread=10+p*70;
      dotA.style.transform='translate('+(cx-spread)+'px,'+cy+'px)';
      dotB.style.transform='translate('+(cx+spread)+'px,'+cy+'px)';
      applyScale(1+p);
      await new Promise(res=>requestAnimationFrame(res));
    }
    hideDots();
    await wait(700);
  }
}
autoSeq();

`pointerdown`마다 `pointerId`를 키로 좌표를 `Map`에 저장해서 지금 몇 개의 포인터가 눌려 있는지 추적한다. 두 번째 포인터가 내려오는 순간의 두 점 사이 거리(`startDist`)와 그 시점의 배율(`startScale`)을 기준점으로 기억해둔다.

이후 `pointermove`마다 현재 두 점 사이 거리를 다시 재서 `startDist`와의 비율을 구하고, `newScale = startScale * (currentDist / startDist)`로 배율을 갱신한다 — 벌리면 거리가 늘어 확대되고, 모으면 줄어 축소된다.

데스크톱에는 멀티터치가 없으므로 트랙패드/마우스 휠(`wheel` 이벤트의 `deltaY`)로도 같은 배율을 조작할 수 있게 보조 경로를 열어두는 경우가 많다. 배율은 `min/max`로 반드시 clamp해서 무한히 커지거나 뒤집히지 않게 막는다.

언제 쓰나

사진 뷰어, 지도, 상세 이미지를 확대해서 봐야 하는 갤러리에 씁니다. 확대한 뒤에는 팬(드래그 이동)도 함께 지원해야 자연스럽습니다.