# AI 꾸러미 — 액체 진행 막대 — Liquid progress bar (fill gauge)
> 진행 막대를 물이 차오르는 병 · 관으로 그려서, 값이 바뀌면 물이 출렁이며 살짝 넘쳤다 가라앉는다.  
> 견본: https://ai-techstudio.web.app/#t/i217

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

## 주문서

### 만들어 줘: 액체 진행 막대 — Liquid progress bar (fill gauge)

#### 1. 목표
분수 · 백분율 진행 막대를 물이 출렁이며 차오르는 게이지로 그려 줘 — 값이 바뀌면 살짝 넘쳤다 가라앉고 수면이 흔들리게. 분위기는 밤바다 파랑.

#### 2. 핵심 기술 용어
- **Liquid progress bar (fill gauge)** — 물이 차오르는 진행 막대
- **Damped spring** — 용수철 + 감쇠 — 지나쳤다 돌아오기
- **Slosh (wave surface)** — 출렁임 — 속도 변화가 수면을 흔듦
- **Canvas clip path** — 병 모양으로 잘라 그리기

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

#### 4. 조건
- 높이는 목표로 순간 이동하지 않고 용수철(가속 = (목표 − 값)·70 − 속도·8)로
- 출렁임은 높이의 가속도에서 나오게 — 그냥 늘 흔들리는 물결이 아니다
- 물은 병 모양으로 clip 해서 밖으로 안 넘치게
- dt 는 0.05초로 잘라 탭이 숨었다 돌아와도 터지지 않게
- 출렁임 세기는 조절 값 하나로

#### 5. 완성 기준 (이게 보이면 성공)
- 1/4 → 2/4 → 3/4 → 가득 으로 바뀔 때마다 물이 출렁이며 차오르고, 목표를 살짝 넘었다 가라앉는다
- 아래 「그냥 막대」와 비교하면 위 병 · 관만 출렁인다
- 「출렁임」 슬라이더를 0 으로 두면 잔잔하게 찬다
- 폰에서도 60fps

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

## 원리
- 보이는 높이 lv 가 목표값을 용수철로 쫓는다: 가속 = (목표 − lv)·70 − 속도·8. 감쇠가 약해서 목표를 살짝 넘었다 돌아온다.
- 출렁임 slosh 는 또 하나의 용수철인데, 높이의 가속도(acc)를 조금(0.02) 받아 흔들린다 — 빨리 찰수록 크게 출렁인다.
- 수면은 sin 물결 + 기울기(slosh 비례)로 그리고, 앞 · 뒤 두 겹을 반대로 기울여 물 두께를 낸다.
- 병 · 관 모양 경로로 clip 한 뒤 물을 그리면 모양 밖으로 안 넘친다. 거품은 아래에서 올라오다 수면 위에선 안 그린다.

## 핵심 코드 — 용수철 높이 + 출렁임 + 물결 수면
(발췌: demos/demosJuice.ts D217 draw() 를 정리)
```ts
let lv = 0, vel = 0;      // 보이는 높이 · 속도
let slosh = 0, sv = 0;    // 출렁임 · 그 속도
let wobble = 1;           // 출렁임 배율

function step(target: number, dt0: number): void {
  const dt = Math.min(dt0, 0.05);
  const acc = (target - lv) * 70 - vel * 8; // 약한 감쇠 → 살짝 넘침
  vel += acc * dt;
  lv += vel * dt;
  // 빨리 찰수록(가속이 클수록) 크게 출렁
  const sa = -slosh * 60 - sv * 3 + acc * 0.02 * wobble;
  sv += sa * dt;
  slosh += sv * dt;
}

// 수면 한 겹: 병 모양으로 clip 한 뒤 아래를 채운다
function water(g: CanvasRenderingContext2D, x0: number, x1: number, yy: number, tilt: number, t: number, u: number, bottom: number): void {
  const amp = Math.max(-0.6, Math.min(0.6, slosh));
  const cx = (x0 + x1) / 2;
  g.beginPath();
  g.moveTo(x0, bottom);
  for (let k = 0; k <= 24; k++) {
    const x = x0 + ((x1 - x0) * k) / 24;
    const y = yy + Math.sin((x * 0.12) / u + t * 5) * 2 * u * (0.4 + Math.abs(amp) * 4) + (x - cx) * tilt;
    g.lineTo(x, y);
  }
  g.lineTo(x1, bottom);
  g.closePath();
  g.fill();
}
// 앞 · 뒤 두 겹을 반대로 기울여 두께: water(..., amp * 0.5) 옅게, water(..., -amp * 0.6) 진하게
```

## 흔한 실수 · 확인 목록
- [ ] **감쇠를 너무 세게 주면 넘침이 없어 밋밋하다** — 견본은 강성 70 · 감쇠 8 — 한 번 살짝 넘었다 돌아온다. 감쇠를 키울수록 그냥 막대처럼 된다.
- [ ] **dt 를 자르지 않으면 탭 전환 뒤 물이 튀어 나간다** — 용수철은 dt 가 크면 터진다. Math.min(dt, 0.05).
- [ ] **0 으로 비울 때 음수로 내려가 바닥 아래로 샌다** — 목표가 0 이고 거의 비면 lv 를 0 이상으로 잡는다.

## 완성 기준 체크리스트
- [ ] 1/4 → 2/4 → 3/4 → 가득 으로 바뀔 때마다 물이 출렁이며 차오르고, 목표를 살짝 넘었다 가라앉는다
- [ ] 아래 「그냥 막대」와 비교하면 위 병 · 관만 출렁인다
- [ ] 「출렁임」 슬라이더를 0 으로 두면 잔잔하게 찬다
- [ ] 폰에서도 60fps

## 이 기술 정보
- id: `i217` · 분류: 이펙트 · 연출 › 손맛 · 주스 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 2D 경로 몇 개와 그러데이션뿐. 폰에서도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i217
- 쓰면 좋을 때: 분수 · 백분율 · 들이처럼 「얼마나 찼나」가 수학 내용일 때 / 진행 막대가 밋밋해서 눈에 안 띌 때
- 쓰지 말 때: 값이 아주 자주 바뀌는 막대(매 프레임) — 늘 출렁여 읽기 어렵다. 그땐 출렁임 배율을 낮춘다 / 정확한 눈금 읽기가 목표인 문제 — 넘침이 헷갈린다. 눈금 문제는 넘침 없이

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### D217 — `src/demos/demosJuice.ts:1976`
```ts
const D217: Demo = {
  kind: '2d',
  caption: '1/4 → 2/4 → 3/4 → 가득 — 물이 출렁이며 차오르고 살짝 넘쳤다 가라앉는 게이지 (아래는 그냥 막대)',
  make() {
    let compare = true;
    let wobble = 1;
    const TG = [0.25, 0.5, 0.75, 1.0, 0];
    const STEP = 1.4;
    let lv = 0;
    let vel = 0;
    let slosh = 0;
    let sv = 0;
    return {
      draw(g, w, h, t, dt0) {
        reset(g);
        const dt = Math.min(dt0, 0.05);
        const u = Math.min(w / 280, h / 175);
        const i = Math.floor(t / STEP) % TG.length;
        const target = TG[i]!;
        // 스프링 (넘쳤다 돌아옴)
        const acc = (target - lv) * 70 - vel * 8;
        vel += acc * dt;
        lv += vel * dt;
        if (target === 0 && lv < 0.02) {
          lv = Math.max(0, lv);
        }
        // 출렁임: 속도 변화가 물결을 흔든다
        const sa = -slosh * 60 - sv * 3 + acc * 0.02 * wobble;
        sv += sa * dt;
        slosh += sv * dt;
        bg(g, w, h, '#123a5a', '#06182a');
        const L = clamp01(lv);
        const amp = clamp(slosh, -0.6, 0.6);
        // 둥근 병
        const fx = 50 * u;
        const fy = h * 0.5;
        const fr = 34 * u;
        const flask = (): void => {
          g.beginPath();
          g.arc(fx, fy + 6 * u, fr, -Math.PI / 2 + 0.38, Math.PI * 1.5 - 0.38);
          g.lineTo(fx - 9 * u, fy - 48 * u);
          g.lineTo(fx + 9 * u, fy - 48 * u);
          g.closePath();
        };
        flask();
        g.fillStyle = 'rgba(200,235,255,.08)';
        g.fill();
        g.save();
        flask();
        g.clip();
        const top = fy + 6 * u + fr - L * (fr * 2 + 14 * u);
        const water = (yy: number, tilt: number, colA: string, colB: string, ph: number): void => {
          g.beginPath();
          g.moveTo(fx - fr - 4 * u, h);
          for (let k = 0; k <= 24; k++) {
            const x = fx - fr - 4 * u + ((fr * 2 + 8 * u) * k) / 24;
            const y = yy + Math.sin(x * 0.12 / u + t * 5 + ph) * 2 * u * (0.4 + Math.abs(amp) * 4) + (x - fx) * tilt;
            g.lineTo(x, y);
          }
          g.lineTo(fx + fr + 4 * u, h);
          g.closePath();
          const gr = g.createLinearGradient(0, yy, 0, fy + fr);
          gr.addColorStop(0, colA);
          gr.addColorStop(1, colB);
          g.fillStyle = gr;
          g.fill();
        };
        if (L > 0.005) {
          water(top - 2 * u, amp * 0.5, 'rgba(116,192,252,.6)', 'rgba(28,126,214,.6)', 1.3);
          water(top, -amp * 0.6, '#74c0fc', '#1864ab', 0);
          // 거품
          for (let k = 0; k < 7; k++) {
            const by = fy + fr - ((t * (14 + hash(k, 2) * 12) * u + hash(k, 3) * 60 * u) % (fr * 2));
            if (by < top + 4 * u) continue;
            g.beginPath();
            g.arc(fx + (hash(k, 1) - 0.5) * fr * 1.2 + Math.sin(t * 3 + k) * 2 * u, by, (1 + hash(k, 4) * 1.6) * u, 0, TAU);
            g.strokeStyle = 'rgba(255,255,255,.6)';
            g.lineWidth = 0.8 * u;
            g.stroke();
          }
        }
        g.restore();
        flask();
        g.strokeStyle = 'rgba(220,240,255,.85)';
        g.lineWidth = 2.4 * u;
        g.lineJoin = 'round';
        g.stroke();
        g.strokeStyle = 'rgba(255,255,255,.5)';
        g.lineWidth = 3 * u;
        g.lineCap = 'round';
        g.beginPath();
        g.arc(fx, fy + 6 * u, fr - 6 * u, Math.PI * 1.05, Math.PI * 1.35);
        g.stroke();
        // 가로 막대
        const bx = 100 * u;
        const bw = w - bx - 16 * u;
        const by = compare ? h * 0.36 : h * 0.5;
        const bh = 32 * u;
        const cap = (): void => rr(g, bx, by - bh / 2, bw, bh, bh / 2);
        cap();
        g.fillStyle = 'rgba(200,235,255,.08)';
        g.fill();
        g.save();
        cap();
        g.clip();
        if (L > 0.003) {
          const front = bx + bw * L;
          g.beginPath();
          g.moveTo(bx, by - bh / 2);
          for (let k = 0; k <= 16; k++) {
            const y = by - bh / 2 + (bh * k) / 16;
            const x = front + Math.sin(y * 0.25 / u + t * 7) * 2.4 * u * (0.5 + Math.abs(amp) * 5) + (y - by) * amp * 0.6;
            g.lineTo(x, y);
          }
          g.lineTo(bx, by + bh / 2);
          g.closePath();
          const gr = g.createLinearGradient(0, by - bh / 2, 0, by + bh / 2);
          gr.addColorStop(0, '#8ce0ff');
          gr.addColorStop(1, '#1c6fd0');
          g.fillStyle = gr;
          g.fill();
          for (let k = 0; k < 6; k++) {
            const px = bx + ((t * (20 + k * 7) * u + k * 40 * u) % Math.max(1, bw * L));
            g.beginPath();
            g.arc(px, by + (hash(k, 8) - 0.5) * bh * 0.6, (0.8 + hash(k, 9)) * u, 0, TAU);
            g.fillStyle = 'rgba(255,255,255,.55)';
            g.fill();
          }
        }
        g.restore();
        cap();
        g.strokeStyle = 'rgba(220,240,255,.85)';
        g.lineWidth = 2.2 * u;
        g.stroke();
        rr(g, bx + 8 * u, by - bh / 2 + 4 * u, bw - 16 * u, 5 * u, 3 * u);
        g.fillStyle = 'rgba(255,255,255,.35)';
        g.fill();
        for (let k = 1; k < 4; k++) {
          g.fillStyle = 'rgba(255,255,255,.4)';
          g.fillRect(bx + (bw * k) / 4 - 0.6 * u, by + bh / 2 - 7 * u, 1.2 * u, 7 * u);
        }
        const pct = Math.round(clamp01(lv) * 100);
        otext(g, `${pct}%`, bx + bw / 2, by, 13 * u, '#fff', '#0b3a66', 3 * u);
        const fr2 = ['1/4', '2/4', '3/4', '4/4', '0'][i]!;
        txt(g, fr2, fx, h * 0.12, 11 * u, '#bfe6ff', 'center', 900);
        if (target === 1 && lv > 0.97) {
          const a = (t % STEP) - 0.5;
          sparks(g, bx + bw - 10 * u, by, a, 10, 4, 34 * u, ['#fff', '#a5d8ff'], 0.5, 0, 2.4 * u);
          if (a > 0) pill(g, '가득!', bx + bw - 22 * u, by - bh / 2 - 9 * u, 7.5 * u, '#40c057', '#fff');
        }
        if (compare) {
          const py = h * 0.74;
          txt(g, '주스 끔 — 그냥 막대', bx, py - 12 * u, 7.5 * u, '#8fa6c8', 'left', 700);
          rr(g, bx, py - 5 * u, bw, 12 * u, 2 * u);
          g.fillStyle = 'rgba(255,255,255,.12)';
          g.fill();
          g.fillStyle = '#4dabf7';
          g.fillRect(bx, py - 5 * u, bw * target, 12 * u);
          txt(g, `${Math.round(target * 100)}%`, bx + bw / 2, py + 1 * u, 7.5 * u, '#fff', 'center', 800);
        }
        reset(g);
      },
      controls: [tgl('그냥 막대와 비교', compare, (v) => (compare = v)), rng('출렁임', 0, 3, 0.1, wobble, (v) => (wobble = v))],
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [동전 빨려 들기 (UI 로 날아가기)](https://ai-techstudio.web.app/ai/t/i208.md) `i208`
- 다음에 해 볼 기술: [별점 터짐](https://ai-techstudio.web.app/ai/t/i211.md) `i211`
