---
code: "INT-30"
id: "c-forcecloud"
tab: "dynamic-2"
kind: "interactive"
title: "Force Bubble Cloud · 충돌 버블"
library: "Canvas"
deps: ["canvas"]
load: "high"
pasteKind: "custom-mount"
family: "collision-layout"
dataShape: "physics-driven bubble packing"
tasks: ["explore data", "filter subsets", "monitor change", "compare profiles", "show connections", "show grouped bubble crowd"]
tags: ["interactive", "exploration", "linked view", "multivariate", "network", "flow", "bubble cloud", "force", "bubble", "cloud", "충돌", "버블"]
gallery: "https://graph.heal7.com/#INT-30"
js: "/sku/INT-30.js"
---

# [INT-30] Force Bubble Cloud · 충돌 버블

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

## 언제 가져가나
엔터티 버블이 척력으로 자유 배치 — 마우스에 끌려와 부딪히며 모이는 인터랙티브 클라우드.

**응용** 키워드 클라우드, 직무 후보군, 팀원 아바타 그룹핑, 흥미·강점 매핑.

## 데이터 모양
- family: `collision-layout`
- dataShape: physics-driven bubble packing
- tasks: explore data, filter subsets, monitor change, compare profiles, show connections, show grouped bubble crowd
- tags: interactive, exploration, linked view, multivariate, network, flow, bubble cloud, force, bubble, cloud, 충돌, 버블


## 의존
canvas · load `high` · library `Canvas`

## 붙여넣는 법
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 dom = document.getElementById('c-forcecloud'); if(!dom) return;
  dom.innerHTML = '';
  dom.style.position='relative';
  const cnv = document.createElement('canvas');
  cnv.style.width='100%'; cnv.style.height='100%'; cnv.style.cursor='grab'; cnv.style.display='block';
  dom.appendChild(cnv);
  const hint = document.createElement('div');
  hint.style.cssText='position:absolute;top:8px;left:12px;color:#a59bbd;font-size:10px;pointer-events:none;z-index:1;line-height:1.5';
  hint.innerHTML='<b style="color:#e8c069">마우스</b> 인력 · <b>클릭</b> 폭발 · <b>우클릭</b> 인력↔척력 · <b>호버</b>=같은 색 연결';
  dom.appendChild(hint);

  const ctx = cnv.getContext('2d');
  let W=0,H=0,DPR=window.devicePixelRatio||1;
  let mx=null,my=null;
  let mode = 1; // 1=인력, -1=척력

  const labels = ['UX','전략','명리','격국','감정','콘텐츠','학습','분석','커뮤','리더','운영','협상','케어','조정','집중'];
  const palette = [T.gold, T.royal, T.rose, T.teal, T.plum];
  const nodes = labels.map((label,i)=>({
    label, color:palette[i%palette.length], catIdx:i%palette.length,
    r: 18 + Math.random()*14,
    x:0, y:0, vx:(Math.random()-.5)*.6, vy:(Math.random()-.5)*.6
  }));
  const ripples = [];
  let hoverIdx = -1;
  let initialized=false;
  function init(){ nodes.forEach(n=>{ n.x = n.r + Math.random()*(W-2*n.r); n.y = n.r + Math.random()*(H-2*n.r); }); initialized=true; }

  cnv.addEventListener('mousemove', e=>{
    const r=cnv.getBoundingClientRect();
    mx=e.clientX-r.left; my=e.clientY-r.top;
    let found=-1;
    for(let i=0;i<nodes.length;i++){
      const n=nodes[i]; if(Math.hypot(n.x-mx, n.y-my) < n.r){ found=i; break; }
    }
    hoverIdx = found;
    cnv.style.cursor = found>=0 ? 'pointer' : 'grab';
  });
  cnv.addEventListener('mouseleave', ()=>{ mx=null; my=null; hoverIdx=-1; });
  cnv.addEventListener('click', e=>{
    const r=cnv.getBoundingClientRect();
    const ex=e.clientX-r.left, ey=e.clientY-r.top;
    nodes.forEach(n=>{
      const dx=n.x-ex, dy=n.y-ey, d=Math.hypot(dx,dy)+1;
      const f = Math.min(18, 900/d);
      n.vx += (dx/d)*f; n.vy += (dy/d)*f;
    });
    ripples.push({x:ex, y:ey, r:0});
  });
  cnv.addEventListener('contextmenu', e=>{ e.preventDefault(); mode *= -1; });

  function frame(){
    if(W<=0||H<=0) return;
    if(!initialized) init();
    ctx.fillStyle = isDark() ? 'rgba(0,0,0,.14)' : 'rgba(255,255,255,.18)';
    ctx.fillRect(0,0,W,H);

    for(let i=0;i<nodes.length;i++){
      const a=nodes[i];
      for(let j=i+1;j<nodes.length;j++){
        const b=nodes[j];
        const dx=b.x-a.x, dy=b.y-a.y, d=Math.hypot(dx,dy)||0.001;
        const minD=a.r+b.r+6;
        if(d<minD){
          const f=(minD-d)*0.05, ux=dx/d, uy=dy/d;
          a.vx-=ux*f; a.vy-=uy*f; b.vx+=ux*f; b.vy+=uy*f;
        } else if(a.catIdx===b.catIdx){
          // 같은 카테고리 약한 인력 (long range)
          const k = Math.min(0.014, 7/(d*d));
          a.vx += (dx/d)*k; a.vy += (dy/d)*k;
          b.vx -= (dx/d)*k; b.vy -= (dy/d)*k;
        }
      }
      if(mx!=null){
        const dx=mx-a.x, dy=my-a.y, d=Math.hypot(dx,dy)||1;
        if(d<200){ a.vx+=dx/d*0.45*mode; a.vy+=dy/d*0.45*mode; }
      }
      a.vx*=0.92; a.vy*=0.92;
      a.x+=a.vx; a.y+=a.vy;
      if(a.x<a.r){ a.x=a.r; a.vx*=-.5; }
      if(a.x>W-a.r){ a.x=W-a.r; a.vx*=-.5; }
      if(a.y<a.r){ a.y=a.r; a.vy*=-.5; }
      if(a.y>H-a.r){ a.y=H-a.r; a.vy*=-.5; }
    }

    // ripple 갱신
    for(let i=ripples.length-1;i>=0;i--){
      const rp = ripples[i];
      rp.r += 8;
      const a = Math.max(0, 1 - rp.r/220);
      ctx.strokeStyle = `rgba(232,192,105,${a})`;
      ctx.lineWidth = 2;
      ctx.beginPath(); ctx.arc(rp.x, rp.y, rp.r, 0, Math.PI*2); ctx.stroke();
      if(rp.r>220) ripples.splice(i,1);
    }

    // hover 시 같은 카테고리 연결선
    if(hoverIdx>=0){
      const h = nodes[hoverIdx];
      ctx.strokeStyle = h.color;
      ctx.lineWidth = 1;
      ctx.globalAlpha = 0.55;
      for(let i=0;i<nodes.length;i++){
        if(i===hoverIdx) continue;
        const n = nodes[i];
        if(n.catIdx===h.catIdx){
          ctx.beginPath(); ctx.moveTo(h.x,h.y); ctx.lineTo(n.x,n.y); ctx.stroke();
        }
      }
      ctx.globalAlpha = 1;
    }

    // 마우스 모드 표시 (작은 링)
    if(mx!=null){
      ctx.strokeStyle = mode>0 ? 'rgba(232,192,105,.35)' : 'rgba(224,122,159,.35)';
      ctx.lineWidth = 1.5;
      ctx.beginPath(); ctx.arc(mx, my, 200, 0, Math.PI*2); ctx.stroke();
    }

    nodes.forEach((n,i)=>{
      const isHover = (i===hoverIdx);
      const rDraw = n.r * (isHover ? 1.15 : 1);
      const g=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,rDraw);
      g.addColorStop(0, n.color); g.addColorStop(1, 'rgba(0,0,0,0)');
      ctx.fillStyle=g; ctx.beginPath(); ctx.arc(n.x,n.y,rDraw,0,Math.PI*2); ctx.fill();
      if(isHover){
        ctx.strokeStyle='#fff'; ctx.lineWidth=1.5;
        ctx.beginPath(); ctx.arc(n.x,n.y,rDraw*0.7,0,Math.PI*2); ctx.stroke();
      }
      ctx.fillStyle = isDark() ? '#fff' : '#1a1230';
      ctx.font='600 11px Pretendard,system-ui,sans-serif';
      ctx.textAlign='center'; ctx.textBaseline='middle';
      ctx.fillText(n.label, n.x, n.y);
    });
    requestAnimationFrame(frame);
  }
  function fit(){ const r=cnv.getBoundingClientRect(); W=r.width; H=r.height; cnv.width=W*DPR; cnv.height=H*DPR; ctx.setTransform(DPR,0,0,DPR,0,0); if(W>0&&H>0) requestAnimationFrame(frame); }
  fit(); window.addEventListener('resize', fit);
  if(window.ResizeObserver){ const ro=new ResizeObserver(fit); ro.observe(dom); }
};
factory();
```

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

```js
function(){
  const dom = document.getElementById('c-forcecloud'); if(!dom) return;
  dom.innerHTML = '';
  dom.style.position='relative';
  const cnv = document.createElement('canvas');
  cnv.style.width='100%'; cnv.style.height='100%'; cnv.style.cursor='grab'; cnv.style.display='block';
  dom.appendChild(cnv);
  const hint = document.createElement('div');
  hint.style.cssText='position:absolute;top:8px;left:12px;color:#a59bbd;font-size:10px;pointer-events:none;z-index:1;line-height:1.5';
  hint.innerHTML='<b style="color:#e8c069">마우스</b> 인력 · <b>클릭</b> 폭발 · <b>우클릭</b> 인력↔척력 · <b>호버</b>=같은 색 연결';
  dom.appendChild(hint);

  const ctx = cnv.getContext('2d');
  let W=0,H=0,DPR=window.devicePixelRatio||1;
  let mx=null,my=null;
  let mode = 1; // 1=인력, -1=척력

  const labels = ['UX','전략','명리','격국','감정','콘텐츠','학습','분석','커뮤','리더','운영','협상','케어','조정','집중'];
  const palette = [T.gold, T.royal, T.rose, T.teal, T.plum];
  const nodes = labels.map((label,i)=>({
    label, color:palette[i%palette.length], catIdx:i%palette.length,
    r: 18 + Math.random()*14,
    x:0, y:0, vx:(Math.random()-.5)*.6, vy:(Math.random()-.5)*.6
  }));
  const ripples = [];
  let hoverIdx = -1;
  let initialized=false;
  function init(){ nodes.forEach(n=>{ n.x = n.r + Math.random()*(W-2*n.r); n.y = n.r + Math.random()*(H-2*n.r); }); initialized=true; }

  cnv.addEventListener('mousemove', e=>{
    const r=cnv.getBoundingClientRect();
    mx=e.clientX-r.left; my=e.clientY-r.top;
    let found=-1;
    for(let i=0;i<nodes.length;i++){
      const n=nodes[i]; if(Math.hypot(n.x-mx, n.y-my) < n.r){ found=i; break; }
    }
    hoverIdx = found;
    cnv.style.cursor = found>=0 ? 'pointer' : 'grab';
  });
  cnv.addEventListener('mouseleave', ()=>{ mx=null; my=null; hoverIdx=-1; });
  cnv.addEventListener('click', e=>{
    const r=cnv.getBoundingClientRect();
    const ex=e.clientX-r.left, ey=e.clientY-r.top;
    nodes.forEach(n=>{
      const dx=n.x-ex, dy=n.y-ey, d=Math.hypot(dx,dy)+1;
      const f = Math.min(18, 900/d);
      n.vx += (dx/d)*f; n.vy += (dy/d)*f;
    });
    ripples.push({x:ex, y:ey, r:0});
  });
  cnv.addEventListener('contextmenu', e=>{ e.preventDefault(); mode *= -1; });

  function frame(){
    if(W<=0||H<=0) return;
    if(!initialized) init();
    ctx.fillStyle = isDark() ? 'rgba(0,0,0,.14)' : 'rgba(255,255,255,.18)';
    ctx.fillRect(0,0,W,H);

    for(let i=0;i<nodes.length;i++){
      const a=nodes[i];
      for(let j=i+1;j<nodes.length;j++){
        const b=nodes[j];
        const dx=b.x-a.x, dy=b.y-a.y, d=Math.hypot(dx,dy)||0.001;
        const minD=a.r+b.r+6;
        if(d<minD){
          const f=(minD-d)*0.05, ux=dx/d, uy=dy/d;
          a.vx-=ux*f; a.vy-=uy*f; b.vx+=ux*f; b.vy+=uy*f;
        } else if(a.catIdx===b.catIdx){
          // 같은 카테고리 약한 인력 (long range)
          const k = Math.min(0.014, 7/(d*d));
          a.vx += (dx/d)*k; a.vy += (dy/d)*k;
          b.vx -= (dx/d)*k; b.vy -= (dy/d)*k;
        }
      }
      if(mx!=null){
        const dx=mx-a.x, dy=my-a.y, d=Math.hypot(dx,dy)||1;
        if(d<200){ a.vx+=dx/d*0.45*mode; a.vy+=dy/d*0.45*mode; }
      }
      a.vx*=0.92; a.vy*=0.92;
      a.x+=a.vx; a.y+=a.vy;
      if(a.x<a.r){ a.x=a.r; a.vx*=-.5; }
      if(a.x>W-a.r){ a.x=W-a.r; a.vx*=-.5; }
      if(a.y<a.r){ a.y=a.r; a.vy*=-.5; }
      if(a.y>H-a.r){ a.y=H-a.r; a.vy*=-.5; }
    }

    // ripple 갱신
    for(let i=ripples.length-1;i>=0;i--){
      const rp = ripples[i];
      rp.r += 8;
      const a = Math.max(0, 1 - rp.r/220);
      ctx.strokeStyle = `rgba(232,192,105,${a})`;
      ctx.lineWidth = 2;
      ctx.beginPath(); ctx.arc(rp.x, rp.y, rp.r, 0, Math.PI*2); ctx.stroke();
      if(rp.r>220) ripples.splice(i,1);
    }

    // hover 시 같은 카테고리 연결선
    if(hoverIdx>=0){
      const h = nodes[hoverIdx];
      ctx.strokeStyle = h.color;
      ctx.lineWidth = 1;
      ctx.globalAlpha = 0.55;
      for(let i=0;i<nodes.length;i++){
        if(i===hoverIdx) continue;
        const n = nodes[i];
        if(n.catIdx===h.catIdx){
          ctx.beginPath(); ctx.moveTo(h.x,h.y); ctx.lineTo(n.x,n.y); ctx.stroke();
        }
      }
      ctx.globalAlpha = 1;
    }

    // 마우스 모드 표시 (작은 링)
    if(mx!=null){
      ctx.strokeStyle = mode>0 ? 'rgba(232,192,105,.35)' : 'rgba(224,122,159,.35)';
      ctx.lineWidth = 1.5;
      ctx.beginPath(); ctx.arc(mx, my, 200, 0, Math.PI*2); ctx.stroke();
    }

    nodes.forEach((n,i)=>{
      const isHover = (i===hoverIdx);
      const rDraw = n.r * (isHover ? 1.15 : 1);
      const g=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,rDraw);
      g.addColorStop(0, n.color); g.addColorStop(1, 'rgba(0,0,0,0)');
      ctx.fillStyle=g; ctx.beginPath(); ctx.arc(n.x,n.y,rDraw,0,Math.PI*2); ctx.fill();
      if(isHover){
        ctx.strokeStyle='#fff'; ctx.lineWidth=1.5;
        ctx.beginPath(); ctx.arc(n.x,n.y,rDraw*0.7,0,Math.PI*2); ctx.stroke();
      }
      ctx.fillStyle = isDark() ? '#fff' : '#1a1230';
      ctx.font='600 11px Pretendard,system-ui,sans-serif';
      ctx.textAlign='center'; ctx.textBaseline='middle';
      ctx.fillText(n.label, n.x, n.y);
    });
    requestAnimationFrame(frame);
  }
  function fit(){ const r=cnv.getBoundingClientRect(); W=r.width; H=r.height; cnv.width=W*DPR; cnv.height=H*DPR; ctx.setTransform(DPR,0,0,DPR,0,0); if(W>0&&H>0) requestAnimationFrame(frame); }
  fit(); window.addEventListener('resize', fit);
  if(window.ResizeObserver){ const ro=new ResizeObserver(fit); ro.observe(dom); }
}
```

