---
code: "INT-01"
id: "c-liquid"
tab: "dynamic-1"
kind: "interactive"
title: "Liquid Gauge · 물결 게이지"
library: "SVG+CSS"
deps: ["svg"]
load: "high"
pasteKind: "custom-mount"
family: "kpi"
dataShape: "headline value, rank race or progress state"
tasks: ["explore data", "monitor change", "drill into structure", "surface headline KPI"]
tags: ["interactive", "live", "exploration", "kpi", "progress", "liquid", "gauge", "물결", "게이지"]
gallery: "https://graph.heal7.com/#INT-01"
js: "/sku/INT-01.js"
---

# [INT-01] Liquid Gauge · 물결 게이지

> AI 붙여넣기 카드. 갤러리 런타임(`main.js`)이 아니라 이 파일을 가져와라.

## 언제 가져가나
물이 차오르는 액체 게이지 — 두 겹 사인파 애니메이션.

**응용** 진행률, 만족도, 잔여 에너지·체력 표시.

## 데이터 모양
- family: `kpi`
- dataShape: headline value, rank race or progress state
- tasks: explore data, monitor change, drill into structure, surface headline KPI
- tags: interactive, live, exploration, kpi, progress, liquid, gauge, 물결, 게이지


## 의존
svg · load `high` · library `SVG+CSS`

## 붙여넣는 법
1. 라이브러리 없이 host 엘리먼트에 직접 그린다
2. 함수 전체를 가져간다
3. `getElementById('원본id')` 만 대상 노드로 바꾼다

```js
const T = {
  "bg": "transparent",
  "fg": "#ece8f5",
  "sub": "#a59bbd",
  "grid": "rgba(255,255,255,.08)",
  "gold": "#e8c069",
  "royal": "#6b8cff",
  "rose": "#e07a9f",
  "teal": "#6dd2c4",
  "plum": "#b487e8"
};
const isDark = () => document.documentElement.getAttribute('data-theme') !== 'light';
const host = document.querySelector('#chart');
const factory = function(){
  const host = document.getElementById('c-liquid'); if(!host) return;
  host.innerHTML = `
    <svg id="lq-svg" viewBox="0 0 240 240" width="100%" height="100%" preserveAspectRatio="xMidYMid meet" style="cursor:ew-resize;display:block">
      <defs>
        <clipPath id="lq-clip"><circle cx="120" cy="120" r="92"/></clipPath>
        <linearGradient id="lq-g1" x1="0" x2="0" y1="0" y2="1">
          <stop id="lq-g1a" offset="0%" stop-color="#6b8cff"/>
          <stop id="lq-g1b" offset="100%" stop-color="#3a2a5e"/>
        </linearGradient>
        <linearGradient id="lq-g2" x1="0" x2="0" y1="0" y2="1">
          <stop offset="0%" stop-color="#e8c069" stop-opacity=".75"/>
          <stop offset="100%" stop-color="#b487e8" stop-opacity=".4"/>
        </linearGradient>
        <radialGradient id="lq-glow" cx="50%" cy="50%" r="50%">
          <stop offset="60%" stop-color="rgba(255,255,255,0)"/>
          <stop offset="100%" stop-color="rgba(232,192,105,.18)"/>
        </radialGradient>
      </defs>
      <circle cx="120" cy="120" r="105" fill="url(#lq-glow)"/>
      <circle cx="120" cy="120" r="105" fill="none" stroke="rgba(255,255,255,.08)" stroke-width="3"/>
      <circle id="lq-ring" cx="120" cy="120" r="105"
              fill="none" stroke="#e8c069" stroke-width="3"
              stroke-linecap="round" transform="rotate(-90 120 120)"
              stroke-dasharray="0 999"/>
      <circle cx="120" cy="120" r="92" fill="rgba(255,255,255,.03)"/>
      <g clip-path="url(#lq-clip)">
        <path id="lq-w0" fill="#3a2a5e" opacity=".55" d=""/>
        <path id="lq-w1" fill="url(#lq-g1)" d=""/>
        <g id="lq-bubbles"></g>
        <path id="lq-w2" fill="url(#lq-g2)" d=""/>
      </g>
      <circle cx="120" cy="120" r="92" fill="none" stroke="rgba(232,192,105,.5)" stroke-width="1.5"/>
      <text id="lq-pct" x="120" y="118" text-anchor="middle" fill="#fff" font-size="34" font-weight="700">68%</text>
      <text x="120" y="142" text-anchor="middle" fill="#a59bbd" font-size="10">드래그·호버로 조절 · 클릭=splash</text>
    </svg>`;
  const svg = host.querySelector('#lq-svg');
  const w0 = host.querySelector('#lq-w0');
  const w1 = host.querySelector('#lq-w1');
  const w2 = host.querySelector('#lq-w2');
  const ring = host.querySelector('#lq-ring');
  const pctText = host.querySelector('#lq-pct');
  const g1a = host.querySelector('#lq-g1a');
  const g1b = host.querySelector('#lq-g1b');
  const bubblesG = host.querySelector('#lq-bubbles');
  const NS = 'http://www.w3.org/2000/svg';

  // 거품 풀 (객체 풀 패턴 — 매 프레임 재할당 회피)
  const bubbles = [];
  for(let i=0;i<12;i++){
    const c = document.createElementNS(NS,'circle');
    c.setAttribute('r','2'); c.setAttribute('fill','rgba(255,255,255,.6)');
    c.setAttribute('opacity','0'); bubblesG.appendChild(c);
    bubbles.push({el:c, x:0, y:0, targetTop:0, r:0, vy:0, alive:false});
  }
  function spawnBubble(lv){
    for(const b of bubbles){
      if(b.alive) continue;
      b.alive = true;
      b.x = 40 + Math.random()*160;
      b.y = 230;
      b.targetTop = (220 - (140*lv/100)) + 6 + Math.random()*8;
      b.r = 1.5 + Math.random()*3;
      b.vy = 0.5 + Math.random()*1.0;
      b.el.setAttribute('r', b.r.toFixed(1));
      return;
    }
  }

  // 0%(rose) → 50%(gold) → 100%(royal) 색 보간
  function lerpHex(a,b,t){
    const pa = a.match(/\w\w/g).map(h=>parseInt(h,16));
    const pb = b.match(/\w\w/g).map(h=>parseInt(h,16));
    return '#' + pa.map((c,i)=> Math.round(c + (pb[i]-c)*t).toString(16).padStart(2,'0')).join('');
  }
  function colorFor(lv){
    return lv < 50
      ? {top: lerpHex('e07a9f','e8c069', lv/50),         bot:'#3a2a5e'}
      : {top: lerpHex('e8c069','6b8cff', (lv-50)/50),    bot:'#1a2050'};
  }

  let level = 68, targetLevel = 68;
  let mouseX = null, lastClick = -10;
  let phase = 0, autoNoise = 0, spawnCD = 0;

  function getX(e){
    const r = svg.getBoundingClientRect();
    const t = e.touches ? e.touches[0] : e;
    return ((t.clientX - r.left) / r.width) * 240;
  }
  function onMove(e){
    mouseX = getX(e);
    targetLevel = Math.max(2, Math.min(100, Math.round((mouseX/240)*100)));
  }
  svg.addEventListener('mousemove', onMove);
  svg.addEventListener('mouseleave', ()=>{ mouseX = null; });
  svg.addEventListener('click', e=>{
    lastClick = performance.now()/1000;
    for(let k=0;k<6;k++) spawnBubble(level);
  });
  svg.addEventListener('touchstart', onMove, {passive:true});
  svg.addEventListener('touchmove',  onMove, {passive:true});

  function tick(){
    phase += 0.035;
    autoNoise = autoNoise*0.97 + (Math.random()-0.5)*0.6;
    if(mouseX===null) targetLevel = Math.max(20, Math.min(95, targetLevel + autoNoise*0.4));
    level += (targetLevel - level) * 0.12;
    const baseY = 220 - (140 * level/100);

    const sinceClick = performance.now()/1000 - lastClick;
    const splash = sinceClick < .7 ? (1 - sinceClick/.7) * 6 : 0;

    const path = (off, amp, freq) => {
      let d = `M 0 ${baseY+off} `;
      for(let x=0; x<=240; x+=6) d += `L ${x} ${baseY+off + Math.sin((x/freq)+phase+off*.3)*amp} `;
      d += `L 240 240 L 0 240 Z`;
      return d;
    };
    w0.setAttribute('d', path(8, 4+splash, 30));
    w1.setAttribute('d', path(0, 6+splash, 24));
    w2.setAttribute('d', path(4, 4+splash, 18));

    const col = colorFor(level);
    g1a.setAttribute('stop-color', col.top);
    g1b.setAttribute('stop-color', col.bot);

    const C = 2*Math.PI*105;
    ring.setAttribute('stroke-dasharray', `${(C*level/100).toFixed(1)} ${C.toFixed(1)}`);
    ring.setAttribute('stroke', col.top);

    if(--spawnCD<=0){ spawnBubble(level); spawnCD = 10 + Math.floor(Math.random()*18); }
    for(const b of bubbles){
      if(!b.alive) continue;
      b.y -= b.vy;
      b.x += Math.sin(phase + b.y/30)*0.4;
      if(b.y < b.targetTop){
        b.alive = false; b.el.setAttribute('opacity','0');
      } else {
        b.el.setAttribute('cx', b.x.toFixed(1));
        b.el.setAttribute('cy', b.y.toFixed(1));
        b.el.setAttribute('opacity', '0.7');
      }
    }

    pctText.textContent = Math.round(level) + '%';
    requestAnimationFrame(tick);
  }
  tick();
};
factory();
```

## 원본 factory (갤러리 정본 발췌)
- file: `frontend/js/graph-gallery/main.js`
- lines: 837-986
- gallery: https://graph.heal7.com/#INT-01
- raw js: https://graph.heal7.com/sku/INT-01.js

```js
function(){
  const host = document.getElementById('c-liquid'); if(!host) return;
  host.innerHTML = `
    <svg id="lq-svg" viewBox="0 0 240 240" width="100%" height="100%" preserveAspectRatio="xMidYMid meet" style="cursor:ew-resize;display:block">
      <defs>
        <clipPath id="lq-clip"><circle cx="120" cy="120" r="92"/></clipPath>
        <linearGradient id="lq-g1" x1="0" x2="0" y1="0" y2="1">
          <stop id="lq-g1a" offset="0%" stop-color="#6b8cff"/>
          <stop id="lq-g1b" offset="100%" stop-color="#3a2a5e"/>
        </linearGradient>
        <linearGradient id="lq-g2" x1="0" x2="0" y1="0" y2="1">
          <stop offset="0%" stop-color="#e8c069" stop-opacity=".75"/>
          <stop offset="100%" stop-color="#b487e8" stop-opacity=".4"/>
        </linearGradient>
        <radialGradient id="lq-glow" cx="50%" cy="50%" r="50%">
          <stop offset="60%" stop-color="rgba(255,255,255,0)"/>
          <stop offset="100%" stop-color="rgba(232,192,105,.18)"/>
        </radialGradient>
      </defs>
      <circle cx="120" cy="120" r="105" fill="url(#lq-glow)"/>
      <circle cx="120" cy="120" r="105" fill="none" stroke="rgba(255,255,255,.08)" stroke-width="3"/>
      <circle id="lq-ring" cx="120" cy="120" r="105"
              fill="none" stroke="#e8c069" stroke-width="3"
              stroke-linecap="round" transform="rotate(-90 120 120)"
              stroke-dasharray="0 999"/>
      <circle cx="120" cy="120" r="92" fill="rgba(255,255,255,.03)"/>
      <g clip-path="url(#lq-clip)">
        <path id="lq-w0" fill="#3a2a5e" opacity=".55" d=""/>
        <path id="lq-w1" fill="url(#lq-g1)" d=""/>
        <g id="lq-bubbles"></g>
        <path id="lq-w2" fill="url(#lq-g2)" d=""/>
      </g>
      <circle cx="120" cy="120" r="92" fill="none" stroke="rgba(232,192,105,.5)" stroke-width="1.5"/>
      <text id="lq-pct" x="120" y="118" text-anchor="middle" fill="#fff" font-size="34" font-weight="700">68%</text>
      <text x="120" y="142" text-anchor="middle" fill="#a59bbd" font-size="10">드래그·호버로 조절 · 클릭=splash</text>
    </svg>`;
  const svg = host.querySelector('#lq-svg');
  const w0 = host.querySelector('#lq-w0');
  const w1 = host.querySelector('#lq-w1');
  const w2 = host.querySelector('#lq-w2');
  const ring = host.querySelector('#lq-ring');
  const pctText = host.querySelector('#lq-pct');
  const g1a = host.querySelector('#lq-g1a');
  const g1b = host.querySelector('#lq-g1b');
  const bubblesG = host.querySelector('#lq-bubbles');
  const NS = 'http://www.w3.org/2000/svg';

  // 거품 풀 (객체 풀 패턴 — 매 프레임 재할당 회피)
  const bubbles = [];
  for(let i=0;i<12;i++){
    const c = document.createElementNS(NS,'circle');
    c.setAttribute('r','2'); c.setAttribute('fill','rgba(255,255,255,.6)');
    c.setAttribute('opacity','0'); bubblesG.appendChild(c);
    bubbles.push({el:c, x:0, y:0, targetTop:0, r:0, vy:0, alive:false});
  }
  function spawnBubble(lv){
    for(const b of bubbles){
      if(b.alive) continue;
      b.alive = true;
      b.x = 40 + Math.random()*160;
      b.y = 230;
      b.targetTop = (220 - (140*lv/100)) + 6 + Math.random()*8;
      b.r = 1.5 + Math.random()*3;
      b.vy = 0.5 + Math.random()*1.0;
      b.el.setAttribute('r', b.r.toFixed(1));
      return;
    }
  }

  // 0%(rose) → 50%(gold) → 100%(royal) 색 보간
  function lerpHex(a,b,t){
    const pa = a.match(/\w\w/g).map(h=>parseInt(h,16));
    const pb = b.match(/\w\w/g).map(h=>parseInt(h,16));
    return '#' + pa.map((c,i)=> Math.round(c + (pb[i]-c)*t).toString(16).padStart(2,'0')).join('');
  }
  function colorFor(lv){
    return lv < 50
      ? {top: lerpHex('e07a9f','e8c069', lv/50),         bot:'#3a2a5e'}
      : {top: lerpHex('e8c069','6b8cff', (lv-50)/50),    bot:'#1a2050'};
  }

  let level = 68, targetLevel = 68;
  let mouseX = null, lastClick = -10;
  let phase = 0, autoNoise = 0, spawnCD = 0;

  function getX(e){
    const r = svg.getBoundingClientRect();
    const t = e.touches ? e.touches[0] : e;
    return ((t.clientX - r.left) / r.width) * 240;
  }
  function onMove(e){
    mouseX = getX(e);
    targetLevel = Math.max(2, Math.min(100, Math.round((mouseX/240)*100)));
  }
  svg.addEventListener('mousemove', onMove);
  svg.addEventListener('mouseleave', ()=>{ mouseX = null; });
  svg.addEventListener('click', e=>{
    lastClick = performance.now()/1000;
    for(let k=0;k<6;k++) spawnBubble(level);
  });
  svg.addEventListener('touchstart', onMove, {passive:true});
  svg.addEventListener('touchmove',  onMove, {passive:true});

  function tick(){
    phase += 0.035;
    autoNoise = autoNoise*0.97 + (Math.random()-0.5)*0.6;
    if(mouseX===null) targetLevel = Math.max(20, Math.min(95, targetLevel + autoNoise*0.4));
    level += (targetLevel - level) * 0.12;
    const baseY = 220 - (140 * level/100);

    const sinceClick = performance.now()/1000 - lastClick;
    const splash = sinceClick < .7 ? (1 - sinceClick/.7) * 6 : 0;

    const path = (off, amp, freq) => {
      let d = `M 0 ${baseY+off} `;
      for(let x=0; x<=240; x+=6) d += `L ${x} ${baseY+off + Math.sin((x/freq)+phase+off*.3)*amp} `;
      d += `L 240 240 L 0 240 Z`;
      return d;
    };
    w0.setAttribute('d', path(8, 4+splash, 30));
    w1.setAttribute('d', path(0, 6+splash, 24));
    w2.setAttribute('d', path(4, 4+splash, 18));

    const col = colorFor(level);
    g1a.setAttribute('stop-color', col.top);
    g1b.setAttribute('stop-color', col.bot);

    const C = 2*Math.PI*105;
    ring.setAttribute('stroke-dasharray', `${(C*level/100).toFixed(1)} ${C.toFixed(1)}`);
    ring.setAttribute('stroke', col.top);

    if(--spawnCD<=0){ spawnBubble(level); spawnCD = 10 + Math.floor(Math.random()*18); }
    for(const b of bubbles){
      if(!b.alive) continue;
      b.y -= b.vy;
      b.x += Math.sin(phase + b.y/30)*0.4;
      if(b.y < b.targetTop){
        b.alive = false; b.el.setAttribute('opacity','0');
      } else {
        b.el.setAttribute('cx', b.x.toFixed(1));
        b.el.setAttribute('cy', b.y.toFixed(1));
        b.el.setAttribute('opacity', '0.7');
      }
    }

    pctText.textContent = Math.round(level) + '%';
    requestAnimationFrame(tick);
  }
  tick();
}
```

