# AI 꾸러미 — 손가락 크기 누름 영역 — Touch target expansion (hit slop)
> 보이는 단추보다 누름 영역을 넓히고 그 안에서 가장 가까운 단추 하나를 골라, 작은 칸이 많은 폰 화면에서도 손가락이 놓치지 않게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i418

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

## 주문서

### 만들어 줘: 손가락 크기 누름 영역 — Touch target expansion (hit slop)

#### 1. 목표
작은 칸이 많은 폰 퍼즐의 누름 판정을 넓혀 줘 — 보이는 단추 반지름 11px 에 누름 영역을 +14px 더하고, 누른 점에서 가장 가까운 단추 하나만 고르기. 넓힌 영역은 점선 테두리로 보이게 (설명용).

#### 2. 핵심 기술 용어
- **Touch target expansion (hit slop)** — 누름 영역 넓히기
- **Nearest-target picking** — 가장 가까운 것 고르기
- **Minimum touch target size (44pt · 48dp)** — 권장 최소 누름 크기
- **Fitts's law** — 목표가 작고 멀수록 누르기 어렵다

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

#### 4. 조건
- 판정은 「가장 가까운 것 하나」 + 「거리 ≤ 반지름 + pad」 — 사각형 겹침 판정 아님
- 누른 순간(down)에 고르고, 뗄 때(up) 확정
- 넓힌 영역 크기를 조절할 수 있게 (0 ~ 30px, 기본 14)
- 놓쳤을 때 ✕ 를 누른 자리에 잠깐 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 끔: 단추 그림 밖을 누르면 ✕ 「놓침 — 단추 밖」
- 켬: 단추 둘레 점선 안을 누르면 가장 가까운 단추가 잡히고, 누른 점에서 단추로 점선이 이어진다
- 「가장 가까운 단추 가운데까지 …px · 누름 영역 반지름 25px」 숫자가 보인다

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

## 원리
- 손가락이 누르는 넓이는 지름 1cm 쯤 — 반지름 11px 단추는 화면에서 너무 작다.
- 누른 점에서 모든 단추 가운데까지 거리를 재고 가장 가까운 하나를 고른다.
- 그 거리가 「보이는 반지름 + 넓힌 만큼(pad)」 안이면 잡음, 아니면 놓침.
- 단추 사이가 가까우면 영역이 겹쳐도 「가장 가까운 것 하나」라 둘이 같이 눌리지 않는다.

## 핵심 코드 — 가장 가까운 단추 + 넓힌 반지름 판정
(발췌: demos/demosInput.ts i418 pick() · down · up)
```ts
const R = 11;   // 보이는 단추 반지름
let pad = 14;  // 넓힌 만큼
const buttons: { x: number; y: number }[] = [];

function pick(x: number, y: number): { i: number; d: number } {
  let best = -1;
  let bd = 1e9;
  buttons.forEach((b, i) => {
    const d = Math.hypot(x - b.x, y - b.y);
    if (d < bd) {
      bd = d;
      best = i;
    }
  });
  return { i: bd <= R + pad ? best : -1, d: bd }; // 영역 밖이면 놓침
}

let pressed = -1;
canvas.addEventListener('pointerdown', (e) => {
  const p = toCanvas(e); // 캔버스 좌표로
  pressed = pick(p.x, p.y).i;
});
canvas.addEventListener('pointerup', (e) => {
  const p = toCanvas(e);
  if (pressed >= 0) toggle(pressed);
  else showMiss(p.x, p.y); // 누른 자리에 ✕
  pressed = -1;
});
```

## 흔한 실수 · 확인 목록
- [ ] **영역을 넓혀 사각형 겹침으로 판정하면 두 단추가 함께 눌린다** — 가장 가까운 하나만 고른다.
- [ ] **넓힌 영역이 보이지 않아 사용자가 「엉뚱한 게 눌렸다」고 느낀다** — 누를 때 잡힌 단추를 바로 빛내 어느 것이 골라졌는지 보여 준다.
- [ ] **폰 화면 크기에서 확인하지 않으면 PC 에선 괜찮던 단추가 폰에서 너무 작다** — 가로 폰 844×390 에서 단추 · 글씨 크기를 꼭 확인한다 (이 사이트 규칙).

## 완성 기준 체크리스트
- [ ] 끔: 단추 그림 밖을 누르면 ✕ 「놓침 — 단추 밖」
- [ ] 켬: 단추 둘레 점선 안을 누르면 가장 가까운 단추가 잡히고, 누른 점에서 단추로 점선이 이어진다
- [ ] 「가장 가까운 단추 가운데까지 …px · 누름 영역 반지름 25px」 숫자가 보인다

## 이 기술 정보
- id: `i418` · 분류: 게임 시스템 · AI › 입력 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 누를 때 단추 수만큼 거리 계산. 단추 수백 개도 순식간.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i418
- 쓰면 좋을 때: 폰에서 칸 · 점이 작아 자꾸 빗나갈 때 / 어린 아이가 하는 게임
- 쓰지 말 때: 단추가 서로 아주 붙어 있고 둘 다 위험한 동작(지우기 · 확인) — 대신 단추 자체를 크게, 간격을 넓게 / 넓힌 영역이 판 밖 다른 단추와 겹칠 때 — 영역을 판 안으로 자르기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i418 — `src/demos/demosInput.ts:679`
```ts
const i418 = demo<P418>({
  caption: '작은 단추 사이를 눌러도 — 끔: 놓침 ✕ · 켬: 넓힌 누름 영역(점선)에서 가장 가까운 단추가 잡힘',
  howto: '작은 단추 둘레(점선 테두리 안)를 눌러 보세요. 끄면 단추 그림 안을 정확히 눌러야만 잡힙니다.',
  onName: '넓힌 누름 영역',
  cycle: 4.4,
  prm: () => ({ pad: 14, rings: true }),
  script: (u) =>
    [
      path(u, [
        [0, 210, 318, 0],
        [0.45, 133, 129, 0],
        [0.7, 133, 129, 1],
        [0.9, 133, 129, 0],
        [1.55, 176, 238, 0],
        [1.8, 176, 238, 1],
        [2.0, 176, 238, 0],
        [2.65, 257, 64, 0],
        [2.9, 257, 64, 1],
        [3.1, 257, 64, 0],
        [4.4, 210, 318, 0],
      ]),
    ],
  create: (env) => {
    const R = 11;
    let sel = new Set<number>();
    let pulse: { i: number; a: number }[] = [];
    let miss: { x: number; y: number; a: number }[] = [];
    let link: { x: number; y: number; i: number; a: number } | null = null;
    let pressed = -1;
    let pid = -1;
    let v: Verdict = { text: '단추를 눌러 보세요', color: COL.dim };
    let ex: string[] = [];
    const reach = (): number => (env.on() ? R + env.prm.pad : R);
    const pick = (x: number, y: number): { i: number; d: number } => {
      let best = -1;
      let bd = 1e9;
      B418.forEach((b, i) => {
        const d = dist(x, y, b.x, b.y);
        if (d < bd) {
          bd = d;
          best = i;
        }
      });
      return { i: bd <= reach() ? best : -1, d: bd };
    };
    return {
      reset() {
        sel = new Set();
        pulse = [];
        miss = [];
        link = null;
        pressed = -1;
        pid = -1;
        v = { text: '단추를 눌러 보세요', color: COL.dim };
        ex = [];
      },
      down(id, x, y) {
        if (pid !== -1) return;
        pid = id;
        const r = pick(x, y);
        pressed = r.i;
        ex = [`가장 가까운 단추 가운데까지 ${Math.round(r.d)}px`, `보이는 단추 반지름 ${R}px → 누름 영역 반지름 ${reach()}px`];
      },
      move() {},
      up(id, x, y) {
        if (id !== pid) return;
        pid = -1;
        if (pressed >= 0) {
          if (sel.has(pressed)) sel.delete(pressed);
          else sel.add(pressed);
          pulse.push({ i: pressed, a: 0 });
          link = { x, y, i: pressed, a: 0 };
          v = { text: `잡음 · ${pressed + 1}번 단추`, color: COL.on };
        } else {
          miss.push({ x, y, a: 0 });
          v = { text: '놓침 — 단추 밖', color: COL.off };
        }
        pressed = -1;
      },
      cancel(id) {
        if (id === pid) {
          pid = -1;
          pressed = -1;
        }
      },
      step(dt) {
        for (const p of pulse) p.a += dt;
        pulse = pulse.filter((p) => p.a < 0.6);
        for (const m of miss) m.a += dt;
        miss = miss.filter((m) => m.a < 1.4);
        if (link) {
          link.a += dt;
          if (link.a > 1.2) link = null;
        }
      },
      draw(g) {
        board(g, 26, 40, 248, 262);
        const on = env.on();
        B418.forEach((b, i) => {
          if (on && env.prm.rings && env.prm.pad > 0) {
            disc(g, b.x, b.y, R + env.prm.pad, rgba(COL.on, i === pressed ? 0.16 : 0.045));
            ring(g, b.x, b.y, R + env.prm.pad, rgba(COL.on, i === pressed ? 0.95 : 0.32), i === pressed ? 1.6 : 1, [3, 3]);
          }
          const isSel = sel.has(i);
          const rr0 = i === pressed ? R * 0.86 : R;
          g.save();
          if (isSel) {
            g.shadowColor = rgba(COL.gold, 0.8);
            g.shadowBlur = 12;
          }
          const gr = g.createRadialGradient(b.x - 3, b.y - 4, 1, b.x, b.y, rr0);
          if (isSel) {
            gr.addColorStop(0, '#fff3c4');
            gr.addColorStop(1, '#e8a93a');
          } else {
            gr.addColorStop(0, '#5a6796');
            gr.addColorStop(1, '#2a3359');
          }
          disc(g, b.x, b.y, rr0, gr as unknown as string);
          g.restore();
          ring(g, b.x, b.y, rr0, isSel ? 'rgba(255,240,200,0.9)' : 'rgba(170,190,255,0.45)', 1);
        });
        for (const p of pulse) {
          const b = B418[p.i]!;
          const f = p.a / 0.6;
          ring(g, b.x, b.y, R + 4 + f * 18, rgba(COL.gold, 0.8 * (1 - f)), 2.2);
        }
        if (link) {
          const b = B418[link.i]!;
          const al = 1 - link.a / 1.2;
          line(g, link.x, link.y, b.x, b.y, rgba(COL.on, al), 2, [4, 3]);
          disc(g, link.x, link.y, 3, rgba(COL.on, al));
        }
        for (const m of miss) {
          const al = 1 - m.a / 1.4;
          const sc = 1 + Math.max(0, 0.25 - m.a) * 2;
          ring(g, m.x, m.y, 12 * sc, rgba(COL.off, al * 0.7), 1.5);
          cross(g, m.x, m.y, 6 * sc, rgba(COL.off, al), 3);
        }
      },
      verdict: () => v,
      extra: () => [...ex, env.on() ? '여러 단추가 가까우면 가장 가까운 것 하나만' : '단추 그림 안만 정확히 맞아야 함'],
    };
  },
  controls: (p) => [
    { type: 'range', label: '누름 영역 넓히기 (px)', min: 0, max: 30, step: 1, value: p.pad, on: (v) => (p.pad = v) },
    { type: 'toggle', label: '누름 영역 테두리 보이기', value: p.rings, on: (v) => (p.rings = v) },
  ],
});
```

## 관련 기술
- 먼저 알면 좋은 기술: [입력 줄 세우기 (컴퓨터 차례에 누른 것 기억)](https://ai-techstudio.web.app/ai/t/i393.md) `i393`
- 다음에 해 볼 기술: [손 떨림 걸러내기 (누름 vs 끌기 문턱)](https://ai-techstudio.web.app/ai/t/i425.md) `i425` · [길게 누르기 · 두 번 누르기 구별](https://ai-techstudio.web.app/ai/t/i421.md) `i421`
- 참고 문서: [Wikipedia — Fitts's law](https://en.wikipedia.org/wiki/Fitts%27s_law)
