---
code: "INT-28"
id: "c-magnet"
tab: "dynamic-2"
kind: "interactive"
title: "Magnetic Field · 자기장"
library: "Canvas"
deps: ["canvas"]
load: "high"
pasteKind: "custom-mount"
family: "vector-field"
dataShape: "interactive magnetic vector field"
tasks: ["explore data", "filter subsets", "monitor change", "show directional force field"]
tags: ["interactive", "exploration", "linked view", "magnetic field", "canvas", "magnetic", "field", "자기장"]
gallery: "https://graph.heal7.com/#INT-28"
js: "/sku/INT-28.js"
---

# [INT-28] Magnetic Field · 자기장

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

## 언제 가져가나
격자 위 화살표가 마우스 위치를 향해 자기장처럼 정렬 — 거리에 따라 강도 변화.

**응용** 인터랙티브 히어로 배경, 영향력 시각화, 추천 흐름 인스피레이션.

## 데이터 모양
- family: `vector-field`
- dataShape: interactive magnetic vector field
- tasks: explore data, filter subsets, monitor change, show directional force field
- tags: interactive, exploration, linked view, magnetic field, canvas, magnetic, field, 자기장


## 의존
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-magnet'); 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='crosshair';
  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> N극 · <b style="color:#6b8cff">Shift+클릭</b> S극 · <b>우클릭</b> 리셋';
  dom.appendChild(hint);

  const ctx = cnv.getContext('2d');
  let W=0,H=0,DPR=window.devicePixelRatio||1;
  let mx=null,my=null;
  const poles = []; // {x,y,kind:'N'|'S', born}

  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); }
  cnv.addEventListener('mousemove', e=>{ const r=cnv.getBoundingClientRect(); mx=e.clientX-r.left; my=e.clientY-r.top; });
  cnv.addEventListener('mouseleave', ()=>{ mx=null; my=null; });
  cnv.addEventListener('click', e=>{
    const r=cnv.getBoundingClientRect();
    poles.push({x:e.clientX-r.left, y:e.clientY-r.top, kind:e.shiftKey?'S':'N', born:performance.now()});
    if(poles.length>6) poles.shift();
  });
  cnv.addEventListener('contextmenu', e=>{ e.preventDefault(); poles.length=0; });

  // 활성 자극 = 고정 자극 + 마우스(N극, 임시)
  function activePoles(){
    const a = poles.slice();
    if(mx!=null) a.push({x:mx, y:my, kind:'N', isMouse:true});
    return a;
  }
  // 합성 자기장 — N에서 발산, S로 수렴. 1/r² 가중.
  function fieldAt(x, y, ps){
    if(!ps.length) return null;
    let fx=0, fy=0;
    for(const p of ps){
      const dx=x-p.x, dy=y-p.y;
      const d = Math.hypot(dx,dy)+2;
      const w = 6000/(d*d);
      const sign = p.kind==='N' ? 1 : -1;
      fx += sign * (dx/d) * w;
      fy += sign * (dy/d) * w;
    }
    const mag = Math.hypot(fx,fy);
    if(mag<1e-6) return null;
    return {ang:Math.atan2(fy,fx), intensity:Math.min(1, Math.log10(mag+1)/2.2)};
  }

  // 자극에서 짧은 streamline 트레이스
  function drawStreamline(sx, sy, dir, color, ps){
    ctx.strokeStyle=color; ctx.lineWidth=.7;
    ctx.beginPath(); ctx.moveTo(sx, sy);
    let x=sx, y=sy;
    for(let i=0;i<30;i++){
      const f = fieldAt(x,y,ps); if(!f) break;
      x += Math.cos(f.ang)*dir*5;
      y += Math.sin(f.ang)*dir*5;
      if(x<0||x>W||y<0||y>H) break;
      ctx.lineTo(x,y);
    }
    ctx.stroke();
  }

  function frame(t){
    if(W<=0||H<=0) return;
    ctx.clearRect(0,0,W,H);
    const ps = activePoles();

    // 격자 화살표
    const step = 22;
    for(let x=step/2; x<W; x+=step){
      for(let y=step/2; y<H; y+=step){
        let ang, intensity;
        if(ps.length>0){
          const f = fieldAt(x,y,ps);
          if(f){ ang = f.ang; intensity = Math.max(.18, f.intensity); }
          else  { ang = 0; intensity = .12; }
        } else {
          ang = Math.sin((x+y)/60 + (t||0)/1500)*Math.PI;
          intensity = .32;
        }
        const len = step*0.42 + intensity*6;
        ctx.strokeStyle = `rgba(232,192,105,${intensity})`;
        ctx.lineWidth = .8 + intensity*0.8;
        ctx.beginPath();
        ctx.moveTo(x - Math.cos(ang)*len*0.5, y - Math.sin(ang)*len*0.5);
        ctx.lineTo(x + Math.cos(ang)*len*0.5, y + Math.sin(ang)*len*0.5);
        ctx.stroke();
        ctx.fillStyle = `rgba(232,192,105,${intensity})`;
        const hx=x+Math.cos(ang)*len*0.5, hy=y+Math.sin(ang)*len*0.5;
        ctx.beginPath();
        ctx.moveTo(hx, hy);
        ctx.lineTo(hx - Math.cos(ang-0.5)*4.5, hy - Math.sin(ang-0.5)*4.5);
        ctx.lineTo(hx - Math.cos(ang+0.5)*4.5, hy - Math.sin(ang+0.5)*4.5);
        ctx.closePath(); ctx.fill();
      }
    }

    // 각 자극에서 10방향 streamline (N→발산 / S→수렴)
    for(const p of ps){
      const color = p.kind==='N' ? 'rgba(232,192,105,.42)' : 'rgba(107,140,255,.42)';
      const dir = p.kind==='N' ? 1 : -1;
      for(let k=0;k<10;k++){
        const a = k/10 * Math.PI*2;
        drawStreamline(p.x + Math.cos(a)*10, p.y + Math.sin(a)*10, dir, color, ps);
      }
    }

    // 자극 본체 + 펄스
    for(const p of ps){
      const isN = p.kind==='N';
      const color = isN ? '#e8c069' : '#6b8cff';
      const age = ((t||0) - (p.born||(t||0)))/1000;
      const pulseR = 12 + Math.sin(age*2)*4;
      const grad = ctx.createRadialGradient(p.x,p.y,0, p.x,p.y, pulseR+12);
      grad.addColorStop(0, color);
      grad.addColorStop(.35, color);
      grad.addColorStop(1, 'rgba(0,0,0,0)');
      ctx.fillStyle=grad;
      ctx.beginPath(); ctx.arc(p.x,p.y,pulseR+12,0,Math.PI*2); ctx.fill();
      ctx.fillStyle = '#0f0820';
      ctx.beginPath(); ctx.arc(p.x,p.y,7,0,Math.PI*2); ctx.fill();
      ctx.strokeStyle = color; ctx.lineWidth=1.5;
      ctx.beginPath(); ctx.arc(p.x,p.y,7,0,Math.PI*2); ctx.stroke();
      ctx.fillStyle='#fff';
      ctx.font='bold 9px system-ui,sans-serif';
      ctx.textAlign='center'; ctx.textBaseline='middle';
      ctx.fillText(isN?'N':'S', p.x, p.y);
    }

    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
};
factory();
```

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

```js
function(){
  const dom = document.getElementById('c-magnet'); 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='crosshair';
  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> N극 · <b style="color:#6b8cff">Shift+클릭</b> S극 · <b>우클릭</b> 리셋';
  dom.appendChild(hint);

  const ctx = cnv.getContext('2d');
  let W=0,H=0,DPR=window.devicePixelRatio||1;
  let mx=null,my=null;
  const poles = []; // {x,y,kind:'N'|'S', born}

  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); }
  cnv.addEventListener('mousemove', e=>{ const r=cnv.getBoundingClientRect(); mx=e.clientX-r.left; my=e.clientY-r.top; });
  cnv.addEventListener('mouseleave', ()=>{ mx=null; my=null; });
  cnv.addEventListener('click', e=>{
    const r=cnv.getBoundingClientRect();
    poles.push({x:e.clientX-r.left, y:e.clientY-r.top, kind:e.shiftKey?'S':'N', born:performance.now()});
    if(poles.length>6) poles.shift();
  });
  cnv.addEventListener('contextmenu', e=>{ e.preventDefault(); poles.length=0; });

  // 활성 자극 = 고정 자극 + 마우스(N극, 임시)
  function activePoles(){
    const a = poles.slice();
    if(mx!=null) a.push({x:mx, y:my, kind:'N', isMouse:true});
    return a;
  }
  // 합성 자기장 — N에서 발산, S로 수렴. 1/r² 가중.
  function fieldAt(x, y, ps){
    if(!ps.length) return null;
    let fx=0, fy=0;
    for(const p of ps){
      const dx=x-p.x, dy=y-p.y;
      const d = Math.hypot(dx,dy)+2;
      const w = 6000/(d*d);
      const sign = p.kind==='N' ? 1 : -1;
      fx += sign * (dx/d) * w;
      fy += sign * (dy/d) * w;
    }
    const mag = Math.hypot(fx,fy);
    if(mag<1e-6) return null;
    return {ang:Math.atan2(fy,fx), intensity:Math.min(1, Math.log10(mag+1)/2.2)};
  }

  // 자극에서 짧은 streamline 트레이스
  function drawStreamline(sx, sy, dir, color, ps){
    ctx.strokeStyle=color; ctx.lineWidth=.7;
    ctx.beginPath(); ctx.moveTo(sx, sy);
    let x=sx, y=sy;
    for(let i=0;i<30;i++){
      const f = fieldAt(x,y,ps); if(!f) break;
      x += Math.cos(f.ang)*dir*5;
      y += Math.sin(f.ang)*dir*5;
      if(x<0||x>W||y<0||y>H) break;
      ctx.lineTo(x,y);
    }
    ctx.stroke();
  }

  function frame(t){
    if(W<=0||H<=0) return;
    ctx.clearRect(0,0,W,H);
    const ps = activePoles();

    // 격자 화살표
    const step = 22;
    for(let x=step/2; x<W; x+=step){
      for(let y=step/2; y<H; y+=step){
        let ang, intensity;
        if(ps.length>0){
          const f = fieldAt(x,y,ps);
          if(f){ ang = f.ang; intensity = Math.max(.18, f.intensity); }
          else  { ang = 0; intensity = .12; }
        } else {
          ang = Math.sin((x+y)/60 + (t||0)/1500)*Math.PI;
          intensity = .32;
        }
        const len = step*0.42 + intensity*6;
        ctx.strokeStyle = `rgba(232,192,105,${intensity})`;
        ctx.lineWidth = .8 + intensity*0.8;
        ctx.beginPath();
        ctx.moveTo(x - Math.cos(ang)*len*0.5, y - Math.sin(ang)*len*0.5);
        ctx.lineTo(x + Math.cos(ang)*len*0.5, y + Math.sin(ang)*len*0.5);
        ctx.stroke();
        ctx.fillStyle = `rgba(232,192,105,${intensity})`;
        const hx=x+Math.cos(ang)*len*0.5, hy=y+Math.sin(ang)*len*0.5;
        ctx.beginPath();
        ctx.moveTo(hx, hy);
        ctx.lineTo(hx - Math.cos(ang-0.5)*4.5, hy - Math.sin(ang-0.5)*4.5);
        ctx.lineTo(hx - Math.cos(ang+0.5)*4.5, hy - Math.sin(ang+0.5)*4.5);
        ctx.closePath(); ctx.fill();
      }
    }

    // 각 자극에서 10방향 streamline (N→발산 / S→수렴)
    for(const p of ps){
      const color = p.kind==='N' ? 'rgba(232,192,105,.42)' : 'rgba(107,140,255,.42)';
      const dir = p.kind==='N' ? 1 : -1;
      for(let k=0;k<10;k++){
        const a = k/10 * Math.PI*2;
        drawStreamline(p.x + Math.cos(a)*10, p.y + Math.sin(a)*10, dir, color, ps);
      }
    }

    // 자극 본체 + 펄스
    for(const p of ps){
      const isN = p.kind==='N';
      const color = isN ? '#e8c069' : '#6b8cff';
      const age = ((t||0) - (p.born||(t||0)))/1000;
      const pulseR = 12 + Math.sin(age*2)*4;
      const grad = ctx.createRadialGradient(p.x,p.y,0, p.x,p.y, pulseR+12);
      grad.addColorStop(0, color);
      grad.addColorStop(.35, color);
      grad.addColorStop(1, 'rgba(0,0,0,0)');
      ctx.fillStyle=grad;
      ctx.beginPath(); ctx.arc(p.x,p.y,pulseR+12,0,Math.PI*2); ctx.fill();
      ctx.fillStyle = '#0f0820';
      ctx.beginPath(); ctx.arc(p.x,p.y,7,0,Math.PI*2); ctx.fill();
      ctx.strokeStyle = color; ctx.lineWidth=1.5;
      ctx.beginPath(); ctx.arc(p.x,p.y,7,0,Math.PI*2); ctx.stroke();
      ctx.fillStyle='#fff';
      ctx.font='bold 9px system-ui,sans-serif';
      ctx.textAlign='center'; ctx.textBaseline='middle';
      ctx.fillText(isN?'N':'S', p.x, p.y);
    }

    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}
```

