---
code: "INT-29"
id: "c-orbit"
tab: "dynamic-2"
kind: "interactive"
title: "Orbital Loader · 궤도 로더"
library: "Canvas"
deps: ["canvas"]
load: "high"
pasteKind: "custom-mount"
family: "orbital-system"
dataShape: "animated orbital bodies"
tasks: ["explore data", "filter subsets", "monitor change", "show orbital motion"]
tags: ["interactive", "exploration", "linked view", "orbit", "loader", "orbital", "궤도", "로더"]
gallery: "https://graph.heal7.com/#INT-29"
js: "/sku/INT-29.js"
---

# [INT-29] Orbital Loader · 궤도 로더

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

## 언제 가져가나
서로 다른 속도로 도는 다중 궤도 — 행성 시스템의 시각적 비유.

**응용** 로딩 인디케이터, 다축 진행도, 메디테이션 비주얼.

## 데이터 모양
- family: `orbital-system`
- dataShape: animated orbital bodies
- tasks: explore data, filter subsets, monitor change, show orbital motion
- tags: interactive, exploration, linked view, orbit, loader, orbital, 궤도, 로더


## 의존
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-orbit'); 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';
  hint.innerHTML='<b style="color:#b487e8">마우스</b> 중력 교란 · <b>클릭</b> 위성 발사';
  dom.appendChild(hint);

  // hex → "r,g,b"
  function rgb(hex){
    const h = hex.replace('#','');
    return parseInt(h.substr(0,2),16) + ',' + parseInt(h.substr(2,2),16) + ',' + parseInt(h.substr(4,2),16);
  }

  const ctx = cnv.getContext('2d');
  let W=0,H=0,DPR=window.devicePixelRatio||1;
  let mx=null,my=null;
  let stars = [];
  function makeStars(){
    const arr = []; const n = 60;
    for(let i=0;i<n;i++) arr.push({x:Math.random()*W, y:Math.random()*H, r:Math.random()*0.9+0.2, tw:Math.random()*Math.PI*2});
    return arr;
  }
  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); stars=makeStars(); 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; });

  const orbits = [
    {r:0.18, speed:1.3, color:T.gold,  rgb:rgb(T.gold),  size:6,   planets:1, trail:[]},
    {r:0.32, speed:0.8, color:T.royal, rgb:rgb(T.royal), size:5,   planets:2, trail:[]},
    {r:0.46, speed:0.5, color:T.rose,  rgb:rgb(T.rose),  size:4,   planets:3, trail:[]},
    {r:0.60, speed:0.3, color:T.teal,  rgb:rgb(T.teal),  size:3.5, planets:5, trail:[]}
  ];
  const userSats = [];
  cnv.addEventListener('click', e=>{
    const r=cnv.getBoundingClientRect();
    const x=e.clientX-r.left, y=e.clientY-r.top;
    const cx=W/2, cy=H/2;
    userSats.push({
      R: Math.max(20, Math.hypot(x-cx, y-cy)),
      ang0: Math.atan2(y-cy, x-cx),
      speed: 0.4 + Math.random()*0.8 * (Math.random()<.5?-1:1),
      size: 3, trail: []
    });
    if(userSats.length>6) userSats.shift();
  });

  function frame(t){
    if(W<=0||H<=0) return;
    // 페이드 — 잔상 trail 효과 (clearRect 대신). 테마 무관하게 카드와 어울리도록 분기.
    ctx.fillStyle = isDark() ? 'rgba(0,0,0,.18)' : 'rgba(255,255,255,.22)';
    ctx.fillRect(0,0,W,H);

    // 별 배경 (트윙클)
    for(const s of stars){
      const a = 0.25 + Math.sin((t||0)/600 + s.tw)*0.25;
      ctx.fillStyle = `rgba(255,255,255,${a})`;
      ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,Math.PI*2); ctx.fill();
    }

    const cx=W/2, cy=H/2, base=Math.min(W,H)/2 - 10;

    // 중심 별 + 회전 광선
    ctx.save();
    ctx.translate(cx,cy);
    ctx.rotate((t||0)/4000);
    for(let i=0;i<12;i++){
      ctx.rotate(Math.PI*2/12);
      ctx.fillStyle='rgba(232,192,105,.12)';
      ctx.beginPath(); ctx.moveTo(8,-1); ctx.lineTo(22,0); ctx.lineTo(8,1); ctx.closePath(); ctx.fill();
    }
    ctx.restore();
    const sun = ctx.createRadialGradient(cx,cy,0, cx,cy,22);
    sun.addColorStop(0,'rgba(232,192,105,.95)');
    sun.addColorStop(.6,'rgba(232,192,105,.45)');
    sun.addColorStop(1,'rgba(232,192,105,0)');
    ctx.fillStyle=sun;
    ctx.beginPath(); ctx.arc(cx,cy,22,0,Math.PI*2); ctx.fill();

    // 마우스 중력원 표시
    if(mx!=null){
      const gm = ctx.createRadialGradient(mx,my,0, mx,my,28);
      gm.addColorStop(0,'rgba(180,135,232,.35)');
      gm.addColorStop(1,'rgba(180,135,232,0)');
      ctx.fillStyle=gm;
      ctx.beginPath(); ctx.arc(mx,my,28,0,Math.PI*2); ctx.fill();
    }

    // 시스템 궤도 + 행성
    orbits.forEach(o=>{
      const R = base*o.r;
      ctx.strokeStyle='rgba(255,255,255,.07)'; ctx.lineWidth=1;
      ctx.beginPath(); ctx.arc(cx,cy,R,0,Math.PI*2); ctx.stroke();
      for(let p=0;p<o.planets;p++){
        const baseAng = (t||0)/1000 * o.speed + (p/o.planets)*Math.PI*2;
        let x = cx + Math.cos(baseAng)*R, y = cy + Math.sin(baseAng)*R;
        // 마우스 중력 perturbation
        if(mx!=null){
          const dx=mx-x, dy=my-y, d=Math.hypot(dx,dy)+1;
          if(d<140){
            const f = (140-d)/140 * 20;
            x += (dx/d)*f; y += (dy/d)*f;
          }
        }
        if(!o.trail[p]) o.trail[p] = [];
        const tr = o.trail[p]; tr.push([x,y]); if(tr.length>14) tr.shift();
        for(let k=0;k<tr.length;k++){
          const [tx,ty] = tr[k];
          const a = (k+1)/tr.length * 0.55;
          ctx.fillStyle = `rgba(${o.rgb},${a})`;
          ctx.beginPath(); ctx.arc(tx,ty, o.size*(0.4 + k/tr.length*0.7), 0, Math.PI*2); ctx.fill();
        }
        ctx.fillStyle=o.color;
        ctx.beginPath(); ctx.arc(x,y,o.size,0,Math.PI*2); ctx.fill();
      }
    });

    // 사용자 위성
    userSats.forEach(s=>{
      const ang = s.ang0 + (t||0)/1000 * s.speed;
      const x = cx + Math.cos(ang)*s.R, y = cy + Math.sin(ang)*s.R;
      s.trail.push([x,y]); if(s.trail.length>22) s.trail.shift();
      for(let k=0;k<s.trail.length;k++){
        const [tx,ty] = s.trail[k];
        const a = (k+1)/s.trail.length * 0.5;
        ctx.fillStyle = `rgba(255,255,255,${a})`;
        ctx.beginPath(); ctx.arc(tx,ty, s.size*(0.5 + k/s.trail.length*0.7), 0, Math.PI*2); ctx.fill();
      }
      ctx.fillStyle='#fff';
      ctx.beginPath(); ctx.arc(x,y,s.size+1,0,Math.PI*2); ctx.fill();
    });

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

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

```js
function(){
  const dom = document.getElementById('c-orbit'); 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';
  hint.innerHTML='<b style="color:#b487e8">마우스</b> 중력 교란 · <b>클릭</b> 위성 발사';
  dom.appendChild(hint);

  // hex → "r,g,b"
  function rgb(hex){
    const h = hex.replace('#','');
    return parseInt(h.substr(0,2),16) + ',' + parseInt(h.substr(2,2),16) + ',' + parseInt(h.substr(4,2),16);
  }

  const ctx = cnv.getContext('2d');
  let W=0,H=0,DPR=window.devicePixelRatio||1;
  let mx=null,my=null;
  let stars = [];
  function makeStars(){
    const arr = []; const n = 60;
    for(let i=0;i<n;i++) arr.push({x:Math.random()*W, y:Math.random()*H, r:Math.random()*0.9+0.2, tw:Math.random()*Math.PI*2});
    return arr;
  }
  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); stars=makeStars(); 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; });

  const orbits = [
    {r:0.18, speed:1.3, color:T.gold,  rgb:rgb(T.gold),  size:6,   planets:1, trail:[]},
    {r:0.32, speed:0.8, color:T.royal, rgb:rgb(T.royal), size:5,   planets:2, trail:[]},
    {r:0.46, speed:0.5, color:T.rose,  rgb:rgb(T.rose),  size:4,   planets:3, trail:[]},
    {r:0.60, speed:0.3, color:T.teal,  rgb:rgb(T.teal),  size:3.5, planets:5, trail:[]}
  ];
  const userSats = [];
  cnv.addEventListener('click', e=>{
    const r=cnv.getBoundingClientRect();
    const x=e.clientX-r.left, y=e.clientY-r.top;
    const cx=W/2, cy=H/2;
    userSats.push({
      R: Math.max(20, Math.hypot(x-cx, y-cy)),
      ang0: Math.atan2(y-cy, x-cx),
      speed: 0.4 + Math.random()*0.8 * (Math.random()<.5?-1:1),
      size: 3, trail: []
    });
    if(userSats.length>6) userSats.shift();
  });

  function frame(t){
    if(W<=0||H<=0) return;
    // 페이드 — 잔상 trail 효과 (clearRect 대신). 테마 무관하게 카드와 어울리도록 분기.
    ctx.fillStyle = isDark() ? 'rgba(0,0,0,.18)' : 'rgba(255,255,255,.22)';
    ctx.fillRect(0,0,W,H);

    // 별 배경 (트윙클)
    for(const s of stars){
      const a = 0.25 + Math.sin((t||0)/600 + s.tw)*0.25;
      ctx.fillStyle = `rgba(255,255,255,${a})`;
      ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,Math.PI*2); ctx.fill();
    }

    const cx=W/2, cy=H/2, base=Math.min(W,H)/2 - 10;

    // 중심 별 + 회전 광선
    ctx.save();
    ctx.translate(cx,cy);
    ctx.rotate((t||0)/4000);
    for(let i=0;i<12;i++){
      ctx.rotate(Math.PI*2/12);
      ctx.fillStyle='rgba(232,192,105,.12)';
      ctx.beginPath(); ctx.moveTo(8,-1); ctx.lineTo(22,0); ctx.lineTo(8,1); ctx.closePath(); ctx.fill();
    }
    ctx.restore();
    const sun = ctx.createRadialGradient(cx,cy,0, cx,cy,22);
    sun.addColorStop(0,'rgba(232,192,105,.95)');
    sun.addColorStop(.6,'rgba(232,192,105,.45)');
    sun.addColorStop(1,'rgba(232,192,105,0)');
    ctx.fillStyle=sun;
    ctx.beginPath(); ctx.arc(cx,cy,22,0,Math.PI*2); ctx.fill();

    // 마우스 중력원 표시
    if(mx!=null){
      const gm = ctx.createRadialGradient(mx,my,0, mx,my,28);
      gm.addColorStop(0,'rgba(180,135,232,.35)');
      gm.addColorStop(1,'rgba(180,135,232,0)');
      ctx.fillStyle=gm;
      ctx.beginPath(); ctx.arc(mx,my,28,0,Math.PI*2); ctx.fill();
    }

    // 시스템 궤도 + 행성
    orbits.forEach(o=>{
      const R = base*o.r;
      ctx.strokeStyle='rgba(255,255,255,.07)'; ctx.lineWidth=1;
      ctx.beginPath(); ctx.arc(cx,cy,R,0,Math.PI*2); ctx.stroke();
      for(let p=0;p<o.planets;p++){
        const baseAng = (t||0)/1000 * o.speed + (p/o.planets)*Math.PI*2;
        let x = cx + Math.cos(baseAng)*R, y = cy + Math.sin(baseAng)*R;
        // 마우스 중력 perturbation
        if(mx!=null){
          const dx=mx-x, dy=my-y, d=Math.hypot(dx,dy)+1;
          if(d<140){
            const f = (140-d)/140 * 20;
            x += (dx/d)*f; y += (dy/d)*f;
          }
        }
        if(!o.trail[p]) o.trail[p] = [];
        const tr = o.trail[p]; tr.push([x,y]); if(tr.length>14) tr.shift();
        for(let k=0;k<tr.length;k++){
          const [tx,ty] = tr[k];
          const a = (k+1)/tr.length * 0.55;
          ctx.fillStyle = `rgba(${o.rgb},${a})`;
          ctx.beginPath(); ctx.arc(tx,ty, o.size*(0.4 + k/tr.length*0.7), 0, Math.PI*2); ctx.fill();
        }
        ctx.fillStyle=o.color;
        ctx.beginPath(); ctx.arc(x,y,o.size,0,Math.PI*2); ctx.fill();
      }
    });

    // 사용자 위성
    userSats.forEach(s=>{
      const ang = s.ang0 + (t||0)/1000 * s.speed;
      const x = cx + Math.cos(ang)*s.R, y = cy + Math.sin(ang)*s.R;
      s.trail.push([x,y]); if(s.trail.length>22) s.trail.shift();
      for(let k=0;k<s.trail.length;k++){
        const [tx,ty] = s.trail[k];
        const a = (k+1)/s.trail.length * 0.5;
        ctx.fillStyle = `rgba(255,255,255,${a})`;
        ctx.beginPath(); ctx.arc(tx,ty, s.size*(0.5 + k/s.trail.length*0.7), 0, Math.PI*2); ctx.fill();
      }
      ctx.fillStyle='#fff';
      ctx.beginPath(); ctx.arc(x,y,s.size+1,0,Math.PI*2); ctx.fill();
    });

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

