---
code: "INT-17"
id: "c-particles"
tab: "dynamic-2"
kind: "interactive"
title: "Particle Constellation · 파티클 네트워크"
library: "Canvas"
deps: ["canvas"]
load: "high"
pasteKind: "custom-mount"
family: "ambient-network"
dataShape: "animated particle field"
tasks: ["explore data", "filter subsets", "monitor change", "show connections", "create living network backdrop"]
tags: ["interactive", "exploration", "linked view", "network", "flow", "particles", "canvas", "particle", "constellation", "파티클", "네트워크"]
gallery: "https://graph.heal7.com/#INT-17"
js: "/sku/INT-17.js"
---

# [INT-17] Particle Constellation · 파티클 네트워크

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

## 언제 가져가나
자유롭게 떠다니는 입자들이 일정 거리 안에서 서로 연결되어 별자리를 이룸. 마우스를 가까이 가져가면 입자가 끌려옵니다.

**응용** 인터랙티브 히어로 배경, 네트워크 토폴로지 살아있는 느낌, 데이터 인스피레이션 보드.

## 데이터 모양
- family: `ambient-network`
- dataShape: animated particle field
- tasks: explore data, filter subsets, monitor change, show connections, create living network backdrop
- tags: interactive, exploration, linked view, network, flow, particles, canvas, particle, constellation, 파티클, 네트워크


## 의존
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-particles'); if(!dom) return;
  const cv = document.createElement('canvas');
  cv.style.cssText = 'width:100%;height:100%;display:block;border-radius:8px';
  dom.appendChild(cv);
  const ctx = cv.getContext('2d');
  let W=0, H=0, parts=[], mouse={x:-9999,y:-9999};
  const COLOR_DOT = isDark() ? 'rgba(232,192,105,.85)' : 'rgba(160,120,32,.85)';
  const COLOR_LINE = isDark() ? 'rgba(232,192,105,' : 'rgba(160,120,32,';
  const COLOR_MOUSE = isDark() ? 'rgba(224,122,159,' : 'rgba(197,78,122,';
  function resize(){
    const r = cv.getBoundingClientRect();
    W = cv.width = Math.max(1, Math.floor(r.width * devicePixelRatio));
    H = cv.height = Math.max(1, Math.floor(r.height * devicePixelRatio));
    ctx.scale(1,1);
  }
  function init(){
    resize();
    parts = [];
    const N = Math.min(110, Math.max(50, Math.floor(W*H/(28000*devicePixelRatio*devicePixelRatio))));
    for(let i=0;i<N;i++) parts.push({
      x: Math.random()*W, y: Math.random()*H,
      vx: (Math.random()-.5)*0.4*devicePixelRatio, vy: (Math.random()-.5)*0.4*devicePixelRatio,
      r: (1 + Math.random()*1.6)*devicePixelRatio
    });
  }
  cv.addEventListener('mousemove', e => {
    const r = cv.getBoundingClientRect();
    mouse.x = (e.clientX - r.left)*devicePixelRatio;
    mouse.y = (e.clientY - r.top)*devicePixelRatio;
  });
  cv.addEventListener('mouseleave', () => { mouse.x = -9999; mouse.y = -9999; });
  window.addEventListener('resize', init);
  function step(){
    ctx.clearRect(0,0,W,H);
    const linkD = 110*devicePixelRatio, mouseD = 140*devicePixelRatio;
    // 입자 이동
    for(const p of parts){
      // 마우스 인력
      const dx = mouse.x - p.x, dy = mouse.y - p.y, d = Math.hypot(dx,dy);
      if(d < mouseD){ const f = (1 - d/mouseD)*0.6; p.vx += (dx/d)*f*0.1; p.vy += (dy/d)*f*0.1; }
      p.x += p.vx; p.y += p.vy;
      if(p.x<0||p.x>W) p.vx*=-1;
      if(p.y<0||p.y>H) p.vy*=-1;
      // 마찰
      p.vx *= 0.985; p.vy *= 0.985;
    }
    // 연결선
    for(let i=0;i<parts.length;i++){
      for(let j=i+1;j<parts.length;j++){
        const a=parts[i], b=parts[j];
        const dx=a.x-b.x, dy=a.y-b.y, d=Math.hypot(dx,dy);
        if(d < linkD){
          const op = (1 - d/linkD)*0.7;
          ctx.strokeStyle = COLOR_LINE + op.toFixed(3) + ')';
          ctx.lineWidth = 1*devicePixelRatio;
          ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
        }
      }
      // 마우스로 가는 선
      const dx = mouse.x - parts[i].x, dy = mouse.y - parts[i].y, d = Math.hypot(dx,dy);
      if(d < mouseD){
        const op = (1 - d/mouseD)*0.9;
        ctx.strokeStyle = COLOR_MOUSE + op.toFixed(3) + ')';
        ctx.lineWidth = 1.4*devicePixelRatio;
        ctx.beginPath(); ctx.moveTo(parts[i].x, parts[i].y); ctx.lineTo(mouse.x, mouse.y); ctx.stroke();
      }
    }
    // 입자
    for(const p of parts){
      ctx.fillStyle = COLOR_DOT;
      ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI*2); ctx.fill();
    }
    requestAnimationFrame(step);
  }
  // 첫 init은 다음 프레임 (탭 전환 시 너비 0 회피)
  setTimeout(()=>{ init(); step(); }, 50);
  // 탭 전환 시 reinit hook — charts 배열에 가짜 chart-like 객체로 등록
  charts.push({ resize: init });
};
factory();
```

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

```js
function(){
  const dom = document.getElementById('c-particles'); if(!dom) return;
  const cv = document.createElement('canvas');
  cv.style.cssText = 'width:100%;height:100%;display:block;border-radius:8px';
  dom.appendChild(cv);
  const ctx = cv.getContext('2d');
  let W=0, H=0, parts=[], mouse={x:-9999,y:-9999};
  const COLOR_DOT = isDark() ? 'rgba(232,192,105,.85)' : 'rgba(160,120,32,.85)';
  const COLOR_LINE = isDark() ? 'rgba(232,192,105,' : 'rgba(160,120,32,';
  const COLOR_MOUSE = isDark() ? 'rgba(224,122,159,' : 'rgba(197,78,122,';
  function resize(){
    const r = cv.getBoundingClientRect();
    W = cv.width = Math.max(1, Math.floor(r.width * devicePixelRatio));
    H = cv.height = Math.max(1, Math.floor(r.height * devicePixelRatio));
    ctx.scale(1,1);
  }
  function init(){
    resize();
    parts = [];
    const N = Math.min(110, Math.max(50, Math.floor(W*H/(28000*devicePixelRatio*devicePixelRatio))));
    for(let i=0;i<N;i++) parts.push({
      x: Math.random()*W, y: Math.random()*H,
      vx: (Math.random()-.5)*0.4*devicePixelRatio, vy: (Math.random()-.5)*0.4*devicePixelRatio,
      r: (1 + Math.random()*1.6)*devicePixelRatio
    });
  }
  cv.addEventListener('mousemove', e => {
    const r = cv.getBoundingClientRect();
    mouse.x = (e.clientX - r.left)*devicePixelRatio;
    mouse.y = (e.clientY - r.top)*devicePixelRatio;
  });
  cv.addEventListener('mouseleave', () => { mouse.x = -9999; mouse.y = -9999; });
  window.addEventListener('resize', init);
  function step(){
    ctx.clearRect(0,0,W,H);
    const linkD = 110*devicePixelRatio, mouseD = 140*devicePixelRatio;
    // 입자 이동
    for(const p of parts){
      // 마우스 인력
      const dx = mouse.x - p.x, dy = mouse.y - p.y, d = Math.hypot(dx,dy);
      if(d < mouseD){ const f = (1 - d/mouseD)*0.6; p.vx += (dx/d)*f*0.1; p.vy += (dy/d)*f*0.1; }
      p.x += p.vx; p.y += p.vy;
      if(p.x<0||p.x>W) p.vx*=-1;
      if(p.y<0||p.y>H) p.vy*=-1;
      // 마찰
      p.vx *= 0.985; p.vy *= 0.985;
    }
    // 연결선
    for(let i=0;i<parts.length;i++){
      for(let j=i+1;j<parts.length;j++){
        const a=parts[i], b=parts[j];
        const dx=a.x-b.x, dy=a.y-b.y, d=Math.hypot(dx,dy);
        if(d < linkD){
          const op = (1 - d/linkD)*0.7;
          ctx.strokeStyle = COLOR_LINE + op.toFixed(3) + ')';
          ctx.lineWidth = 1*devicePixelRatio;
          ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
        }
      }
      // 마우스로 가는 선
      const dx = mouse.x - parts[i].x, dy = mouse.y - parts[i].y, d = Math.hypot(dx,dy);
      if(d < mouseD){
        const op = (1 - d/mouseD)*0.9;
        ctx.strokeStyle = COLOR_MOUSE + op.toFixed(3) + ')';
        ctx.lineWidth = 1.4*devicePixelRatio;
        ctx.beginPath(); ctx.moveTo(parts[i].x, parts[i].y); ctx.lineTo(mouse.x, mouse.y); ctx.stroke();
      }
    }
    // 입자
    for(const p of parts){
      ctx.fillStyle = COLOR_DOT;
      ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI*2); ctx.fill();
    }
    requestAnimationFrame(step);
  }
  // 첫 init은 다음 프레임 (탭 전환 시 너비 0 회피)
  setTimeout(()=>{ init(); step(); }, 50);
  // 탭 전환 시 reinit hook — charts 배열에 가짜 chart-like 객체로 등록
  charts.push({ resize: init });
}
```

