# AI 꾸러미 — 손 떨림 걸러내기 (누름 vs 끌기 문턱) — Touch slop (drag threshold)
> 누른 점에서 몇 px(기본 10) 안의 움직임은 누름으로 보고 그 문턱을 넘어야 끌기로 바꿔, 아이 손 · 폰의 떨림 때문에 누름이 끌기로 사라지지 않게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i425

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

## 주문서

### 만들어 줘: 손 떨림 걸러내기 (누름 vs 끌기 문턱) — Touch slop (drag threshold)

#### 1. 목표
옆으로 넘기는 카드 줄에 손 떨림 걸러내기를 넣어 줘 — 누른 점에서 10px 안의 움직임은 누름 후보로 두고, 넘는 순간부터 끌기(넘긴 그 자리 기준이라 튀지 않게). 뗄 때 끌기가 아니면 처음 누른 자리로 누름 판정. 문턱 원 · 손 자취 보여 주기.

#### 2. 핵심 기술 용어
- **Touch slop (drag threshold)** — 누름 vs 끌기 문턱
- **Tap vs drag disambiguation** — 누름과 끌기 가르기
- **EventSystem.pixelDragThreshold (Unity)** — 유니티의 같은 문턱 설정
- **Jitter** — 손 떨림 — 몇 px 흔들림

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- 문턱 기본 10px (2 ~ 30 조절) — 폰은 화면 밀도(devicePixelRatio)를 생각해 CSS px 로
- 문턱을 넘는 순간 끌기 기준점 = 그때 손 자리 (튀지 않게)
- 누름 판정은 처음 누른 자리로
- 한 번 끌기가 되면 다시 누름으로 돌아가지 않기

#### 5. 완성 기준 (이게 보이면 성공)
- 끔: 손 떨림 흉내를 켜고 누르면 줄이 조금 밀리며 「끌기로 판정 — 누름 사라짐」
- 켬: 같은 떨림에도 문턱 원 안이라 「누름 → n번 고름」, 크게 끌면 줄이 넘어간다
- 확대 창(×3)에 손 자취와 문턱 원이 보이고 「최대 움직임 …px (문턱 10px)」

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

## 원리
- 사람 손가락은 누르고 있는 동안 몇 px 씩 떨린다 — 1px 만 움직여도 끌기로 보면 누름이 사라진다.
- 누른 점을 기억하고, 거기서 거리가 문턱(10px)을 넘기 전까지는 누름 후보.
- 처음 문턱을 넘는 순간 끌기로 바꾸고, 끌기의 기준점을 그 순간의 손 자리로 — 그래야 넘는 순간 10px 툭 튀지 않는다.
- 뗄 때 끌기가 아니었다면 처음 누른 자리에서 누름 판정 (떨린 자리 아님).

## 핵심 코드 — 문턱 안은 누름 · 넘는 순간부터 끌기
(발췌: demos/demosInput.ts i425 handle() · up())
```ts
let slop = 10; // 누름 문턱 (px)
let press: { x0: number; y0: number; drag: boolean; ox: number; sx0: number; maxD: number } | null = null;

function down(x: number, y: number): void {
  press = { x0: x, y0: y, drag: false, ox: x, sx0: scrollX, maxD: 0 };
}

function move(x: number, y: number): void {
  if (!press) return;
  const d = Math.hypot(x - press.x0, y - press.y0);
  press.maxD = Math.max(press.maxD, d);
  if (!press.drag && d > slop) {
    press.drag = true;   // 한 번 끌기가 되면 끝까지 끌기
    press.ox = x;        // 기준점 = 넘는 순간의 손 자리 → 10px 툭 튀지 않음
    press.sx0 = scrollX;
  }
  if (press.drag) scrollX = clamp(press.sx0 + (x - press.ox), minScroll, 0);
}

function up(): void {
  const p = press;
  press = null;
  if (!p) return;
  if (!p.drag) {
    const i = tileAt(p.x0); // 떨린 자리가 아니라 처음 누른 자리로
    if (i >= 0) select(i);
  }
}
```

## 흔한 실수 · 확인 목록
- [ ] **1px 만 움직여도 끌기로 보면 폰에서 누름이 자주 사라진다** — 문턱 10px 안은 누름으로.
- [ ] **문턱을 넘는 순간 기준점을 처음 자리로 두면 화면이 10px 툭 튄다** — 넘는 순간의 손 자리를 끌기 기준점으로 삼는다.
- [ ] **안내용 움직임 문턱이 너무 작으면 살짝 누르기만 해도 「막혔어요」 소리가 난다** — 빵빵 주차장은 6px 넘게 움직였을 때만 「막힌 차를 끌려 했다」로 보고 막힌 소리를 낸다 — 누름 · 끌기 문턱과 같은 생각.

## 완성 기준 체크리스트
- [ ] 끔: 손 떨림 흉내를 켜고 누르면 줄이 조금 밀리며 「끌기로 판정 — 누름 사라짐」
- [ ] 켬: 같은 떨림에도 문턱 원 안이라 「누름 → n번 고름」, 크게 끌면 줄이 넘어간다
- [ ] 확대 창(×3)에 손 자취와 문턱 원이 보이고 「최대 움직임 …px (문턱 10px)」

## 이 기술 정보
- id: `i425` · 분류: 게임 시스템 · AI › 입력 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 움직일 때 거리 한 번. 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i425
- 쓰면 좋을 때: 누르기와 끌기가 같은 곳에서 일어나는 모든 화면 (카드 줄 · 판 · 목록) / 아이 · 폰 사용자
- 쓰지 말 때: 그림 그리기 · 선 긋기 — 처음 몇 px 이 사라지면 선이 늦게 시작된다 (대신 문턱을 작게, 넘으면 처음 자리부터 선) / 정밀한 1px 끌기 (자 · 눈금) — 대신 확대 보기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i425 — `src/demos/demosInput.ts:2246`
```ts
const i425 = demo<P425>({
  caption: '떨리는 손으로 누르기 — 끔: 조금만 움직여도 「끌기」라 선택이 사라짐 · 켬: 문턱 원 안이면 누름 (크게 끌면 넘기기)',
  howto: '칸을 눌러 고르거나 옆으로 끌어 넘겨 보세요. 「손 떨림 흉내」를 올리면 누르고 있는 동안 손이 떨립니다.',
  onName: '문턱 안 움직임은 누름으로',
  cycle: 4.6,
  prm: () => ({ slop: 10, jit: 3 }),
  script: (u) => {
    const f = path(u, [
      [0, 150, 312, 0],
      [0.25, 149, 160, 0],
      [0.4, 149, 160, 1],
      [0.8, 149, 160, 0],
      [1.35, 235, 160, 0],
      [1.5, 235, 160, 1],
      [1.9, 235, 160, 0],
      [2.45, 230, 166, 0],
      [2.6, 230, 166, 1],
      [3.5, 110, 160, 1],
      [3.6, 110, 160, 0],
      [4.6, 150, 312, 0],
    ]);
    if (f.down) {
      f.x += jx(u);
      f.y += jy(u);
    }
    return [f];
  },
  create: (env) => {
    const VX = 20;
    const VWd = 260;
    const TY = 116;
    const TH = 88;
    const TW = 76;
    const GAP = 10;
    const NT = 6;
    const minS = -(NT * (TW + GAP) + GAP - VWd);
    const HUES = ['#ff8a5c', '#ffcf4a', '#7ed86a', '#4fc3f7', '#a98bff', '#ff7aa8'];
    let sx = 0;
    let sel = -1;
    let pulse = 0;
    let press: { id: number; x0: number; y0: number; raw: V2; path: V2[]; drag: boolean; ox: number; sx0: number; maxD: number } | null = null;
    let lastPath: V2[] = [];
    let lastStart: V2 | null = null;
    let v: Verdict = { text: '칸을 눌러 골라요', color: COL.dim };
    let ex: string[] = [];
    const thr = (): number => (env.on() ? env.prm.slop : 0.5);
    const tileAt = (x: number): number => {
      const lx = x - VX - sx - GAP;
      const i = Math.floor(lx / (TW + GAP));
      const inT = lx - i * (TW + GAP) <= TW;
      return i >= 0 && i < NT && inT ? i : -1;
    };
    const handle = (x: number, y: number): void => {
      if (!press) return;
      press.path.push({ x, y });
      if (press.path.length > 240) press.path.shift();
      const d = dist(x, y, press.x0, press.y0);
      press.maxD = Math.max(press.maxD, d);
      if (!press.drag && d > thr()) {
        press.drag = true;
        press.ox = env.on() ? x : press.x0;
        press.sx0 = sx;
      }
      if (press.drag) sx = clamp(press.sx0 + (x - press.ox), minS, 0);
      ex = [`최대 움직임 ${press.maxD.toFixed(1)}px (문턱 ${env.on() ? env.prm.slop : 0}px)`, press.drag ? '문턱을 넘어 끌기로 바뀜' : '아직 누름 후보'];
    };
    return {
      reset() {
        sx = 0;
        sel = -1;
        pulse = 0;
        press = null;
        lastPath = [];
        lastStart = null;
        v = { text: '칸을 눌러 골라요', color: COL.dim };
        ex = [];
      },
      down(id, x, y) {
        if (press) return;
        if (y < TY - 10 || y > TY + TH + 10) return;
        press = { id, x0: x, y0: y, raw: { x, y }, path: [{ x, y }], drag: false, ox: x, sx0: sx, maxD: 0 };
        lastStart = { x, y };
        v = { text: '누르는 중…', color: COL.blue };
      },
      move(id, x, y) {
        if (!press || press.id !== id) return;
        press.raw = { x, y };
        if (env.demo() || env.prm.jit <= 0) handle(x, y);
      },
      up(id) {
        if (!press || press.id !== id) return;
        const p = press;
        press = null;
        lastPath = p.path;
        if (!p.drag) {
          const i = tileAt(p.x0);
          if (i >= 0) {
            sel = i;
            pulse = 0.5;
            v = { text: `누름 → ${i + 1}번 고름`, color: COL.on };
          } else v = { text: '누름 (빈 곳)', color: COL.dim };
        } else if (p.maxD < 14) v = { text: '끌기로 판정 — 누름 사라짐', color: COL.off };
        else v = { text: '끌기 → 넘기기', color: COL.blue };
        ex = [`최대 움직임 ${p.maxD.toFixed(1)}px (문턱 ${env.on() ? env.prm.slop : 0}px)`, p.drag ? (p.maxD < 14 ? '손 떨림인데 끌기로 오해' : '일부러 끈 것 — 넘기기') : '문턱 안 — 누름으로 인정'];
      },
      cancel(id) {
        if (press && press.id === id) press = null;
      },
      step(dt) {
        pulse = Math.max(0, pulse - dt);
        if (press && !env.demo() && env.prm.jit > 0) {
          const t = env.now();
          const a = env.prm.jit / 5;
          handle(press.raw.x + jx(t) * a, press.raw.y + jy(t) * a);
        }
      },
      draw(g) {
        const on = env.on();
        txt(g, '고르기 · 옆으로 끌면 넘기기', 150, 92, 11, COL.dim, 'center', 600);
        board(g, VX - 6, TY - 8, VWd + 12, TH + 16);
        g.save();
        rr(g, VX, TY - 4, VWd, TH + 8, 9);
        g.clip();
        for (let i = 0; i < NT; i++) {
          const x = VX + sx + GAP + i * (TW + GAP);
          const isSel = i === sel;
          const sc = isSel && pulse > 0 ? 1 + Math.sin((pulse / 0.5) * Math.PI) * 0.06 : 1;
          g.save();
          g.translate(x + TW / 2, TY + TH / 2);
          g.scale(sc, sc);
          rr(g, -TW / 2, -TH / 2 + 4, TW, TH - 8, 12);
          const gr = g.createLinearGradient(0, -TH / 2, 0, TH / 2);
          gr.addColorStop(0, HUES[i]!);
          gr.addColorStop(1, 'rgba(20,24,50,0.9)');
          g.fillStyle = gr;
          g.fill();
          if (isSel) {
            g.lineWidth = 3;
            g.strokeStyle = COL.gold;
            g.stroke();
          }
          disc(g, 0, -4, 18, 'rgba(255,255,255,0.92)');
          txt(g, String(i + 1), 0, -3, 18, '#1d2346', 'center', 800);
          if (isSel) {
            disc(g, TW / 2 - 12, -TH / 2 + 16, 8, COL.gold);
            g.beginPath();
            g.moveTo(TW / 2 - 16, -TH / 2 + 16);
            g.lineTo(TW / 2 - 13, -TH / 2 + 19);
            g.lineTo(TW / 2 - 8, -TH / 2 + 13);
            g.lineWidth = 2;
            g.strokeStyle = '#3b2a00';
            g.stroke();
          }
          g.restore();
        }
        g.restore();
        // 문턱 원 + 자취
        const start = press ? { x: press.x0, y: press.y0 } : lastStart;
        const pts = press ? press.path : lastPath;
        if (start) {
          if (on) {
            disc(g, start.x, start.y, env.prm.slop, rgba(COL.on, 0.1));
            ring(g, start.x, start.y, env.prm.slop, COL.on, 1.4, [3, 2]);
          } else disc(g, start.x, start.y, 2.5, COL.off);
        }
        // 확대 창 (×5)
        const IX = 92;
        const IY = 226;
        const IW = 116;
        const IH = 66;
        const Z = 3;
        rr(g, IX, IY, IW, IH, 9);
        g.fillStyle = 'rgba(5,8,20,0.75)';
        g.fill();
        g.lineWidth = 1;
        g.strokeStyle = 'rgba(160,180,255,0.25)';
        g.stroke();
        txt(g, `확대 ×${Z}`, IX + 6, IY + 9, 8.5, COL.dim, 'left', 700);
        if (start) {
          g.save();
          rr(g, IX, IY, IW, IH, 9);
          g.clip();
          const cx = IX + IW / 2;
          const cy = IY + IH / 2 + 4;
          if (on) ring(g, cx, cy, env.prm.slop * Z, COL.on, 1.4, [4, 3]);
          else disc(g, cx, cy, 2.5, COL.off);
          if (pts.length > 1) {
            g.beginPath();
            pts.forEach((p, i) => {
              const x = cx + (p.x - start.x) * Z;
              const y = cy + (p.y - start.y) * Z;
              if (i) g.lineTo(x, y);
              else g.moveTo(x, y);
            });
            g.lineWidth = 1.6;
            g.lineJoin = 'round';
            g.strokeStyle = '#ffb35c';
            g.stroke();
          }
          g.restore();
        }
      },
      verdict: () => v,
      extra: () => [...(ex.length ? ex : ['누른 점에서 문턱 거리 안이면 누름']), env.on() ? '문턱을 넘은 순간부터 끌기 (튀지 않게 그 자리 기준)' : '1px만 움직여도 끌기 — 아이 손 · 폰에서 실수'],
    };
  },
  controls: (p) => [
    { type: 'range', label: '누름 문턱 (px)', min: 2, max: 30, step: 1, value: p.slop, on: (v) => (p.slop = v) },
    { type: 'range', label: '손 떨림 흉내 (직접 해 볼 때, px)', min: 0, max: 8, step: 0.5, value: p.jit, on: (v) => (p.jit = v) },
  ],
});
```

## 관련 기술
- 먼저 알면 좋은 기술: [손가락 크기 누름 영역](https://ai-techstudio.web.app/ai/t/i418.md) `i418`
- 다음에 해 볼 기술: [끌기 축 잠금](https://ai-techstudio.web.app/ai/t/i419.md) `i419` · [길게 누르기 · 두 번 누르기 구별](https://ai-techstudio.web.app/ai/t/i421.md) `i421` · [포인터 끌기 · 3D 고르기](https://ai-techstudio.web.app/ai/t/u79.md) `u79`
