# AI 꾸러미 — 데이터 애니 (막대 경주 · 자라는 차트) — Bar chart race
> 막대 너비는 CSS 트랜지션으로 자라게 하고, 순위가 바뀌면 FLIP(처음 자리 → 새 자리 차이만큼 되돌렸다 풀기)으로 줄이 미끄러지게 해 막대 경주 차트를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i116

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

## 주문서

### 만들어 줘: 데이터 애니 (막대 경주 · 자라는 차트) — Bar chart race

#### 1. 목표
좋아하는 과일 투표 (주별)을(를) 데이터 애니메이션으로 보여 줘 — 주마다 막대가 자라고 순위가 바뀌면 줄이 FLIP 으로 미끄러지게, 옆에는 선 그래프가 한 주씩 그려지게. 분위기는 짙은 남색 방송 그래픽.

#### 2. 핵심 기술 용어
- **Bar chart race** — 시간에 따라 순위가 바뀌는 막대 경주
- **FLIP animation (First, Last, Invert, Play)** — 처음 · 끝 자리를 재고 차이만큼 되돌렸다가 풀기
- **Growing line chart (SVG polyline)** — 한 칸씩 그려지는 선 그래프

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 순위 바꾸기는 데이터가 바뀌는 순간에만 (매 프레임 재지 않기)
- 처음 자리를 먼저 재고 → DOM 순서 바꾸고 → 새 자리를 재는 순서를 지킨다
- Invert 뒤 offsetWidth 를 읽어 한 번 그리게 해야 트랜지션이 걸린다
- 움직임은 transform 만 (top · margin 을 트윈하지 않기)
- 숫자는 목표값을 지수로 따라가게 (shown += (target − shown) × min(1, dt × 5))

#### 5. 완성 기준 (이게 보이면 성공)
- 1주 → 12주로 넘어갈 때마다 막대가 자라거나 줄고, 순위가 바뀐 줄은 위아래로 미끄러지며 자리를 바꾼다
- 줄이 순간이동하지 않는다 — 언제 멈춰 봐도 중간 자리에 있다
- 오른쪽 선 그래프가 한 주씩 이어 그려지고, 끝점 동그라미가 선을 따라 간다

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

## 원리
- First: 순서를 바꾸기 전에 줄마다 offsetTop 을 잰다.
- Last: 값 순서대로 appendChild 로 줄을 다시 놓는다 (DOM 순서가 곧 순위). 새 offsetTop 을 잰다.
- Invert: 차이 dy 만큼 transform: translateY(dy) 로 옛 자리에 보이게 (트랜지션 끔) → offsetWidth 로 한 번 그리게 한다.
- Play: 트랜지션을 켜고 transform 을 비우면 줄이 새 자리로 미끄러진다 (.65초). 막대 너비 · 숫자도 따로 부드럽게 따라간다.

## 핵심 코드 — FLIP 으로 순위 줄 미끄러지기 + 막대 너비
(발췌: demos/demosMotionB.ts i116 의 apply() 를 정리)
```ts
/** 주 k 의 값으로 순위를 다시 놓는다 (데이터가 바뀔 때만) */
function apply(k: number): void {
  const vals = FRU.map((_, i) => val(i, k));
  const first = rows.map((r) => r.el.offsetTop);                 // First
  const order = vals.map((v, i) => [v, i] as const).sort((a, b) => b[0] - a[0]);
  order.forEach(([, i], rank) => {
    rowsBox.appendChild(rows[i].el);                              // Last: DOM 순서 = 순위
    rows[i].rk.textContent = String(rank + 1);
  });
  rows.forEach((r, i) => {
    r.target = vals[i];
    r.bar.style.width = (vals[i] / 42) * 100 + '%';               // 너비는 CSS 트랜지션(.85s)
    const dy = first[i] - r.el.offsetTop;
    if (dy) {
      r.el.style.transition = 'none';
      r.el.style.transform = 'translateY(' + dy + 'px)';          // Invert: 옛 자리에 보이게
      void r.el.offsetWidth;                                      // 한 번 그리게 한다
      r.el.style.transition = 'transform .65s cubic-bezier(.2,.8,.2,1)';
      r.el.style.transform = '';                                  // Play: 새 자리로 미끄러짐
    }
  });
}

// 매 프레임: 주가 바뀔 때만 apply, 숫자는 부드럽게 따라가기
const k = Math.floor(t / 1.1) % 12;
if (k !== lastK) { lastK = k; apply(k); }
for (const r of rows) {
  r.shown += (r.target - r.shown) * Math.min(1, dt * 5);
  r.num.textContent = Math.round(r.shown) + '표';
}
```

## 흔한 실수 · 확인 목록
- [ ] **offsetWidth 를 읽지 않으면 트랜지션 없이 순간이동한다** — Invert 와 Play 사이에 강제 레이아웃(void el.offsetWidth)이 있어야 브라우저가 옛 자리를 한 번 그린다.
- [ ] **DOM 순서를 바꾼 뒤에 처음 자리를 재면 차이가 0 이다** — 반드시 바꾸기 전에 모든 줄의 offsetTop 을 먼저 잰다.
- [ ] **top · margin 을 트윈하면 레이아웃을 매 프레임 다시 계산한다** — transform 만 움직인다 — 합성 단계에서 끝나 가볍다.
- [ ] **바뀌는 부분만 갈아 끼워야 하는데 목록을 통째로 다시 만들면 깜박인다** — 줄 요소는 한 번 만들어 두고 순서 · 값만 바꾼다 (사용자 지적: 새로고침 느낌 금지).

## 완성 기준 체크리스트
- [ ] 1주 → 12주로 넘어갈 때마다 막대가 자라거나 줄고, 순위가 바뀐 줄은 위아래로 미끄러지며 자리를 바꾼다
- [ ] 줄이 순간이동하지 않는다 — 언제 멈춰 봐도 중간 자리에 있다
- [ ] 오른쪽 선 그래프가 한 주씩 이어 그려지고, 끝점 동그라미가 선을 따라 간다

## 이 기술 정보
- id: `i116` · 분류: 이펙트 · 연출 › 모션 그래픽 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 순위가 바뀌는 순간에만 offsetTop 을 두 번 재고(레이아웃 한 번) 트랜지션은 브라우저 합성이 맡는다. 줄 5개 기준 아주 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i116
- 쓰면 좋을 때: 값의 순위가 시간에 따라 바뀌는 것을 보여 줄 때 / 목록 정렬 · 순위표가 바뀌는 순간을 자연스럽게
- 쓰지 말 때: 줄이 수백 개 — 레이아웃 재기가 무겁다. 대신 캔버스에 직접 그리기 / 순위가 안 바뀌는 단순 막대 — 너비 트랜지션만

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i116 견본 항목 — `src/demos/demosMotionB.ts:1111`
```ts
  i116: {
    kind: 'dom',
    caption: '주마다 막대가 자라고 순위가 바뀌면 줄이 미끄러지듯 자리를 바꿔요(FLIP) · 오른쪽 선 그래프는 한 주씩 그려짐',
    make(box) {
      const FRU: [string, string, string][] = [
        ['🍎', '사과', '#ff5c6c'],
        ['🍌', '바나나', '#ffd23f'],
        ['🍇', '포도', '#a77bff'],
        ['🍓', '딸기', '#ff8ac4'],
        ['🍊', '귤', '#ff9f43'],
      ];
      const N = 12;
      const F1 = [1, 1, 2, 1, 2];
      const F2 = [2, 1, 1, 3, 1];
      const val = (i: number, k: number): number =>
        Math.round(22 + 11 * Math.sin((TAU * k * F1[i]!) / N + i * 1.3) + 6 * Math.cos((TAU * k * F2[i]!) / N + i));
      const root = domRoot(
        box,
        'mb-race',
        `.mb-race{background:linear-gradient(#1d2558,#0a0e25);display:flex;gap:3cqmin;padding:4cqmin;box-sizing:border-box}
         .mb-race .L{flex:1.3;display:flex;flex-direction:column;min-width:0}
         .mb-race .ttl{font-weight:800;font-size:5.4cqmin;margin-bottom:1.5cqmin;white-space:nowrap}
         .mb-race .ttl b{color:#ffd23f}
         .mb-race .rows{flex:1;display:flex;flex-direction:column;justify-content:space-around}
         .mb-race .row{display:flex;align-items:center;gap:1.4cqmin;height:12.5cqmin}
         .mb-race .nm{width:8cqmin;font-size:7cqmin;text-align:center;line-height:1}
         .mb-race .trk{flex:1;height:100%;padding-right:10cqmin;position:relative}
         .mb-race .bar{height:100%;border-radius:2.4cqmin;transition:width .85s cubic-bezier(.2,.8,.2,1);position:relative;min-width:3cqmin;box-shadow:inset 0 -1.2cqmin 0 rgba(0,0,0,.16)}
         .mb-race .bar span{position:absolute;left:calc(100% + 1.4cqmin);top:50%;transform:translateY(-50%);font-weight:900;font-size:5cqmin;white-space:nowrap}
         .mb-race .bar i{position:absolute;left:2cqmin;top:50%;transform:translateY(-50%);font-style:normal;font-size:4.2cqmin;font-weight:800;color:rgba(0,0,0,.6);white-space:nowrap}
         .mb-race .rk{width:5cqmin;font-weight:900;font-size:4.6cqmin;color:#9fb0ff;text-align:center}
         .mb-race .R{flex:1;display:flex;flex-direction:column;min-width:0;background:rgba(255,255,255,.05);border-radius:3cqmin;padding:2.5cqmin;box-sizing:border-box}
         .mb-race .R .ttl{font-size:4.6cqmin;margin:0}
         .mb-race svg{flex:1;width:100%;min-height:0;display:block}`,
        `<div class="L"><div class="ttl">좋아하는 과일 투표 · <b class="wk">1주</b></div><div class="rows"></div></div>
         <div class="R"><div class="ttl">주별 득표 변화</div><svg></svg></div>`,
      );
      const rowsBox = q(root, '.rows');
      const wk = q(root, '.wk');
      const svg = q<SVGSVGElement>(root, 'svg');
      const rows = FRU.map(([em, nm, col]) => {
        const el = document.createElement('div');
        el.className = 'row';
        el.innerHTML = `<div class="rk"></div><div class="nm">${em}</div><div class="trk"><div class="bar" style="background:${col}"><i>${nm}</i><span>0</span></div></div>`;
        rowsBox.appendChild(el);
        return { el, bar: q(el, '.bar'), num: q(el, '.bar span'), rk: q(el, '.rk'), shown: 0, target: 0 };
      });
      const NS = 'http://www.w3.org/2000/svg';
      const grid = document.createElementNS(NS, 'g');
      const lines = FRU.map(([, , col]) => {
        const p = document.createElementNS(NS, 'polyline');
        p.setAttribute('fill', 'none');
        p.setAttribute('stroke', col);
        p.setAttribute('stroke-width', '2.5');
        p.setAttribute('stroke-linejoin', 'round');
        p.setAttribute('stroke-linecap', 'round');
        const c = document.createElementNS(NS, 'circle');
        c.setAttribute('fill', col);
        c.setAttribute('stroke', '#fff');
        c.setAttribute('stroke-width', '1.5');
        return { p, c };
      });
      const plot = document.createElementNS(NS, 'g');
      svg.append(grid, plot);
      for (const l of lines) plot.append(l.p, l.c);
      let lastK = -1;
      let sz = '';
      const STEP = 1.1;
      const apply = (k: number): void => {
        wk.textContent = `${k + 1}주`;
        const vals = FRU.map((_, i) => val(i, k));
        const first = rows.map((r) => r.el.offsetTop);
        const order = vals.map((v, i) => [v, i] as const).sort((a, b) => b[0] - a[0]);
        order.forEach(([, i], rank) => {
          rowsBox.appendChild(rows[i]!.el);
          rows[i]!.rk.textContent = String(rank + 1);
        });
        rows.forEach((r, i) => {
          r.target = vals[i]!;
          r.bar.style.width = `${(vals[i]! / 42) * 100}%`;
          const dy = first[i]! - r.el.offsetTop;
          if (dy) {
            r.el.style.transition = 'none';
            r.el.style.transform = `translateY(${dy}px)`;
            void r.el.offsetWidth;
            r.el.style.transition = 'transform .65s cubic-bezier(.2,.8,.2,1)';
            r.el.style.transform = '';
          }
        });
      };
      return {
        update(t, dt) {
          const k = Math.floor(t / STEP) % N;
          if (k !== lastK) {
            lastK = k;
            apply(k);
          }
          for (const r of rows) {
            r.shown += (r.target - r.shown) * Math.min(1, dt * 5);
            r.num.textContent = `${Math.round(r.shown)}표`;
          }
          const W = svg.clientWidth;
          const H = svg.clientHeight;
          if (`${W}x${H}` !== sz) {
            sz = `${W}x${H}`;
            svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
            grid.innerHTML = [0, 1, 2, 3].map((i) => `<line x1="0" x2="${W}" y1="${4 + (i * (H - 8)) / 3}" y2="${4 + (i * (H - 8)) / 3}" stroke="rgba(255,255,255,.12)"/>`).join('');
          }
          const X = (s: number): number => 5 + (s / (N - 1)) * (W - 10);
          const Y = (v: number): number => H - 4 - (v / 42) * (H - 8);
          const fr = easeIO(fract(t / STEP) / 0.75);
          const s = k + (k < N - 1 ? fr : 0);
          plot.setAttribute('opacity', String(k === N - 1 ? 1 - clamp01((fract(t / STEP) - 0.55) / 0.4) : 1));
          lines.forEach((l, i) => {
            const pts: string[] = [];
            for (let j = 0; j <= Math.floor(s); j++) pts.push(`${X(j).toFixed(1)},${Y(val(i, j)).toFixed(1)}`);
            const j0 = Math.floor(s);
            const hv = j0 < N - 1 ? lerp(val(i, j0), val(i, j0 + 1), s - j0) : val(i, j0);
            pts.push(`${X(s).toFixed(1)},${Y(hv).toFixed(1)}`);
            l.p.setAttribute('points', pts.join(' '));
            l.c.setAttribute('cx', X(s).toFixed(1));
            l.c.setAttribute('cy', Y(hv).toFixed(1));
            l.c.setAttribute('r', String(Math.max(2.5, H * 0.03)));
          });
        },
      };
    },
  }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [통계 그래프 그리기 (막대 · 띠 · 원 · 상자)](https://ai-techstudio.web.app/ai/t/i413.md) `i413`
- 먼저 알면 좋은 기술: [만화 연출 층 (DOM)](https://ai-techstudio.web.app/ai/t/u38.md) `u38`
- 다음에 해 볼 기술: [그래프 + 3D 연동 (슬라이더 하나로)](https://ai-techstudio.web.app/ai/t/i39.md) `i39` · [타임라인 연출 + 프레임 효과음](https://ai-techstudio.web.app/ai/t/i127.md) `i127`
- 참고 문서: [Paul Lewis — FLIP Your Animations](https://aerotwist.com/blog/flip-your-animations/)
