# AI 꾸러미 — 통계 그래프 그리기 (막대 · 띠 · 원 · 상자)
> 같은 자료를 여러 그래프로 부드럽게 바꿔 그리기 — 평균 · 중앙값 표시  
> 견본: https://ai-techstudio.web.app/#t/i413

이 문서를 코드 도우미(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: `i413` · 분류: 그래픽 · 셰이더 › 수학 원리 · 수와 변화 · 2D · 난이도 쉬움 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i413
- 쓰면 좋을 때: 쓰면 좋을 곳 — 통계 · 결과 화면 · 순위표

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i413 — `src/demos/demosMathC.ts:1708`
```ts
const i413 = cv('같은 자료 20개를 막대 · 띠 · 원 · 상자 수염으로 — 평균(무게 중심) · 중앙값(가운데) · 최빈값(가장 많은)이 어디에 보이나', () => {
  let data: number[] = [];
  let counts: number[] = [];
  let mean = 0;
  let median = 0;
  let modes: number[] = [];
  let q1 = 0;
  let q3 = 0;
  let mn = 0;
  let mx = 0;
  let clock = 0;
  let fixed = 0;
  let marks = true;
  function gen(): void {
    data = [];
    for (let i = 0; i < 20; i++) {
      const v = Math.floor((Math.random() + Math.random() + Math.random() * 0.8) * 3.4);
      data.push(clamp(v, 0, 8));
    }
    const s = [...data].sort((a, b) => a - b);
    counts = new Array(9).fill(0);
    for (const v of s) counts[v]!++;
    mean = s.reduce((a, b) => a + b, 0) / 20;
    median = (s[9]! + s[10]!) / 2;
    q1 = (s[4]! + s[5]!) / 2;
    q3 = (s[14]! + s[15]!) / 2;
    mn = s[0]!;
    mx = s[19]!;
    const top = Math.max(...counts);
    modes = counts.map((c, v) => (c === top ? v : -1)).filter((v) => v >= 0);
  }
  gen();
  const X = (v: number): number => 24 + v * 19;
  const catCol = (v: number): string => (modes.includes(v) ? ACC : `hsl(${218 - v * 4}, ${52 + v * 3}%, ${30 + v * 6.5}%)`);
  const NAMES = ['막대그래프', '띠그래프', '원그래프', '상자 수염 그림'];
  const TIPS = ['가장 높은 막대 = 최빈값', '띠의 50% 자리 = 중앙값이 든 칸', '가장 큰 조각 = 최빈값', '상자 가운데 선 = 중앙값, 상자 = 가운데 절반'];
  function meanMark(g: G, y: number): void {
    const x = X(mean);
    g.fillStyle = ACC;
    g.beginPath();
    g.moveTo(x, y);
    g.lineTo(x - 4, y + 6);
    g.lineTo(x + 4, y + 6);
    g.closePath();
    g.fill();
    text(g, `평균 ${fN(mean, 2)}`, x, y + 11, 6, ACC, 'center', 700);
  }
  function bars(g: G, al: number): void {
    g.globalAlpha = al;
    const base = 146;
    const top = Math.max(...counts);
    const sc = 96 / Math.max(top, 5);
    g.strokeStyle = inkA(0.35);
    g.lineWidth = 0.7;
    g.beginPath();
    g.moveTo(14, base);
    g.lineTo(192, base);
    g.stroke();
    for (let v = 0; v <= 8; v++) {
      const h = counts[v]! * sc;
      const x = X(v);
      if (h > 0) {
        rr(g, x - 6.5, base - h, 13, h, 1.5);
        g.fillStyle = modes.includes(v) ? accFill(g, base - h, base) : 'rgba(120,160,240,0.75)';
        g.fill();
        text(g, String(counts[v]), x, base - h - 5, 6.2, modes.includes(v) ? ACC : inkA(0.7), 'center', 700);
      }
      text(g, String(v), x, base + 5, 5.8, inkA(0.5), 'center');
    }
    if (marks) {
      dashLine(g, X(median), 40, X(median), base, inkA(0.7), 0.8, [2.5, 2]);
      text(g, `중앙값 ${f1(median)}`, X(median), 36, 6, INK, 'center', 700);
      meanMark(g, base + 9);
    }
    g.globalAlpha = 1;
  }
  function band(g: G, al: number): void {
    g.globalAlpha = al;
    const x0 = 14;
    const W = 176;
    const y0 = 74;
    const h = 30;
    let acc = 0;
    for (let v = 0; v <= 8; v++) {
      const c = counts[v]!;
      if (!c) continue;
      const x = x0 + (acc / 20) * W;
      const w = (c / 20) * W;
      g.fillStyle = catCol(v);
      g.fillRect(x, y0, w - 0.6, h);
      text(g, String(v), x + w / 2, y0 - 6, 6, inkA(0.7), 'center', 700);
      if (w > 13) text(g, `${c * 5}%`, x + w / 2, y0 + h / 2, 5.8, modes.includes(v) ? DARK : INK, 'center', 700);
      acc += c;
    }
    g.strokeStyle = inkA(0.3);
    g.lineWidth = 0.6;
    g.strokeRect(x0, y0, W, h);
    text(g, '읽은 책 수 (권)', x0, y0 - 16, 5.8, inkA(0.45), 'left');
    if (marks) {
      const mx2 = x0 + W / 2;
      arrow(g, mx2, y0 + h + 16, mx2, y0 + h + 2, INK, 0.9, 4);
      text(g, `50% 자리 → 중앙값 ${f1(median)}`, mx2, y0 + h + 22, 6.4, INK, 'center', 700);
    }
    for (let i = 0; i <= 10; i++) {
      const x = x0 + (i / 10) * W;
      g.fillStyle = inkA(0.3);
      g.fillRect(x - 0.3, y0 + h + 0.5, 0.6, 2.4);
    }
    text(g, '0%', x0, y0 + h + 7, 5, inkA(0.4), 'center');
    text(g, '100%', x0 + W, y0 + h + 7, 5, inkA(0.4), 'center');
    g.globalAlpha = 1;
  }
  function pie(g: G, al: number): void {
    g.globalAlpha = al;
    const cx = 100;
    const cy = 98;
    const r = 56;
    let a0 = -Math.PI / 2;
    for (let v = 0; v <= 8; v++) {
      const c = counts[v]!;
      if (!c) continue;
      const a1 = a0 + (c / 20) * TAU;
      const mid = (a0 + a1) / 2;
      const off = modes.includes(v) ? 4 : 0;
      const ox = Math.cos(mid) * off;
      const oy = Math.sin(mid) * off;
      g.beginPath();
      g.moveTo(cx + ox, cy + oy);
      g.arc(cx + ox, cy + oy, r, a0, a1);
      g.closePath();
      g.fillStyle = catCol(v);
      g.fill();
      g.strokeStyle = '#0b1022';
      g.lineWidth = 0.8;
      g.stroke();
      const lr = r * 0.66;
      if (c >= 2) {
        text(g, `${v}권`, cx + ox + Math.cos(mid) * lr, cy + oy + Math.sin(mid) * lr - 3.5, 5.8, modes.includes(v) ? DARK : INK, 'center', 800);
        text(g, `${c * 5}%`, cx + ox + Math.cos(mid) * lr, cy + oy + Math.sin(mid) * lr + 3.8, 5.2, modes.includes(v) ? DARK : inkA(0.8), 'center', 700);
      } else text(g, `${v}`, cx + ox + Math.cos(mid) * (r + 6), cy + oy + Math.sin(mid) * (r + 6), 5.6, inkA(0.6), 'center', 700);
      a0 = a1;
    }
    g.globalAlpha = 1;
  }
  function boxPlot(g: G, al: number): void {
    g.globalAlpha = al;
    const base = 150;
    g.strokeStyle = inkA(0.35);
    g.lineWidth = 0.7;
    g.beginPath();
    g.moveTo(14, base);
    g.lineTo(192, base);
    g.stroke();
    for (let v = 0; v <= 8; v++) {
      text(g, String(v), X(v), base + 5, 5.8, inkA(0.5), 'center');
      for (let i = 0; i < counts[v]!; i++) {
        g.fillStyle = modes.includes(v) ? ACC : 'rgba(150,185,255,0.85)';
        g.beginPath();
        g.arc(X(v), base - 4 - i * 6.6, 2.6, 0, TAU);
        g.fill();
      }
    }
    const y0 = 44;
    const y1 = 70;
    g.strokeStyle = INK;
    g.lineWidth = 0.9;
    g.beginPath();
    g.moveTo(X(mn), (y0 + y1) / 2);
    g.lineTo(X(q1), (y0 + y1) / 2);
    g.moveTo(X(q3), (y0 + y1) / 2);
    g.lineTo(X(mx), (y0 + y1) / 2);
    g.moveTo(X(mn), y0 + 6);
    g.lineTo(X(mn), y1 - 6);
    g.moveTo(X(mx), y0 + 6);
    g.lineTo(X(mx), y1 - 6);
    g.stroke();
    g.fillStyle = coolA(0.2);
    g.fillRect(X(q1), y0, Math.max(1, X(q3) - X(q1)), y1 - y0);
    g.strokeRect(X(q1), y0, Math.max(1, X(q3) - X(q1)), y1 - y0);
    g.strokeStyle = ACC;
    g.lineWidth = 1.8;
    g.beginPath();
    g.moveTo(X(median), y0 - 2);
    g.lineTo(X(median), y1 + 2);
    g.stroke();
    text(g, `중앙값 ${f1(median)}`, X(median), y0 - 7, 6, ACC, 'center', 700);
    text(g, `Q1 ${f1(q1)}`, X(q1), y1 + 6, 5.4, inkA(0.6), 'center');
    text(g, `Q3 ${f1(q3)}`, X(q3), y1 + 6, 5.4, inkA(0.6), 'center');
    if (marks) meanMark(g, base + 9);
    g.globalAlpha = 1;
  }
  const DRAW = [bars, band, pie, boxPlot];
  return {
    draw(g, _t, dt) {
      clock += dt;
      const PER = 3.6;
      let cur: number;
      let prev: number;
      let k = 1;
      if (fixed) {
        cur = fixed - 1;
        prev = cur;
      } else {
        const idx = Math.floor(clock / PER);
        cur = idx % 4;
        prev = (idx + 3) % 4;
        k = smooth(0, 0.5, clock - idx * PER);
        if (idx > 0 && idx % 4 === 0 && clock - idx * PER < dt) gen();
      }
      if (k < 1) DRAW[prev]!(g, 1 - k);
      DRAW[cur]!(g, k);
      // 제목
      text(g, NAMES[cur]!, 14, 13, 9, INK, 'left', 800);
      text(g, TIPS[cur]!, 14, 24, 6.2, accA(0.85), 'left', 600);
      // 오른쪽 셈
      const x0 = 200;
      rr(g, x0 - 4, 8, 78, 92, 6);
      g.fillStyle = 'rgba(10,14,32,0.6)';
      g.fill();
      g.strokeStyle = inkA(0.12);
      g.lineWidth = 0.6;
      g.stroke();
      text(g, '읽은 책 수 20명', x0, 18, 6.4, inkA(0.6), 'left', 700);
      const sum = data.reduce((a, b) => a + b, 0);
      mtext(g, ['평균 ', ['/', String(sum), '20'], ' = ', ['c', ACC, fN(mean, 2)]], x0, 36, 7.4, 'l', INK, 700);
      mtext(g, ['중앙값 ', ['/', `${[...data].sort((a, b) => a - b)[9]}+${[...data].sort((a, b) => a - b)[10]}`, '2'], ' = ', ['c', ACC, f1(median)]], x0, 58, 7.4, 'l', INK, 700);
      text(g, `최빈값 ${modes.join(', ')}`, x0, 78, 7.4, INK, 'left', 700);
      text(g, `(${Math.max(...counts)}명)`, x0, 89, 6, inkA(0.5), 'left');
      // 자료 줄
      const s = [...data].sort((a, b) => a - b);
      for (let i = 0; i < 20; i++) {
        const xx = x0 - 2 + (i % 10) * 7.6;
        const yy = 114 + Math.floor(i / 10) * 11;
        const mid = i === 9 || i === 10;
        rr(g, xx - 3.2, yy - 4.5, 6.4, 9, 1.5);
        g.fillStyle = mid ? accA(0.25) : inkA(0.05);
        g.fill();
        text(g, String(s[i]), xx, yy + 0.3, 5.8, mid ? ACC : inkA(0.75), 'center', 700);
      }
      text(g, '작은 수부터 · 10 · 11번째가 가운데', x0 + 34, 140, 5.2, inkA(0.45), 'center');
    },
    controls: [
      { type: 'button', label: '새 자료 (무작위 20명)', on: () => gen() },
      { type: 'range', label: '그래프 (0 자동 · 1 막대 · 2 띠 · 3 원 · 4 상자)', min: 0, max: 4, step: 1, value: 0, on: (v) => ((fixed = v), (clock = 0)) },
      { type: 'toggle', label: '평균 · 중앙값 표시', value: true, on: (v) => (marks = v) },
    ],
  };
});
```

## 관련 기술
- 이 효과를 만든 기술: [데이터 애니 (막대 경주 · 자라는 차트)](https://ai-techstudio.web.app/ai/t/i116.md) `i116`
