# AI 꾸러미 — 값과 그래프 연동 (끌면 바로 다시 그리기)
> 매개변수를 끌면 그래프가 실시간으로 다시 그려지고 원래 그래프와 겹쳐 비교  
> 견본: https://ai-techstudio.web.app/#t/i376

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

## 주문서

### 만들어 줘: 값과 그래프 연동 (끌면 바로 다시 그리기)

#### 1. 목표
함수에 「값과 그래프 연동 (끌면 바로 다시 그리기)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 매개변수를 끌면 그래프가 실시간으로 다시 그려지고 원래 그래프와 겹쳐 비교

#### 2. 핵심 기술 용어
- 값과 그래프 연동 (끌면 바로 다시 그리기) — 매개변수를 끌면 그래프가 실시간으로 다시 그려지고 원래 그래프와 겹쳐 비교

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

#### 4. 조건
- devicePixelRatio 를 맞춰 또렷하게
- 매 프레임 다시 그릴 필요 없는 그림은 화면 밖 캔버스에 미리 구워 두기
- 한 프레임 16ms 안 (폰 포함)

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「매개변수를 끌면 그래프가 실시간으로 다시 그려지고 원래 그래프와 겹쳐 비교」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 매개변수를 끌면 그래프가 실시간으로 다시 그려지고 원래 그래프와 겹쳐 비교


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「매개변수를 끌면 그래프가 실시간으로 다시 그려지고 원래 그래프와 겹쳐 비교」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i376` · 분류: 그래픽 · 셰이더 › 수학 원리 · 수와 변화 · 2D · 난이도 쉬움 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i376
- 쓰면 좋을 때: 쓰면 좋을 곳 — 함수 · 물리 실험 · 설정 화면

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i376 — `src/demos/demosMathB.ts:1173`
```ts
const i376 = cv('y = a·f(x − p) + q — p 는 옆으로, q 는 위아래로, a 는 늘이고 뒤집기 (큰 화면: 꼭짓점 · a 점을 끌기)', () => {
  let a = 1;
  let p = 0;
  let q = 0;
  let fi = 0;
  let auto = true;
  let clock = 0;
  let drag: '' | 'v' | 'a' = '';
  let hi = '';
  const S = 22;
  const OX = 140;
  const OY = 100;
  const X = (x: number): number => OX + x * S;
  const Y = (y: number): number => OY - y * S;
  const FS: ((x: number) => number)[] = [(x) => x * x, (x) => Math.abs(x), (x) => (x >= 0 ? Math.sqrt(x) : NaN)];
  const NAMES: Tok[][] = [[['i', 'x'], ['^', '2']], ['|', ['i', 'x'], '|'], ['√', ['i', 'x']]];
  const KF: [number, number, number, string, string][] = [
    [1, 0, 0, '', '원래 그래프'],
    [1, 2, 0, 'p', 'p = 2 → 오른쪽으로 2'],
    [1, 2, 1.5, 'q', 'q = 1.5 → 위로 1.5'],
    [-1, 2, 1.5, 'a', 'a = −1 → 위아래 뒤집기'],
    [0.5, 2, 1.5, 'a', 'a = 0.5 → 납작하게'],
    [0.5, -1.5, 1.5, 'p', 'p = −1.5 → 왼쪽으로'],
    [0.5, -1.5, -1, 'q', 'q = −1 → 아래로'],
    [2, -1.5, -1, 'a', 'a = 2 → 2배로 늘이기'],
  ];
  const TR = 1.1;
  const HOLD = 1.2;
  let note = '';
  function eq(): Tok[] {
    const cA = hi.includes('a') ? ACC : INK;
    const cP = hi.includes('p') ? ACC : INK;
    const cQ = hi.includes('q') ? ACC : INK;
    const ra = Math.round(a * 10) / 10;
    const rp = Math.round(p * 10) / 10;
    const rq = Math.round(q * 10) / 10;
    const toks: Tok[] = [['i', 'y'], ' = '];
    if (ra === -1) toks.push(['c', cA, '−']);
    else if (ra !== 1) toks.push(['c', cA, f1(ra)]);
    const inner: Tok[] = [['i', 'x']];
    if (rp !== 0) inner.push(rp > 0 ? ' − ' : ' + ', ['c', cP, f1(Math.abs(rp))]);
    if (fi === 0) {
      if (rp !== 0) toks.push('(', ...inner, ')', ['^', '2']);
      else toks.push(...inner, ['^', '2']);
    } else if (fi === 1) toks.push('|', ...inner, '|');
    else toks.push('√', '(', ...inner, ')');
    if (rq !== 0) toks.push(rq > 0 ? ' + ' : ' − ', ['c', cQ, f1(Math.abs(rq))]);
    return toks;
  }
  function curve(g: G, A: number, Pp: number, Qq: number): void {
    const f = FS[fi]!;
    g.beginPath();
    let on = false;
    const x0 = fi === 2 ? Pp : -6.6;
    for (let i = 0; i <= 260; i++) {
      const x = lerp(x0, 6.6, i / 260);
      const y = A * f(x - Pp) + Qq;
      if (!isFinite(y)) {
        on = false;
        continue;
      }
      const yy = clamp(Y(y), -60, 260);
      if (!on) g.moveTo(X(x), yy);
      else g.lineTo(X(x), yy);
      on = true;
    }
  }
  return {
    draw(g, _t, dt) {
      if (auto && !drag) {
        clock += dt;
        const per = TR + HOLD;
        const loop = per * KF.length;
        if (clock >= loop * 3) clock -= loop * 3;
        fi = Math.floor(clock / loop) % 3;
        const tl = clock % loop;
        const i = Math.floor(tl / per);
        const k = smooth(0, TR, tl - i * per);
        const A = KF[i]!;
        const B = KF[(i + 1) % KF.length]!;
        a = lerp(A[0], B[0], k);
        p = lerp(A[1], B[1], k);
        q = lerp(A[2], B[2], k);
        hi = B[3];
        note = k > 0.5 ? B[4] : A[4];
      }
      // 모눈
      g.save();
      rr(g, 6, 24, 268, 146, 8);
      g.clip();
      g.fillStyle = 'rgba(255,255,255,0.02)';
      g.fillRect(6, 24, 268, 146);
      g.lineWidth = 0.5;
      for (let i = -7; i <= 7; i++) {
        g.strokeStyle = inkA(i === 0 ? 0 : 0.06);
        g.beginPath();
        g.moveTo(X(i), 24);
        g.lineTo(X(i), 170);
        g.stroke();
      }
      for (let j = -4; j <= 4; j++) {
        g.strokeStyle = inkA(j === 0 ? 0 : 0.06);
        g.beginPath();
        g.moveTo(6, Y(j));
        g.lineTo(274, Y(j));
        g.stroke();
      }
      g.strokeStyle = inkA(0.35);
      g.lineWidth = 0.8;
      g.beginPath();
      g.moveTo(6, OY);
      g.lineTo(274, OY);
      g.moveTo(OX, 24);
      g.lineTo(OX, 170);
      g.stroke();
      for (let i = -6; i <= 6; i += 2) if (i) text(g, String(i).replace('-', '−'), X(i), OY + 6, 6, inkA(0.35), 'center');
      for (let j = -2; j <= 3; j++) if (j) text(g, String(j).replace('-', '−'), OX - 4, Y(j), 6, inkA(0.35), 'right');
      // 원래 그래프
      g.setLineDash([3, 3]);
      g.strokeStyle = inkA(0.35);
      g.lineWidth = 1;
      curve(g, 1, 0, 0);
      g.stroke();
      g.setLineDash([]);
      // 옮긴 화살표
      if (Math.hypot(p, q) > 0.15) {
        g.setLineDash([2, 2.5]);
        arrow(g, X(0), Y(0), X(p), Y(q), inkA(0.45), 0.8, 5);
        g.setLineDash([]);
      }
      // 새 그래프
      g.lineJoin = 'round';
      g.strokeStyle = accA(0.22);
      g.lineWidth = 6;
      curve(g, a, p, q);
      g.stroke();
      g.strokeStyle = ACC;
      g.lineWidth = 2;
      curve(g, a, p, q);
      g.stroke();
      // a 점: (p+1, q+a) — f(1) = 1 이라 세로 거리가 곧 a
      g.setLineDash([1.5, 2]);
      g.strokeStyle = inkA(0.4);
      g.lineWidth = 0.8;
      g.beginPath();
      g.moveTo(X(p), Y(q));
      g.lineTo(X(p + 1), Y(q));
      g.lineTo(X(p + 1), Y(q + a));
      g.stroke();
      g.setLineDash([]);
      text(g, '1', X(p + 0.5), Y(q) + (a >= 0 ? 6 : -6), 6.5, inkA(0.55), 'center');
      mtext(g, [['i', 'a']], X(p + 1) + 6, Y(q + a / 2), 8, 'l', hi === 'a' ? ACC : inkA(0.7));
      glowDot(g, X(p + 1), Y(q + a), drag === 'a' ? 3.4 : 2.6, COOL);
      glowDot(g, X(p), Y(q), drag === 'v' ? 4.2 : 3.3);
      mtext(g, ['(', f1(p), ', ', f1(q), ')'], X(p) + (p > 3 ? -7 : 7), Y(q) + (a >= 0 ? 9 : -9), 7.5, p > 3 ? 'r' : 'l', ACC);
      g.restore();
      // 식
      rr(g, 70, 4, 140, 17, 8.5);
      g.fillStyle = 'rgba(10,14,32,0.7)';
      g.fill();
      g.strokeStyle = inkA(0.12);
      g.lineWidth = 0.6;
      g.stroke();
      mtext(g, eq(), 140, 13, 11.5, 'c', INK, 700);
      mtext(g, ['원래: ', ['i', 'y'], ' = ', ...NAMES[fi]!], 12, 162, 7.5, 'l', inkA(0.5));
      if (note) text(g, note, 268, 162, 8, hi ? ACC : inkA(0.6), 'right', 700);
    },
    down(x, y) {
      if (Math.hypot(x - X(p + 1), y - Y(q + a)) < 9) drag = 'a';
      else if (Math.hypot(x - X(p), y - Y(q)) < 11) drag = 'v';
      if (drag) {
        auto = false;
        note = '';
      }
    },
    move(x, y, pressed) {
      if (drag && pressed) {
        if (drag === 'v') {
          p = clamp(Math.round(((x - OX) / S) * 10) / 10, -5, 5);
          q = clamp(Math.round(((OY - y) / S) * 10) / 10, -3, 3);
          hi = 'pq';
          note = '꼭짓점 (p, q) 를 옮기는 중';
        } else {
          a = clamp(Math.round(((OY - y) / S - q) * 10) / 10, -4, 4);
          hi = 'a';
          note = a < 0 ? '음수 a → 뒤집힘' : Math.abs(a) < 1 ? '|a| < 1 → 납작' : '|a| > 1 → 홀쭉';
        }
        return 'grabbing';
      }
      return Math.hypot(x - X(p + 1), y - Y(q + a)) < 9 || Math.hypot(x - X(p), y - Y(q)) < 11 ? 'grab' : '';
    },
    up() {
      drag = '';
    },
    controls: [
      { type: 'range', label: 'a (늘이기 · 뒤집기)', min: -3, max: 3, step: 0.1, value: 1, on: (v) => ((auto = false), (a = v), (hi = 'a'), (note = '')) },
      { type: 'range', label: 'p (옆으로)', min: -4, max: 4, step: 0.1, value: 0, on: (v) => ((auto = false), (p = v), (hi = 'p'), (note = '')) },
      { type: 'range', label: 'q (위아래로)', min: -3, max: 3, step: 0.1, value: 0, on: (v) => ((auto = false), (q = v), (hi = 'q'), (note = '')) },
      { type: 'range', label: '원래 함수 (1 x² · 2 |x| · 3 √x)', min: 1, max: 3, step: 1, value: 1, on: (v) => ((auto = false), (fi = v - 1), (hi = ''), (note = '')) },
    ],
  };
});
```

## 관련 기술
- 이 효과를 만든 기술: [그래프 + 3D 연동 (슬라이더 하나로)](https://ai-techstudio.web.app/ai/t/i39.md) `i39`
