# AI 꾸러미 — 카드 더미 넘기기 (끌기 · 문턱 · 날려 보내기) — Swipe card stack (Tinder-style swipe)
> 맨 위 카드를 끄는 만큼 기울고 도장이 비치며, 문턱을 넘겨 놓으면 속도를 이어 날아가고 뒤 카드가 통 올라온다 — 못 넘기면 스프링으로 제자리.  
> 견본: https://ai-techstudio.web.app/#t/i550

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 카드 더미 넘기기 (끌기 · 문턱 · 날려 보내기) — Swipe card stack (Tinder-style swipe)

#### 1. 목표
캐릭터 카드 (좋아요 · 패스)을(를) 좌우로 끌어 넘기는 카드 더미로 만들어 줘 — 끄는 만큼 기울고 도장이 비치고, 문턱을 넘겨 놓으면 날아가며 뒤 카드가 올라오고, 못 넘기면 스프링으로 돌아오게. 분위기는 보라 바탕 · 둥근 카드.

#### 2. 핵심 기술 용어
- **Swipe card stack (Tinder-style swipe)** — 좌우로 넘겨 고르는 카드 더미
- **Pointer events + drag threshold** — 끈 거리(또는 놓을 때 속도)가 문턱을 넘었는지로 결정
- **Fling with release velocity** — 놓는 순간 속도를 이어 받아 날아감
- **Spring back (damped oscillation)** — 문턱 아래면 감쇠 진동으로 제자리

#### 3. 환경
- 플랫폼: TypeScript + HTML · CSS · SVG (DOM), 라이브러리 없이 — 움직임은 transform · opacity 로
- 화면: 브라우저 — PC 와 폰(390px 폭) 모두, 60fps 목표
- pointerdown · pointermove · pointerup 과 setPointerCapture. 카드에 touch-action: none 을 줘야 폰에서 화면이 같이 스크롤되지 않는다.

#### 4. 조건
- 카드는 보이는 3~4장만 만들고, 넘어가면 내용을 갈아 끼울 것 (수백 장을 다 만들지 않는다)
- 문턱은 거리 또는 놓는 속도 둘 중 하나라도 넘으면 넘김 — 빠르게 튕겨도 넘어가게
- 문턱 아래로 놓으면 스프링(감쇠 진동)으로 제자리, 뒤 카드도 같이 내려감
- 폰: touch-action: none, setPointerCapture 로 손가락이 카드 밖으로 나가도 끌기 유지
- 끌기 말고 단추로도 같은 동작

#### 5. 완성 기준 (이게 보이면 성공)
- 끄는 만큼 카드가 기울고, 오른쪽이면 「좋아요」 왼쪽이면 「패스」 도장이 진해진다
- 문턱을 넘겨 놓으면 카드가 그 방향으로 가속하며 날아가고 뒤 카드가 통 올라와 맨 위 자리에 딱 맞는다
- 문턱 전에 놓으면 출렁이며 제자리로 돌아오고 아무 일도 없다
- 문턱 눈금을 켜면 지금 끈 거리 점과 문턱이 보이고, 넘으면 노랗게 바뀐다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 끄는 동안: 카드 위치 = 손가락이 옮겨 간 거리 dx, 회전 = dx × 0.13°(아래쪽 축 기준이라 손잡이처럼 기운다). 도장 투명도 = |dx| ÷ 문턱.
- 놓을 때: |dx| 가 문턱(카드 폭의 절반쯤)을 넘거나 놓는 속도가 빠르면 그 방향으로 날려 보낸다. 날아가는 속도는 놓는 순간 속도를 이어 받아 가속.
- 문턱 아래면 dx = 처음 거리 × e^(−7t) × cos(11t) 로 출렁이며 제자리 — 「아깝다」가 느껴진다.
- 뒤 카드는 등수 r 마다 아래로 9px · 5% 작게. 앞 카드를 끄는 동안 조금(20%) 올라오고, 날아가면 한 칸 올라와 맨 위 자리에 딱 맞는다.

## 핵심 코드 — 끌기 · 문턱 · 날리기 · 스프링 복귀
(발췌: 새로 씀 (demos/demosMotionC.ts i550 의 식을 실제 손 입력에 붙인 꼴))
```ts
const TH = 50;                          // 문턱 (px)
let x0 = 0, dx = 0, lastX = 0, lastT = 0, vx = 0;
card.style.touchAction = 'none';
card.onpointerdown = (e) => { card.setPointerCapture(e.pointerId); x0 = e.clientX; lastX = x0; lastT = e.timeStamp; };
card.onpointermove = (e) => {
  if (!card.hasPointerCapture(e.pointerId)) return;
  dx = e.clientX - x0;
  vx = (e.clientX - lastX) / Math.max(1, e.timeStamp - lastT);  // px/ms
  lastX = e.clientX; lastT = e.timeStamp;
  card.style.transform = 'translateX(' + dx + 'px) rotate(' + dx * 0.13 + 'deg)';
  yes.style.opacity = String(Math.min(1, Math.max(0, dx / TH)));
  no.style.opacity = String(Math.min(1, Math.max(0, -dx / TH)));
};
card.onpointerup = () => {
  const dir = Math.sign(dx || vx);
  if (Math.abs(dx) > TH || Math.abs(vx) > 0.6) {
    // 놓는 속도를 이어 받아 날아감
    card.animate([{ transform: card.style.transform }, { transform: 'translate(' + dir * 500 + 'px, 60px) rotate(' + dir * 40 + 'deg)', opacity: 0 }],
      { duration: 380, easing: 'cubic-bezier(.4,0,1,1)', fill: 'forwards' }).onfinish = nextCard;
  } else {
    // 문턱 아래 — 출렁이며 제자리 (감쇠 진동)
    const a = dx, t0 = performance.now();
    const step = (now: number) => {
      const s = (now - t0) / 1000, x = a * Math.exp(-7 * s) * Math.cos(11 * s);
      card.style.transform = 'translateX(' + x + 'px) rotate(' + x * 0.13 + 'deg)';
      if (s < 0.6) requestAnimationFrame(step); else card.style.transform = '';
    };
    requestAnimationFrame(step);
  }
};
```

## 흔한 실수 · 확인 목록
- [ ] **폰에서 끌면 화면이 같이 스크롤된다** — 카드에 touch-action: none 을 주고 pointer 이벤트를 쓴다. touchmove 의 preventDefault 는 passive 리스너에서 무시된다.
- [ ] **문턱 표시를 카드 아래 두면 카드에 가려 안 보인다** — 견본도 처음엔 문턱 세로선을 카드 뒤에 그렸다가 가려졌다 — 위쪽에 작은 눈금(끈 거리 점 + 문턱 표시)으로 옮겼다.
- [ ] **거리만 보면 빠르게 튕긴 손짓이 안 넘어간다** — 놓기 직전 속도도 함께 본다 (|vx| > 0.6 px/ms 정도).
- [ ] **뒤 카드가 날아간 뒤에야 올라오면 굼뜨다** — 끄는 동안 이미 조금 올라오게(끈 비율 × 20%) — 앞 카드를 놓는 순간 남은 거리만 채운다.

## 완성 기준 체크리스트
- [ ] 끄는 만큼 카드가 기울고, 오른쪽이면 「좋아요」 왼쪽이면 「패스」 도장이 진해진다
- [ ] 문턱을 넘겨 놓으면 카드가 그 방향으로 가속하며 날아가고 뒤 카드가 통 올라와 맨 위 자리에 딱 맞는다
- [ ] 문턱 전에 놓으면 출렁이며 제자리로 돌아오고 아무 일도 없다
- [ ] 문턱 눈금을 켜면 지금 끈 거리 점과 문턱이 보이고, 넘으면 노랗게 바뀐다

## 이 기술 정보
- id: `i550` · 분류: 이펙트 · 연출 › 모션 그래픽 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 카드 4장만 DOM 에 두고 내용을 갈아 끼운다(가상화). 움직임은 transform · opacity 만.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i550
- 쓰면 좋을 때: 하나씩 보고 두 갈래로 고르는 화면 (좋아요/패스, 알아요/몰라요) / 카드 수집 · 뽑기 결과를 한 장씩 넘겨 볼 때
- 쓰지 말 때: 고를 것이 셋 이상이면 — 대신 단추 · 목록 / 접근성이 중요하면 끌기만 두지 말 것 — 같은 일을 하는 단추(✕ · ♥)를 함께

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i550 — `src/demos/demosMotionC.ts:318`
```ts
const i550 = {
  kind: 'dom' as const,
  caption: '끌면 기울고 도장이 비치며, 문턱을 넘겨 놓으면 날아가고 뒤 카드가 통 올라와요 — 못 넘기면 스프링으로 제자리',
  make(box: HTMLElement) {
    const o = { s: 1, line: true };
    const CARDS = [['불꽃 여우', '#ff9a4a', '#ff4f6d'], ['얼음 거북', '#8fe3ff', '#3a8bff'], ['번개 새', '#ffe36b', '#ffa424'], ['숲 곰', '#8be07a', '#2e9e5b'], ['바다 고래', '#7fb2ff', '#5a4bff']];
    const S = stage(
      box,
      `.mc50-gg{position:absolute;left:60px;top:9px;width:160px;height:4px;border-radius:2px;background:rgba(255,255,255,.18)}
      .mc50-tk{position:absolute;top:5px;width:2px;height:12px;margin-left:-1px;border-radius:1px;background:rgba(255,255,255,.5)}
      .mc50-dt{position:absolute;left:0;top:7px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:#fff}
      .mc50-gl{position:absolute;top:6px;font:700 7px ${F};color:rgba(255,255,255,.6)}
      .mc50-c{position:absolute;left:88px;top:26px;width:104px;height:128px;border-radius:12px;background:#fff;box-shadow:0 6px 16px rgba(0,0,0,.28);transform-origin:50% 120%;overflow:hidden;font-family:${F}}
      .mc50-hd{position:absolute;left:0;top:0;right:0;height:72px}
      .mc50-fc{position:absolute;left:32px;top:14px;width:40px;height:40px;border-radius:50%;background:rgba(255,255,255,.85)}
      .mc50-fc i{position:absolute;top:15px;width:5px;height:7px;border-radius:3px;background:#1d2b4f}
      .mc50-nm{position:absolute;left:10px;top:78px;font:800 11px ${F};color:#1d2b4f}
      .mc50-no{position:absolute;right:9px;top:80px;font:700 8px ${F};color:#8a94b0}
      .mc50-bar{position:absolute;left:10px;width:84px;height:5px;border-radius:3px;background:#e6eaf4}
      .mc50-bar i{position:absolute;left:0;top:0;bottom:0;border-radius:3px}
      .mc50-st{position:absolute;top:16px;padding:1px 6px;border:2.5px solid;border-radius:6px;font:900 12px ${F};opacity:0}
      .mc50-y{left:8px;color:#1fbf6a;transform:rotate(-14deg)}
      .mc50-n{right:8px;color:#ff4f6d;transform:rotate(14deg)}
      .mc50-f{position:absolute;left:0;top:0;width:18px;height:18px;margin:-9px;border-radius:50%;background:rgba(255,255,255,.55);border:2px solid #fff;opacity:0;z-index:20;box-sizing:border-box}`,
      '<div class="mc50-g"><div class="mc50-gg"></div><div class="mc50-tk" style="left:90px"></div><div class="mc50-tk" style="left:190px"></div><div class="mc50-gl" style="left:22px">← 패스</div><div class="mc50-gl" style="left:226px">좋아요 →</div><div class="mc50-dt"></div></div>' +
        '<div class="mc50-c"></div><div class="mc50-c"></div><div class="mc50-c"></div><div class="mc50-c"></div><div class="mc50-f"></div>',
      'linear-gradient(160deg,#5b3fd0,#25155e)',
    );
    const slots = [...S.st.querySelectorAll<HTMLElement>('.mc50-c')];
    // 문턱 눈금 — 점이 카드 가운데가 옮겨 간 거리, 눈금이 문턱 (±50)
    const gauge = S.st.querySelector<HTMLElement>('.mc50-g')!;
    const ticks = [...S.st.querySelectorAll<HTMLElement>('.mc50-tk')];
    const gdot = S.st.querySelector<HTMLElement>('.mc50-dt')!;
    const finger = S.st.querySelector<HTMLElement>('.mc50-f')!;
    const ids = slots.map(() => -1);
    const fill = (el: HTMLElement, id: number): void => {
      const c = CARDS[id % CARDS.length]!;
      const a = 30 + ((id * 37) % 60);
      const b = 25 + ((id * 53) % 65);
      el.innerHTML = `<div class="mc50-hd" style="background:linear-gradient(135deg,${c[1]},${c[2]})"><div class="mc50-fc"><i style="left:12px"></i><i style="right:12px"></i></div></div>
        <div class="mc50-nm">${c[0]}</div><div class="mc50-no">No.${id + 1}</div>
        <div class="mc50-bar" style="top:100px"><i style="width:${a}%;background:${c[2]}"></i></div>
        <div class="mc50-bar" style="top:112px"><i style="width:${b}%;background:${c[1]}"></i></div>
        <div class="mc50-st mc50-y">좋아요</div><div class="mc50-st mc50-n">패스</div>`;
    };
    let tt = 0;
    return {
      controls: [speedCtl(o), { type: 'toggle', label: '문턱 눈금 보기', value: true, on: (v: boolean) => (o.line = v) }] as Control[],
      update(_t: number, dt: number) {
        tt += Math.max(0, Math.min(dt, 0.1)) * o.s;
        const P = 1.7;
        const n = Math.floor(tt / P);
        const q = tt - n * P;
        const fail = n % 4 === 3; // 네 번에 한 번은 문턱을 못 넘김
        const top = n - Math.floor(n / 4);
        const dir = Math.floor(n / 2) % 2 ? -1 : 1;
        const TH = 50;
        let dx = 0;
        let dy = 0;
        let op = 1;
        let promote = 0;
        let fin = 0;
        if (q > 0.25 && q <= 0.85) {
          const k = inOut(seg(q, 0.25, 0.85));
          dx = dir * (fail ? 34 : 72) * k;
          dy = -4 * Math.sin(Math.PI * k);
          fin = 1;
        } else if (q > 0.85) {
          const s = q - 0.85;
          if (fail) dx = dir * 34 * Math.exp(-7 * s) * Math.cos(11 * s); // 문턱 아래 — 스프링으로 제자리
          else {
            // 놓는 순간의 속도를 이어 받아 가속하며 날아감
            const k = seg(q, 0.85, 1.3);
            dx = dir * (72 + 300 * k * k);
            dy = 40 * k * k;
            op = 1 - seg(q, 1.1, 1.3);
            promote = outBack(k, 1.4);
          }
        }
        if (!promote) promote = 0.2 * Math.min(1, Math.abs(dx) / 72); // 끄는 동안 뒤 카드도 조금 올라옴
        for (let r = 0; r < 4; r++) {
          const el = slots[r]!;
          const id = top + r;
          if (ids[r] !== id) {
            fill(el, id);
            ids[r] = id;
          }
          el.style.zIndex = String(10 - r);
          const st = el.querySelectorAll<HTMLElement>('.mc50-st');
          if (r === 0) {
            el.style.transform = `translate(${dx.toFixed(2)}px,${dy.toFixed(2)}px) rotate(${(dx * 0.13).toFixed(2)}deg)`;
            el.style.opacity = op.toFixed(3);
            st[0]!.style.opacity = clamp01(dx / TH).toFixed(3);
            st[1]!.style.opacity = clamp01(-dx / TH).toFixed(3);
          } else {
            const eff = r - promote;
            el.style.transform = `translateY(${(9 * eff).toFixed(2)}px) scale(${(1 - 0.05 * eff).toFixed(4)})`;
            el.style.opacity = clamp01(3 - eff).toFixed(3);
            st[0]!.style.opacity = st[1]!.style.opacity = '0';
          }
        }
        const over = Math.abs(dx) >= TH && q <= 0.9;
        gauge.style.display = o.line ? '' : 'none';
        ticks.forEach((k) => (k.style.background = over ? '#ffe36b' : ''));
        const gdx = !fail && q > 0.85 ? 0 : dx; // 날아가는 동안은 눈금에서 빼기
        gdot.style.transform = `translateX(${(140 + Math.max(-80, Math.min(80, gdx))).toFixed(1)}px)`;
        gdot.style.background = over ? '#ffe36b' : '';
        finger.style.opacity = (fin * seg(q, 0.25, 0.32)).toFixed(3);
        finger.style.transform = `translate(${(140 + dx).toFixed(1)}px,${(100 + dy).toFixed(1)}px)`;
      },
      dispose() {
        S.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [스프링 · 감쇠 움직임 (임계 감쇠)](https://ai-techstudio.web.app/ai/t/i293.md) `i293` · [착 붙기 · 관성 (스냅과 던지기)](https://ai-techstudio.web.app/ai/t/i420.md) `i420`
- 다음에 해 볼 기술: [공유 요소 전환 (FLIP · 목록 → 상세)](https://ai-techstudio.web.app/ai/t/i548.md) `i548` · [손 떨림 걸러내기 (누름 vs 끌기 문턱)](https://ai-techstudio.web.app/ai/t/i425.md) `i425`
- 참고 문서: [MDN — Pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) · [MDN — touch-action](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action)
