06放射軌道 — 同心円レベルを回りながら位置を定める
中心ノードを基準に同心円レベルを決め、各レベル内で角度がゆっくり回転しながら力の均衡を取ると、時計のように回る関係図が完成します。静的配置に回転を加えて画面を生かし続ける項目です。
使いどころレーダー関係図、ダッシュボードウィジェット
arcMath.sinMath.cosrequestAnimationFrame

<main class="stage">
<div class="gf is-demo" id="gfRoot" role="img" aria-label="同心円レベルに沿って回る放射状ネットワーク図の実験版" tabindex="0">
<canvas class="gf__cv" id="gfCv"></canvas>
<p class="gf__hint">
<span class="gf__title">放射軌道</span>
<span class="gf__desc">同心円のレベル内で角度がゆっくり回り力の均衡を保ちます</span>
</p>
<div class="gf__bar">
<span class="gf__meta">ノード24個 · 4軌道 · rAF</span>
<span class="gf__meta gf__meta--dim">arc · Math.sin · Math.cos</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 방사형 궤도(06) — 무대 노랑. 각도·반지름 전부 Math.sin/cos 로 매 프레임 다시 계산.
$gf-stage: #ffe9a8;
@include stage($gf-stage);
$gf-panel: #fff7d6;
$gf-text: #665f43;
$gf-node: #5a5470;
$gf-ctr: #c17b5e;
$duration: $dur-loop;
$easing: $ease-out;
$color: $gf-node;
.gf {
--gf-panel: #{$gf-panel};
--gf-node: #{$gf-node};
--gf-ctr: #{$gf-ctr};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px;
overflow: hidden;
}
.gf__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.gf__hint {
position: relative; z-index: 1;
display: flex; flex-direction: column; gap: 0; margin: 0;
padding: $sp-2 $sp-3 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $gf-text;
}
.gf__title { text-shadow: 0 1px 6px rgba($gf-panel, .9); }
.gf__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($gf-text, .78);
text-shadow: 0 1px 6px rgba($gf-panel, .9);
}
.gf__bar {
position: relative; z-index: 1;
margin-top: auto;
display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2;
padding: 0 $sp-3 $sp-2;
}
.gf__meta {
font-size: 10px; font-weight: 500; color: rgba($gf-text, .8);
background: rgba($gf-panel, .82);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.gf:focus-visible { outline: 2px solid $gf-node; outline-offset: -2px; }
@media (max-width: 340px) {
.gf__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.gf.is-still { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffe9a8;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gf {
--gf-panel: #fff7d6;
--gf-node: #5a5470;
--gf-ctr: #c17b5e;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.gf__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.gf__hint {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0;
margin: 0;
padding: 8px 12px 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #665f43;
}
.gf__title {
text-shadow: 0 1px 6px rgba(255, 247, 214, 0.9);
}
.gf__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(102, 95, 67, 0.78);
text-shadow: 0 1px 6px rgba(255, 247, 214, 0.9);
}
.gf__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.gf__meta {
font-size: 10px;
font-weight: 500;
color: rgba(102, 95, 67, 0.8);
background: rgba(255, 247, 214, 0.82);
border-radius: 4px;
padding: 4px 4px;
}
.gf:focus-visible {
outline: 2px solid #5a5470;
outline-offset: -2px;
}
@media (max-width: 340px) {
.gf__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gf.is-still {
cursor: default !important;
}
}// 放射軌道(06) — 각 노드는 자기 링의 반지름을 유지한 채 각도만 시간에 따라
// 회전한다(링마다 각속도가 다르다). 같은 링 안에서는 각도가 너무 가까워지면
// 접선 방향으로 서로 밀어내 겹치지 않는다 — 매 프레임 Math.sin/cos 로 좌표를 다시 계산한다.
(function () {
// ── 변형 상수 블록 ──
var RINGS = 4;
var PER_RING = 6;
var RSTEP = 32; // 링 사이 반지름 간격
// ────────────────────────
var RING_OMEGA = [0.5, -0.38, 0.62, -0.46]; // 링마다 다른 각속도(방향도 반대)
var SEP_K = 0.9;
var BREATHE = 14; // 반지름 숨쉬기 폭
var NR = 5;
var root = document.getElementById('gfRoot');
var cvEl = document.getElementById('gfCv');
if (!root || !cvEl) return;
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_PANEL = tokRGB('--gf-panel');
var C_NODE = tokRGB('--gf-node');
var C_CTR = tokRGB('--gf-ctr');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x327006);
var N, ring, angle, px, py;
function buildGraph() {
N = RINGS * PER_RING;
ring = new Int8Array(N);
angle = new Float32Array(N);
px = new Float32Array(N); py = new Float32Array(N);
var idx = 0;
for (var r = 0; r < RINGS; r++) {
for (var k = 0; k < PER_RING; k++) {
ring[idx] = r;
angle[idx] = (k / PER_RING) * Math.PI * 2 + rand() * 0.3;
idx++;
}
}
}
function fit() {
var r = root.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = Math.round(W * dpr); cvEl.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
buildGraph();
}
function step(dt, t) {
// 링별 각속도 회전
for (var i = 0; i < N; i++) {
angle[i] += RING_OMEGA[ring[i]] * dt;
}
// 같은 링끼리 각도 간격 벌리기(접선 힘, 각도 공간에서 직접 보정)
for (var a = 0; a < N; a++) {
for (var b = a + 1; b < N; b++) {
if (ring[a] !== ring[b]) continue;
var da = angle[a] - angle[b];
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
var minGap = (Math.PI * 2 / PER_RING) * 0.7;
if (Math.abs(da) < minGap && Math.abs(da) > 0.0001) {
var push = (minGap - Math.abs(da)) * SEP_K * dt;
var sign = da > 0 ? 1 : -1;
angle[a] += push * sign;
angle[b] -= push * sign;
}
}
}
var cx = W / 2, cy = H / 2;
for (var n = 0; n < N; n++) {
var baseR = (ring[n] + 1) * RSTEP;
var rr = baseR + Math.sin(t * 0.7 + n * 0.6) * BREATHE;
px[n] = cx + Math.cos(angle[n]) * rr;
py[n] = cy + Math.sin(angle[n]) * rr;
}
}
function draw(t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + C_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + C_CTR.join(',') + ')';
ctx.beginPath(); ctx.arc(W / 2, H / 2, 4, 0, Math.PI * 2); ctx.fill();
for (var i = 0; i < N; i++) {
var pulse = 0.5 + 0.5 * Math.sin(t * 2.4 + i * 0.5);
ctx.fillStyle = 'rgba(' + C_NODE.join(',') + ',' + (0.2 + 0.18 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(px[i], py[i], NR + 4 + pulse * 6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgb(' + C_NODE.join(',') + ')';
ctx.beginPath(); ctx.arc(px[i], py[i], NR, 0, Math.PI * 2); ctx.fill();
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1, last = -1;
function frame(now) {
if (t0 < 0) { t0 = now; last = now; }
var dt = Math.min((now - last) / 1000, 0.05); last = now;
var t = (now - t0) / 1000;
step(dt, t);
draw(t);
requestAnimationFrame(frame);
}
function settle(t) {
for (var s = 0; s < 200; s++) step(1 / 60, s / 60);
draw(t || 0);
}
window.addEventListener('resize', function () {
fit();
if (reduce) settle(1.2);
});
fit();
if (reduce) {
settle(1.2);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// GraphForceRadialOrbit — vanilla 판(run/327/demo/06-graph-force-radial-orbit/index.html) 의 React 포트.
// 각 노드는 자기 링의 반지름을 유지한 채 각도만 시간에 따라 회전한다(링마다 각속도가 다르다).
// 같은 링 안에서는 각도가 너무 가까워지면 접선 방향으로 서로 밀어내 겹치지 않는다 — 매 프레임
// Math.sin/cos 로 좌표를 다시 계산한다. 링별 각속도 자체가 그 링의 rotateAll 강체 회전이다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GraphForceRadialOrbit.module.scss";
export type GraphForceRadialOrbitProps = {
duration?: string;
easing?: string;
color?: string;
rings?: number;
perRing?: number;
rstep?: number; // 링 사이 반지름 간격
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const RINGS = 4;
const PER_RING = 6;
const RSTEP = 32;
const RING_OMEGA = [0.5, -0.38, 0.62, -0.46]; // 링마다 다른 각속도(방향도 반대) — OMEGA 값은 vanilla 와 일치해야 한다
const SEP_K = 0.9;
const BREATHE = 14; // 반지름 숨쉬기 폭
const NR = 5;
const SEED = 0x327006;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const BOARD_LABEL = "동심원 레벨을 따라 도는 방사형 관계도 실험판";
const META_LEFT = "노드 24개 · 4궤도 · rAF";
const META_RIGHT = "arc · Math.sin · Math.cos";
const HINT_TITLE = "방사형 궤도";
const HINT_DESC = "동심원 레벨 안에서 각도가 서서히 돌며 힘 균형을 잡습니다";
function makeRand(seed: number): () => number {
let st = seed >>> 0;
return function () {
st = (st + 0x6d2b79f5) >>> 0;
let t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GraphForceRadialOrbit({
duration = "2s",
easing = "ease-out",
color = "#5a5470",
rings = RINGS,
perRing = PER_RING,
rstep = RSTEP
}: GraphForceRadialOrbitProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ panel: RGB; node: RGB; ctr: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const randRef = useRef(makeRand(SEED)) as Box<() => number>;
const nRef = useRef(0) as Box<number>;
const ringRef = useRef<Int8Array | null>(null);
const angleRef = useRef<Float32Array | null>(null);
const pxRef = useRef<Float32Array | null>(null);
const pyRef = useRef<Float32Array | null>(null);
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function buildGraph(): void {
const rand = randRef.current;
const N = rings * perRing;
const ring = new Int8Array(N);
const angle = new Float32Array(N);
const px = new Float32Array(N), py = new Float32Array(N);
let idx = 0;
for (let r = 0; r < rings; r++) {
for (let k = 0; k < perRing; k++) {
ring[idx] = r;
angle[idx] = (k / perRing) * Math.PI * 2 + rand() * 0.3;
idx++;
}
}
nRef.current = N;
ringRef.current = ring; angleRef.current = angle;
pxRef.current = px; pyRef.current = py;
}
function fit(): void {
const root = rootRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!root || !cv || !ctx) return;
const r = root.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = Math.round(wRef.current * dpr);
cv.height = Math.round(hRef.current * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
buildGraph();
}
function step(dt: number, t: number): void {
const ring = ringRef.current, angle = angleRef.current, px = pxRef.current, py = pyRef.current;
const N = nRef.current;
const W = wRef.current, H = hRef.current;
if (!ring || !angle || !px || !py) return;
// 링별 각속도 회전
for (let i = 0; i < N; i++) {
angle[i] += RING_OMEGA[ring[i]] * dt;
}
// 같은 링끼리 각도 간격 벌리기(접선 힘, 각도 공간에서 직접 보정)
for (let a = 0; a < N; a++) {
for (let b = a + 1; b < N; b++) {
if (ring[a] !== ring[b]) continue;
let da = angle[a] - angle[b];
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
const minGap = (Math.PI * 2 / perRing) * 0.7;
if (Math.abs(da) < minGap && Math.abs(da) > 0.0001) {
const push = (minGap - Math.abs(da)) * SEP_K * dt;
const sign = da > 0 ? 1 : -1;
angle[a] += push * sign;
angle[b] -= push * sign;
}
}
}
const cx = W / 2, cy = H / 2;
for (let i = 0; i < N; i++) {
const baseR = (ring[i] + 1) * rstep;
const rr = baseR + Math.sin(t * 0.7 + i * 0.6) * BREATHE;
px[i] = cx + Math.cos(angle[i]) * rr;
py[i] = cy + Math.sin(angle[i]) * rr;
}
}
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const px = pxRef.current, py = pyRef.current;
const N = nRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !px || !py) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.ctr.join(",") + ")";
ctx.beginPath(); ctx.arc(W / 2, H / 2, 4, 0, Math.PI * 2); ctx.fill();
for (let i = 0; i < N; i++) {
const pulse = 0.5 + 0.5 * Math.sin(t * 2.4 + i * 0.5);
ctx.fillStyle = "rgba(" + col.node.join(",") + "," + (0.2 + 0.18 * pulse).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(px[i], py[i], NR + 4 + pulse * 6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "rgb(" + col.node.join(",") + ")";
ctx.beginPath(); ctx.arc(px[i], py[i], NR, 0, Math.PI * 2); ctx.fill();
}
}
function settle(t: number): void {
for (let s = 0; s < 200; s++) step(1 / 60, s / 60);
draw(t || 0);
}
let t0 = -1, last = -1;
function frame(now: number): void {
if (t0 < 0) { t0 = now; last = now; }
const dt = Math.min((now - last) / 1000, 0.05); last = now;
const t = (now - t0) / 1000;
step(dt, t);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
panel: tokRGB(root, "--gf-panel"),
node: tokRGB(root, "--gf-node"),
ctr: tokRGB(root, "--gf-ctr")
};
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) settle(1.2);
}
window.addEventListener("resize", onResize);
if (reduced()) {
settle(1.2);
setStill(true);
} else {
t0 = -1; last = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rings, perRing, rstep]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div
className={rootCls}
ref={rootRef}
style={vars}
role="img"
aria-label={BOARD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<p className={styles.hint}>
<span className={styles.title}>{HINT_TITLE}</span>
<span className={styles.desc}>{HINT_DESC}</span>
</p>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}06 放射軌道 — 同心円レベルを回りながら位置を定める — 計測 累積面積 35.355% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · ネットワークグラフ9種 — 力で押し引きし自ら並ぶキャンバス
このデモが属する記事
ネットワークグラフ9種 — 力で押し引きし自ら並ぶキャンバス点と線に反発力と引力を与えるだけで、絡み合った関係がひとりでに読みやすい図に整います。毎フレーム配置を計算し直すキャンバスエンジン — ライブラリなしのコピペです。ネットワークグラフとは点(ノード)と線(エッジ)でつながりを表す図のことで、 この記事のデモをすべて見る


