ネットワークグラフ9種 — 力で押し引きし自ら並ぶキャンバス
点と線に反発力と引力を与えるだけで、絡み合った関係がひとりでに読みやすい図に整います。毎フレーム配置を計算し直すキャンバスエンジン — ライブラリなしのコピペです。
01 graph-force-basic-spring
02 graph-force-drag-node
03 graph-force-cluster-color
04 graph-force-collision-avoid
05 graph-force-hierarchy-tree
06 graph-force-radial-orbit
07 graph-force-link-weight
08 graph-force-pan-zoom-large
09 graph-force-hero-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 基本フォースレイアウト — 反発力とバネだけで散った点が整う
- 02 ドラッグ再配置 — 1つのノードを引くと全体が再びバランスを取る
- 03 クラスタ色分け — 同グループがより強く引き合い固まる
- 04 衝突回避 — ノードは自分の半径分だけ押し合い重ならない
- 05 階層ツリー — ルートから放射するレベル別配置
- 06 放射軌道 — 同心円レベルを回りながら位置を定める
- 07 関係の強さ — 重みがリンクの長さと太さを変える
- 08 大規模パン&ズーム — 数百のノードをキャンバス座標で見渡す
- 09 表紙の関係図 — サービス機能を力で結んで見せるヒーロー
デモ
各盤は index.html と style.css の2ファイル単独で動き、追加の読み込みは一切ありません。すべてのノードの組について反発力を合算し、エッジのばね力を足し、速度を減衰させて座標を動かす — この3行が9つすべての土台です。24フレームの録画で9つすべてが23フレーム動き続けました(23/23)。02と08はポインター操作ができ、残りの7つは開いた瞬間から自ら配置を解き始めます。この一覧はライブラリなしのネットワークグラフ 可視化を9つの断面に分けたものです。
| 盤 | 何が解けるか | 累積面積(%) | フレーム最大(%) | 強度平均 | フレーム |
|---|---|---|---|---|---|
| 01 基本フォース配置 | 反発+ばねの最小実装 | 46.931 | 10.174 | 53.7 | 23/23 |
| 02 ドラッグ再配置 | 掴んだ1点から全体が再平衡 | 39.459 | 7.694 | 53.7 | 23/23 |
| 03 クラスタ色分け | エッジ0本で色集団が分離 | 55.851 | 10.608 | 42.1 | 23/23 |
| 04 衝突回避 | 位置補正で重なり0 | 31.530 | 5.551 | 83.3 | 23/23 |
| 05 階層ツリー | ルートから放射する組織図 | 32.872 | 7.752 | 53.2 | 23/23 |
| 06 放射軌道 | 極座標で回るリング配置 | 35.355 | 6.037 | 57.3 | 23/23 |
| 07 関係の強さ | 重みが長さと太さを決める | 47.947 | 9.865 | 55.6 | 23/23 |
| 08 大規模パン&ズーム | 140ノードのカメラ漫遊 | 70.841 | 21.407 | 28.3 | 23/23 |
| 09 表紙の関係図 | 機能名が脈動するヒーロー | 36.535 | 6.272 | 52.7 | 23/23 |
累積面積が最も広いのは08の70.841%です。カメラが1400×900のワールド座標をゆっくり見渡し、140個のノードが毎フレーム別の場所を埋めるため、画面の7割が塗り替わり続けます。逆に強度平均が最も高いのは04の83.3で、これは力ではなく位置そのものを押し返す衝突補正パスが画素をはっきり反転させるためです。面積と明るさは別の指標であることが、この2つの盤の対比から読み取れます。
反発力とばねから極座標まで、グラフ座標を求め直した順序
並び順の基準は派手さではなく力の項がどこへ1つ加わるかです。01は反発とばねだけの原形で、02が「掴んだ1点を固定する」という操作の項を、03はエッジを全部外して反発の非対称という項を足します。04は力の積分の後に位置を直す補正パスを重ね、05・06は平面上の自由な動きを放射・極座標という拘束に置き換え、07はエッジごとの重みというデータ側の項を加えます。08は力とカメラを分離して規模を伸ばし、09がラベルとグロウで第一印象を担う幕引きです。311の反応拡散から続く計算軸の系譜で、ここまでのどの回も「ノードとエッジの関係図を力で並べる」は扱っていませんでした。
項目別コード
ここからは各盤の定数と、動いている関数をそのまま引きます。切り詰めた断片ではなく動いているコードそのもので、コメントだけこの記事の言葉に置き換えてあり、変数名と関数名は zip の中身と同じ英語のままです。コードごとコピペすればそのままノードグラフ 作成の土台になります。
01基本フォース配置 — 反発力とばねだけで散った点が整う
すべてのノードの組 i<j について距離の2乗の逆数に比例する反発力 REPEL/d² を掛け、エッジで繋がった組には (距離−REST)×ATTRACT のばね力を足します。合力を dt で速度に積分し DAMP 0.955 で減衰して座標を動かす — プロパティのトゥイーンではなく力の積分で動く、フォースレイアウトの最小単位です。グラフはスパニングツリーで連結を保証した上に N×0.5 本のランダムエッジを足しています。累積面積46.931%・強度平均53.7は18ノードの密な再配置として妥当な値です。

<main class="stage">
<div class="ga is-demo" id="gaRoot" role="application" aria-label="フォース配置ネットワーク図の実験版" tabindex="0">
<canvas class="ga__cv" id="gaCv"></canvas>
<p class="ga__hint">
<span class="ga__title">基本フォース配置</span>
<span class="ga__desc">反発力とばね力だけで散らばった点が自然に整います</span>
</p>
<div class="ga__bar">
<span class="ga__meta">ノード18個 · 反発+ばね · rAF</span>
<span class="ga__meta ga__meta--dim">arc · lineTo · Math.hypot</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 기본 힘 배치(01) — @keyframes 를 두지 않는다. 움직임은 전부 캔버스 rAF 루프
// (매 프레임 척력·스프링 재계산)가 만든다. 무대 잉크 — 어두운 판 위 파스텔 노드.
$ga-stage: #1a1720;
@include stage($ga-stage);
$ga-panel: #241f2d; // 캔버스 바닥 — 무대보다 한 단계 밝은 먹보라
$ga-text: #d5cfe6; // 라벨 글자 — 밝은 라일락 회색
$ga-edge: #6f6a82; // 링크 선 — 저채도 회보라
$ga-node: #cfd9e7; // 노드 — 파스텔 아이리스 블루
$ga-glow: #e8d5e2; // 노드 둘레 광 — 벚꽃 파스텔
$duration: $dur-loop;
$easing: $ease-out;
$color: $ga-node;
.ga {
--ga-panel: #{$ga-panel};
--ga-edge: #{$ga-edge};
--ga-node: #{$ga-node};
--ga-glow: #{$ga-glow};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px;
overflow: hidden;
}
.ga__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.ga__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: $ga-text;
}
.ga__title { text-shadow: 0 1px 6px rgba($ga-panel, 1); }
.ga__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($ga-text, .78);
text-shadow: 0 1px 6px rgba($ga-panel, 1);
}
.ga__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;
}
.ga__meta {
font-size: 10px; font-weight: 500; color: rgba($ga-text, .78);
background: rgba($ga-panel, .8);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.ga:focus-visible { outline: 2px solid $ga-node; outline-offset: -2px; }
@media (max-width: 340px) {
.ga__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 정착된 배치 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.ga.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: #1a1720;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ga {
--ga-panel: #241f2d;
--ga-edge: #6f6a82;
--ga-node: #cfd9e7;
--ga-glow: #e8d5e2;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.ga__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.ga__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: #d5cfe6;
}
.ga__title {
text-shadow: 0 1px 6px #241f2d;
}
.ga__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(213, 207, 230, 0.78);
text-shadow: 0 1px 6px #241f2d;
}
.ga__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.ga__meta {
font-size: 10px;
font-weight: 500;
color: rgba(213, 207, 230, 0.78);
background: rgba(36, 31, 45, 0.8);
border-radius: 4px;
padding: 4px 4px;
}
.ga:focus-visible {
outline: 2px solid #cfd9e7;
outline-offset: -2px;
}
@media (max-width: 340px) {
.ga__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ga.is-still {
cursor: default !important;
}
}// 基本フォース配置(01) — 모든 노드 쌍의 척력(쿨롱형)과 엣지의 스프링(훅 법칙)을
// 매 프레임 합산해 좌표 자체를 다시 계산한다. 속성 트윈이 아니라 계산으로 움직인다.
// 흩어진 시작점에서 출발해 실시간으로 정돈된 그림으로 수렴하고, 수렴한 뒤에도
// 아주 약한 무작위 힘(온도)을 계속 더해 화면이 완전히 멈추지 않게 한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N = 18; // 노드 개수
var ATTRACT = 0.02; // 스프링 강도(k) — 변형 1: 0.02 → 0.08
var REPEL = 900; // 척력 강도
// ────────────────────────────────────────────────────
var REST = 46; // 스프링 목표 길이
var DAMP = 0.955; // 속도 감쇠 — 낮출수록 오래 출렁인다
var CENTER_K = 0.0009; // 중심으로 약하게 당기는 힘 — 그래프가 화면 밖으로 흘러가지 않게
var JITTER = 50; // 항상 더하는 무작위 힘(온도) — 화면 전역이 계속 출렁이게 한다
var NR = 5; // 노드 반지름
var OMEGA = 0.5; // 전체를 중심 기준 아주 천천히 돌리는 각속도(rad/s) — 화면 넓게 훑는다
var root = document.getElementById('gaRoot');
var cvEl = document.getElementById('gaCv');
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('--ga-panel');
var C_EDGE = tokRGB('--ga-edge');
var C_NODE = tokRGB('--ga-node');
var C_GLOW = tokRGB('--ga-glow');
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(0x327001);
var px, py, vx, vy, edges;
function buildGraph() {
px = new Float32Array(N); py = new Float32Array(N);
vx = new Float32Array(N); vy = new Float32Array(N);
for (var i = 0; i < N; i++) {
px[i] = rand() * W; py[i] = rand() * H;
}
edges = [];
for (var a = 1; a < N; a++) {
edges.push([a, Math.floor(rand() * a)]); // 스패닝 트리 — 모든 노드가 연결된다
}
var extra = Math.round(N * 0.5);
for (var k = 0; k < extra; k++) {
var u = Math.floor(rand() * N), v = Math.floor(rand() * N);
if (u !== v) edges.push([u, v]);
}
}
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) {
var fx = new Float32Array(N), fy = new Float32Array(N);
for (var i = 0; i < N; i++) {
for (var j = i + 1; j < N; j++) {
var dx = px[j] - px[i], dy = py[j] - py[i];
var d2 = dx * dx + dy * dy; if (d2 < 4) d2 = 4;
var d = Math.sqrt(d2);
var f = REPEL / d2;
var ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
for (var e = 0; e < edges.length; e++) {
var a = edges[e][0], b = edges[e][1];
var dx2 = px[b] - px[a], dy2 = py[b] - py[a];
var d3 = Math.hypot(dx2, dy2) || 0.001;
var diff = (d3 - REST) * ATTRACT;
var ux2 = dx2 / d3, uy2 = dy2 / d3;
fx[a] += ux2 * diff; fy[a] += uy2 * diff;
fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
}
var cx = W / 2, cy = H / 2;
for (var n = 0; n < N; n++) {
fx[n] += (cx - px[n]) * CENTER_K;
fy[n] += (cy - py[n]) * CENTER_K;
fx[n] += (rand() - 0.5) * JITTER;
fy[n] += (rand() - 0.5) * JITTER;
vx[n] = (vx[n] + fx[n] * dt) * DAMP;
vy[n] = (vy[n] + fy[n] * dt) * DAMP;
px[n] += vx[n] * dt; py[n] += vy[n] * dt;
}
}
function rotateAll(dtheta) {
var cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (var i = 0; i < N; i++) {
var rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
function draw(t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + C_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
ctx.lineWidth = 1.2;
ctx.strokeStyle = 'rgba(' + C_EDGE.join(',') + ',0.55)';
ctx.beginPath();
for (var e = 0; e < edges.length; e++) {
var a = edges[e][0], b = edges[e][1];
ctx.moveTo(px[a], py[a]); ctx.lineTo(px[b], py[b]);
}
ctx.stroke();
for (var i = 0; i < N; i++) {
var pulse = 0.5 + 0.5 * Math.sin(t * 2.2 + i * 0.7);
ctx.fillStyle = 'rgba(' + C_GLOW.join(',') + ',' + (0.24 + 0.2 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(px[i], py[i], NR + 4 + pulse * 7, 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);
rotateAll(OMEGA * dt);
draw(t);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) settle();
});
function settle() {
for (var s = 0; s < 260; s++) step(1 / 60);
draw(0);
}
fit();
if (reduce) {
settle();
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// GraphForceBasicSpring — vanilla 판(run/327/demo/01-graph-force-basic-spring/index.html) 의 React 포트.
// 모든 노드 쌍의 척력(쿨롱형)과 엣지의 스프링(훅 법칙)을 매 프레임 합산해 좌표 자체를 다시 계산한다.
// 속성 트윈이 아니라 계산으로 움직인다. 흩어진 시작점에서 정돈된 그림으로 수렴하고, 수렴한 뒤에도
// 아주 약한 무작위 힘(온도)을 계속 더해 화면이 완전히 멈추지 않는다. 전체는 rotateAll 로 천천히 강체 회전한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GraphForceBasicSpring.module.scss";
export type GraphForceBasicSpringProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 노드 개수
attract?: number; // 스프링 강도(k) — 변형 1: 0.02 → 0.08
repel?: number; // 척력 강도
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const N = 18;
const ATTRACT = 0.02;
const REPEL = 900;
const REST = 46; // 스프링 목표 길이
const DAMP = 0.955; // 속도 감쇠 — 낮출수록 오래 출렁인다
const CENTER_K = 0.0009; // 중심으로 약하게 당기는 힘
const JITTER = 50; // 항상 더하는 무작위 힘(온도)
const NR = 5; // 노드 반지름
const OMEGA = 0.5; // 전체를 중심 기준 천천히 돌리는 각속도(rad/s)
const SEED = 0x327001;
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 = "노드 18개 · 척력+스프링 · rAF";
const META_RIGHT = "arc · lineTo · Math.hypot";
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 GraphForceBasicSpring({
duration = "2s",
easing = "ease-out",
color = "#cfd9e7",
n = N,
attract = ATTRACT,
repel = REPEL
}: GraphForceBasicSpringProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ panel: RGB; edge: RGB; node: RGB; glow: 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 pxRef = useRef<Float32Array | null>(null);
const pyRef = useRef<Float32Array | null>(null);
const vxRef = useRef<Float32Array | null>(null);
const vyRef = useRef<Float32Array | null>(null);
const edgesRef = useRef<[number, number][]>([]);
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 W = wRef.current, H = hRef.current;
const px = new Float32Array(n), py = new Float32Array(n);
const vx = new Float32Array(n), vy = new Float32Array(n);
for (let i = 0; i < n; i++) {
px[i] = rand() * W; py[i] = rand() * H;
}
const edges: [number, number][] = [];
for (let a = 1; a < n; a++) {
edges.push([a, Math.floor(rand() * a)]); // 스패닝 트리 — 모든 노드가 연결된다
}
const extra = Math.round(n * 0.5);
for (let k = 0; k < extra; k++) {
const u = Math.floor(rand() * n), v = Math.floor(rand() * n);
if (u !== v) edges.push([u, v]);
}
pxRef.current = px; pyRef.current = py; vxRef.current = vx; vyRef.current = vy;
edgesRef.current = edges;
}
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): void {
const px = pxRef.current, py = pyRef.current, vx = vxRef.current, vy = vyRef.current;
const edges = edgesRef.current;
const rand = randRef.current;
const W = wRef.current, H = hRef.current;
if (!px || !py || !vx || !vy || !edges) return;
const fx = new Float32Array(n), fy = new Float32Array(n);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const dx = px[j] - px[i], dy = py[j] - py[i];
let d2 = dx * dx + dy * dy; if (d2 < 4) d2 = 4;
const d = Math.sqrt(d2);
const f = repel / d2;
const ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
for (let e = 0; e < edges.length; e++) {
const a = edges[e][0], b = edges[e][1];
const dx2 = px[b] - px[a], dy2 = py[b] - py[a];
const d3 = Math.hypot(dx2, dy2) || 0.001;
const diff = (d3 - REST) * attract;
const ux2 = dx2 / d3, uy2 = dy2 / d3;
fx[a] += ux2 * diff; fy[a] += uy2 * diff;
fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
}
const cx = W / 2, cy = H / 2;
for (let i = 0; i < n; i++) {
fx[i] += (cx - px[i]) * CENTER_K;
fy[i] += (cy - py[i]) * CENTER_K;
fx[i] += (rand() - 0.5) * JITTER;
fy[i] += (rand() - 0.5) * JITTER;
vx[i] = (vx[i] + fx[i] * dt) * DAMP;
vy[i] = (vy[i] + fy[i] * dt) * DAMP;
px[i] += vx[i] * dt; py[i] += vy[i] * dt;
}
}
function rotateAll(dtheta: number): void {
const px = pxRef.current, py = pyRef.current;
if (!px || !py) return;
const W = wRef.current, H = hRef.current;
const cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (let i = 0; i < n; i++) {
const rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const px = pxRef.current, py = pyRef.current;
const edges = edgesRef.current;
const W = wRef.current, H = hRef.current;
const rand = randRef.current;
if (!ctx || !col || !px || !py || !edges) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
ctx.lineWidth = 1.2;
ctx.strokeStyle = "rgba(" + col.edge.join(",") + ",0.55)";
ctx.beginPath();
for (let e = 0; e < edges.length; e++) {
const a = edges[e][0], b = edges[e][1];
ctx.moveTo(px[a], py[a]); ctx.lineTo(px[b], py[b]);
}
ctx.stroke();
for (let i = 0; i < n; i++) {
const pulse = 0.5 + 0.5 * Math.sin(t * 2.2 + i * 0.7);
ctx.fillStyle = "rgba(" + col.glow.join(",") + "," + (0.24 + 0.2 * pulse).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(px[i], py[i], NR + 4 + pulse * 7, 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(): void {
for (let s = 0; s < 260; s++) step(1 / 60);
draw(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);
rotateAll(OMEGA * dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
panel: tokRGB(root, "--ga-panel"),
edge: tokRGB(root, "--ga-edge"),
node: tokRGB(root, "--ga-node"),
glow: tokRGB(root, "--ga-glow")
};
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) settle();
}
window.addEventListener("resize", onResize);
if (reduced()) {
settle();
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
}, [n, attract, repel]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div
className={rootCls}
ref={rootRef}
style={vars}
role="application"
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>
);
}01 · 基本フォースレイアウト — 反発力とバネだけで散った点が整う — 計測 累積面積 46.931% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
step — 反発とばねの合算
ペアごとの反発は O(n²) の全走査、ばねはエッジ本数分だけです。d² は最小4にクランプし、ゼロ除算と無限大を同時に防ぎます。
function step(dt) {
var fx = new Float32Array(N), fy = new Float32Array(N);
for (var i = 0; i < N; i++) {
for (var j = i + 1; j < N; j++) {
var dx = px[j] - px[i], dy = py[j] - py[i];
var d2 = dx * dx + dy * dy; if (d2 < 4) d2 = 4;
var d = Math.sqrt(d2);
var f = REPEL / d2;
var ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
for (var e = 0; e < edges.length; e++) {
var a = edges[e][0], b = edges[e][1];
var dx2 = px[b] - px[a], dy2 = py[b] - py[a];
var d3 = Math.hypot(dx2, dy2) || 0.001;
var diff = (d3 - REST) * ATTRACT;
var ux2 = dx2 / d3, uy2 = dy2 / d3;
fx[a] += ux2 * diff; fy[a] += uy2 * diff;
fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
}
}
rotateAll — 収束後も画面を動かす剛体回転
力が収束して座標が静止しても、画面中心を軸に全ノードを同じ角度で回せばノード間の距離と角度は保存されたまま画面は動き続けます。力の計算を歪めないよう、常に step() の後に呼びます。
function rotateAll(dtheta) {
var cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (var i = 0; i < N; i++) {
var rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
02ドラッグ再配置 — 1つのノードを引くと全体が再びバランスを取る
01と同じ力の計算に、ポインターが掴んだ1ノードを積分から除外して毎フレームその座標へ固定する項を加えます。引く力が隣接するばねを伝って波及し、残りのノードがリアルタイムで席を探し直す — 静的な図を触れる道具に変える1行です。累積面積39.459%・強度平均53.7で、14ノードの再配置が画面の4割を塗り替えます。

<main class="stage">
<div class="gb is-demo" id="gbRoot" role="application" aria-label="ドラッグで再配置するネットワーク図の実験版" tabindex="0">
<canvas class="gb__cv" id="gbCv"></canvas>
<p class="gb__hint">
<span class="gb__title">ドラッグ再配置</span>
<span class="gb__desc">ノードを引くと周囲全体がリアルタイムで釣り合い直します</span>
</p>
<div class="gb__bar">
<span class="gb__meta">ノード14個 · ドラッグ伝播 · rAF</span>
<span class="gb__meta gb__meta--dim">pointermove · arc · lineTo</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 드래그 재배치(02) — 무대 노랑. 01 과 같은 엔진 위에 포인터 드래그를 얹는다.
// 움직임은 전부 캔버스 rAF(척력·스프링 재계산 + 드래그 강제 고정)가 만든다.
$gb-stage: #ffe9a8;
@include stage($gb-stage);
$gb-panel: #fff7d6; // 캔버스 바닥 — 무대보다 밝은 크림
$gb-text: #665f43; // 라벨 글자 — 짙은 올리브 브라운(파스텔)
$gb-edge: #b3a074; // 링크 선 — 저채도 탠
$gb-node: #5a5470; // 노드 — 그레이 플럼
$gb-hot: #c17b5e; // 붙잡힌 노드 — 러스트
$duration: $dur-loop;
$easing: $ease-out;
$color: $gb-node;
.gb {
--gb-panel: #{$gb-panel};
--gb-edge: #{$gb-edge};
--gb-node: #{$gb-node};
--gb-hot: #{$gb-hot};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px;
overflow: hidden;
}
.gb__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; }
.gb__cv:active { cursor: grabbing; }
.gb__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: $gb-text;
}
.gb__title { text-shadow: 0 1px 6px rgba($gb-panel, .9); }
.gb__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($gb-text, .78);
text-shadow: 0 1px 6px rgba($gb-panel, .9);
}
.gb__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;
}
.gb__meta {
font-size: 10px; font-weight: 500; color: rgba($gb-text, .8);
background: rgba($gb-panel, .82);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.gb:focus-visible { outline: 2px solid $gb-node; outline-offset: -2px; }
@media (max-width: 340px) {
.gb__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 정착된 배치 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gb.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);
}
.gb {
--gb-panel: #fff7d6;
--gb-edge: #b3a074;
--gb-node: #5a5470;
--gb-hot: #c17b5e;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.gb__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.gb__cv:active {
cursor: grabbing;
}
.gb__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;
}
.gb__title {
text-shadow: 0 1px 6px rgba(255, 247, 214, 0.9);
}
.gb__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);
}
.gb__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.gb__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;
}
.gb:focus-visible {
outline: 2px solid #5a5470;
outline-offset: -2px;
}
@media (max-width: 340px) {
.gb__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gb.is-still {
cursor: default !important;
}
}// ドラッグ再配置(02) — 01 의 척력+스프링 엔진에 포인터 드래그를 얹는다. 붙잡은 노드는
// 매 프레임 포인터 좌표로 강제 고정되고(적분에서 제외), 그 힘이 스프링을 타고 이웃으로
// 퍼져 나머지 노드가 실시간으로 재배치된다. .is-demo 동안은 같은 로직을 자동 각본이
// 반복 호출해 사람이 만지지 않아도 화면이 산다 — 실제 드래그와 자동 각본은 같은 함수를 쓴다.
(function () {
// ── 변형 상수 블록 ──
var N = 14;
var ATTRACT = 0.03;
var REPEL = 760;
// ────────────────────────
var REST = 44;
var DAMP = 0.95;
var CENTER_K = 0.0011;
var JITTER = 42;
var NR = 5;
var HIT_R = 16; // 드래그 인식 반경(px)
var OMEGA = 0.4; // 안 잡힌 나머지 노드를 아주 천천히 회전시켜 화면을 넓게 훑는다
var root = document.getElementById('gbRoot');
var cvEl = document.getElementById('gbCv');
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('--gb-panel');
var C_EDGE = tokRGB('--gb-edge');
var C_NODE = tokRGB('--gb-node');
var C_HOT = tokRGB('--gb-hot');
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(0x327002);
var px, py, vx, vy, edges;
var dragIdx = -1, autoIdx = -1, autoEntry = null;
function buildGraph() {
px = new Float32Array(N); py = new Float32Array(N);
vx = new Float32Array(N); vy = new Float32Array(N);
for (var i = 0; i < N; i++) { px[i] = rand() * W; py[i] = rand() * H; }
edges = [];
for (var a = 1; a < N; a++) edges.push([a, Math.floor(rand() * a)]);
var extra = Math.round(N * 0.4);
for (var k = 0; k < extra; k++) {
var u = Math.floor(rand() * N), v = Math.floor(rand() * N);
if (u !== v) edges.push([u, v]);
}
dragIdx = -1; autoIdx = -1; autoEntry = null;
}
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 nearestNode(x, y) {
var best = -1, bd = Infinity;
for (var i = 0; i < N; i++) {
var dx = px[i] - x, dy = py[i] - y, d = Math.hypot(dx, dy);
if (d < bd) { bd = d; best = i; }
}
return best;
}
function step(dt, pinnedIdx) {
var fx = new Float32Array(N), fy = new Float32Array(N);
for (var i = 0; i < N; i++) {
for (var j = i + 1; j < N; j++) {
var dx = px[j] - px[i], dy = py[j] - py[i];
var d2 = dx * dx + dy * dy; if (d2 < 4) d2 = 4;
var d = Math.sqrt(d2);
var f = REPEL / d2;
var ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
for (var e = 0; e < edges.length; e++) {
var a = edges[e][0], b = edges[e][1];
var dx2 = px[b] - px[a], dy2 = py[b] - py[a];
var d3 = Math.hypot(dx2, dy2) || 0.001;
var diff = (d3 - REST) * ATTRACT;
var ux2 = dx2 / d3, uy2 = dy2 / d3;
fx[a] += ux2 * diff; fy[a] += uy2 * diff;
fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
}
var cx = W / 2, cy = H / 2;
for (var n = 0; n < N; n++) {
if (n === pinnedIdx) { vx[n] = 0; vy[n] = 0; continue; }
fx[n] += (cx - px[n]) * CENTER_K;
fy[n] += (cy - py[n]) * CENTER_K;
fx[n] += (rand() - 0.5) * JITTER;
fy[n] += (rand() - 0.5) * JITTER;
vx[n] = (vx[n] + fx[n] * dt) * DAMP;
vy[n] = (vy[n] + fy[n] * dt) * DAMP;
px[n] += vx[n] * dt; py[n] += vy[n] * dt;
}
}
function rotateAll(dtheta, exclude) {
var cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (var i = 0; i < N; i++) {
if (i === exclude) continue;
var rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + C_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
ctx.lineWidth = 1.2;
ctx.strokeStyle = 'rgba(' + C_EDGE.join(',') + ',0.6)';
ctx.beginPath();
for (var e = 0; e < edges.length; e++) {
var a = edges[e][0], b = edges[e][1];
ctx.moveTo(px[a], py[a]); ctx.lineTo(px[b], py[b]);
}
ctx.stroke();
var pinned = dragIdx >= 0 ? dragIdx : autoIdx;
for (var i = 0; i < N; i++) {
var isPinned = i === pinned;
ctx.fillStyle = isPinned ? 'rgb(' + C_HOT.join(',') + ')' : 'rgb(' + C_NODE.join(',') + ')';
ctx.beginPath(); ctx.arc(px[i], py[i], isPinned ? NR + 2.4 : NR, 0, Math.PI * 2); ctx.fill();
}
}
// .is-demo 자동 드래그 각본 — 사람이 안 눌러도 같은 pinnedIdx 경로로 화면이 산다.
var script = [
{ t: 0.3, dur: 1.3, from: { x: 0.22, y: 0.5 }, to: { x: 0.78, y: 0.22 } },
{ t: 2.1, dur: 1.3, from: { x: 0.78, y: 0.7 }, to: { x: 0.24, y: 0.82 } },
{ t: 3.9, dur: 1.3, from: { x: 0.5, y: 0.18 }, to: { x: 0.62, y: 0.86 } }
];
var AUTO_CYCLE = 5.6;
function findEntry(tMod) {
for (var i = 0; i < script.length; i++) {
var s = script[i];
if (tMod >= s.t && tMod <= s.t + s.dur) return s;
}
return null;
}
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;
if (dragIdx < 0 && root.classList.contains('is-demo')) {
var tMod = ((now - t0) / 1000) % AUTO_CYCLE;
var entry = findEntry(tMod);
if (entry) {
if (autoEntry !== entry) {
autoEntry = entry;
autoIdx = nearestNode(entry.from.x * W, entry.from.y * H);
}
var k = (tMod - entry.t) / entry.dur;
px[autoIdx] = W * (entry.from.x + (entry.to.x - entry.from.x) * k);
py[autoIdx] = H * (entry.from.y + (entry.to.y - entry.from.y) * k);
} else { autoEntry = null; autoIdx = -1; }
}
var pinned = dragIdx >= 0 ? dragIdx : autoIdx;
step(dt, pinned);
rotateAll(OMEGA * dt, pinned);
draw();
requestAnimationFrame(frame);
}
function pointerXY(e) {
var r = cvEl.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
cvEl.addEventListener('pointerdown', function (e) {
var xy = pointerXY(e);
var idx = nearestNode(xy[0], xy[1]);
if (idx >= 0 && Math.hypot(px[idx] - xy[0], py[idx] - xy[1]) <= HIT_R) {
dragIdx = idx; autoIdx = -1; autoEntry = null;
cvEl.setPointerCapture(e.pointerId);
}
});
cvEl.addEventListener('pointermove', function (e) {
if (dragIdx < 0) return;
var xy = pointerXY(e);
px[dragIdx] = xy[0]; py[dragIdx] = xy[1];
});
function release() { dragIdx = -1; }
cvEl.addEventListener('pointerup', release);
cvEl.addEventListener('pointercancel', release);
function settle() {
for (var s = 0; s < 200; s++) step(1 / 60, -1);
draw();
}
window.addEventListener('resize', function () {
fit();
if (reduce) settle();
});
fit();
if (reduce) {
settle();
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// GraphForceDragNode — vanilla 판(run/327/demo/02-graph-force-drag-node/index.html) 의 React 포트.
// 01 의 척력+스프링 엔진에 포인터 드래그를 얹는다. 붙잡은 노드는 매 프레임 포인터 좌표로
// 강제 고정되고(적분에서 제외), 그 힘이 스프링을 타고 이웃으로 퍼져 나머지가 실시간으로 재배치된다.
// 사람이 잡지 않는 동안은 자동 각본이 같은 pinnedIdx 경로를 반복 호출해 화면이 계속 산다
// (실제 드래그와 자동 각본은 같은 step/rotateAll 함수를 쓴다 — vanilla 의 .is-demo 상시 동작과 동일).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GraphForceDragNode.module.scss";
export type GraphForceDragNodeProps = {
duration?: string;
easing?: string;
color?: string;
n?: number;
attract?: number;
repel?: number;
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const N = 14;
const ATTRACT = 0.03;
const REPEL = 760;
const REST = 44;
const DAMP = 0.95;
const CENTER_K = 0.0011;
const JITTER = 42;
const NR = 5;
const HIT_R = 16; // 드래그 인식 반경(px)
const OMEGA = 0.4; // 안 잡힌 나머지 노드를 아주 천천히 회전시켜 화면을 넓게 훑는다
const SEED = 0x327002;
const AUTO_CYCLE = 5.6;
type ScriptEntry = { t: number; dur: number; from: { x: number; y: number }; to: { x: number; y: number } };
const SCRIPT: ScriptEntry[] = [
{ t: 0.3, dur: 1.3, from: { x: 0.22, y: 0.5 }, to: { x: 0.78, y: 0.22 } },
{ t: 2.1, dur: 1.3, from: { x: 0.78, y: 0.7 }, to: { x: 0.24, y: 0.82 } },
{ t: 3.9, dur: 1.3, from: { x: 0.5, y: 0.18 }, to: { x: 0.62, y: 0.86 } }
];
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 = "노드 14개 · 드래그 전파 · rAF";
const META_RIGHT = "pointermove · arc · lineTo";
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 GraphForceDragNode({
duration = "2s",
easing = "ease-out",
color = "#cfd9e7",
n = N,
attract = ATTRACT,
repel = REPEL
}: GraphForceDragNodeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ panel: RGB; edge: RGB; node: RGB; hot: 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 pxRef = useRef<Float32Array | null>(null);
const pyRef = useRef<Float32Array | null>(null);
const vxRef = useRef<Float32Array | null>(null);
const vyRef = useRef<Float32Array | null>(null);
const edgesRef = useRef<[number, number][]>([]);
const dragIdxRef = useRef(-1) as Box<number>;
const autoIdxRef = useRef(-1) as Box<number>;
const autoEntryRef = useRef<ScriptEntry | 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 W = wRef.current, H = hRef.current;
const px = new Float32Array(n), py = new Float32Array(n);
const vx = new Float32Array(n), vy = new Float32Array(n);
for (let i = 0; i < n; i++) { px[i] = rand() * W; py[i] = rand() * H; }
const edges: [number, number][] = [];
for (let a = 1; a < n; a++) edges.push([a, Math.floor(rand() * a)]);
const extra = Math.round(n * 0.4);
for (let k = 0; k < extra; k++) {
const u = Math.floor(rand() * n), v = Math.floor(rand() * n);
if (u !== v) edges.push([u, v]);
}
pxRef.current = px; pyRef.current = py; vxRef.current = vx; vyRef.current = vy;
edgesRef.current = edges;
dragIdxRef.current = -1; autoIdxRef.current = -1; autoEntryRef.current = null;
}
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 nearestNode(x: number, y: number): number {
const px = pxRef.current, py = pyRef.current;
if (!px || !py) return -1;
let best = -1, bd = Infinity;
for (let i = 0; i < n; i++) {
const dx = px[i] - x, dy = py[i] - y, d = Math.hypot(dx, dy);
if (d < bd) { bd = d; best = i; }
}
return best;
}
function step(dt: number, pinnedIdx: number): void {
const px = pxRef.current, py = pyRef.current, vx = vxRef.current, vy = vyRef.current;
const edges = edgesRef.current;
const rand = randRef.current;
const W = wRef.current, H = hRef.current;
if (!px || !py || !vx || !vy || !edges) return;
const fx = new Float32Array(n), fy = new Float32Array(n);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const dx = px[j] - px[i], dy = py[j] - py[i];
let d2 = dx * dx + dy * dy; if (d2 < 4) d2 = 4;
const d = Math.sqrt(d2);
const f = repel / d2;
const ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
for (let e = 0; e < edges.length; e++) {
const a = edges[e][0], b = edges[e][1];
const dx2 = px[b] - px[a], dy2 = py[b] - py[a];
const d3 = Math.hypot(dx2, dy2) || 0.001;
const diff = (d3 - REST) * attract;
const ux2 = dx2 / d3, uy2 = dy2 / d3;
fx[a] += ux2 * diff; fy[a] += uy2 * diff;
fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
}
const cx = W / 2, cy = H / 2;
for (let i = 0; i < n; i++) {
if (i === pinnedIdx) { vx[i] = 0; vy[i] = 0; continue; }
fx[i] += (cx - px[i]) * CENTER_K;
fy[i] += (cy - py[i]) * CENTER_K;
fx[i] += (rand() - 0.5) * JITTER;
fy[i] += (rand() - 0.5) * JITTER;
vx[i] = (vx[i] + fx[i] * dt) * DAMP;
vy[i] = (vy[i] + fy[i] * dt) * DAMP;
px[i] += vx[i] * dt; py[i] += vy[i] * dt;
}
}
function rotateAll(dtheta: number, exclude: number): void {
const px = pxRef.current, py = pyRef.current;
if (!px || !py) return;
const W = wRef.current, H = hRef.current;
const cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (let i = 0; i < n; i++) {
if (i === exclude) continue;
const rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
function draw(): void {
const ctx = ctxRef.current;
const col = colRef.current;
const px = pxRef.current, py = pyRef.current;
const edges = edgesRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !px || !py || !edges) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
ctx.lineWidth = 1.2;
ctx.strokeStyle = "rgba(" + col.edge.join(",") + ",0.6)";
ctx.beginPath();
for (let e = 0; e < edges.length; e++) {
const a = edges[e][0], b = edges[e][1];
ctx.moveTo(px[a], py[a]); ctx.lineTo(px[b], py[b]);
}
ctx.stroke();
const pinned = dragIdxRef.current >= 0 ? dragIdxRef.current : autoIdxRef.current;
for (let i = 0; i < n; i++) {
const isPinned = i === pinned;
ctx.fillStyle = isPinned ? "rgb(" + col.hot.join(",") + ")" : "rgb(" + col.node.join(",") + ")";
ctx.beginPath(); ctx.arc(px[i], py[i], isPinned ? NR + 2.4 : NR, 0, Math.PI * 2); ctx.fill();
}
}
function findEntry(tMod: number): ScriptEntry | null {
for (let i = 0; i < SCRIPT.length; i++) {
const s = SCRIPT[i];
if (tMod >= s.t && tMod <= s.t + s.dur) return s;
}
return null;
}
function settle(): void {
for (let s = 0; s < 200; s++) step(1 / 60, -1);
draw();
}
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 px = pxRef.current, py = pyRef.current;
if (dragIdxRef.current < 0 && px && py) {
const tMod = ((now - t0) / 1000) % AUTO_CYCLE;
const entry = findEntry(tMod);
if (entry) {
if (autoEntryRef.current !== entry) {
autoEntryRef.current = entry;
autoIdxRef.current = nearestNode(entry.from.x * wRef.current, entry.from.y * hRef.current);
}
const k = (tMod - entry.t) / entry.dur;
const idx = autoIdxRef.current;
if (idx >= 0) {
px[idx] = wRef.current * (entry.from.x + (entry.to.x - entry.from.x) * k);
py[idx] = hRef.current * (entry.from.y + (entry.to.y - entry.from.y) * k);
}
} else { autoEntryRef.current = null; autoIdxRef.current = -1; }
}
const pinned = dragIdxRef.current >= 0 ? dragIdxRef.current : autoIdxRef.current;
step(dt, pinned);
rotateAll(OMEGA * dt, pinned);
draw();
rafRef.current = requestAnimationFrame(frame);
}
function pointerXY(e: PointerEvent): [number, number] {
const cv = cvRef.current;
if (!cv) return [0, 0];
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * wRef.current, (e.clientY - r.top) / r.height * hRef.current];
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
panel: tokRGB(root, "--gb-panel"),
edge: tokRGB(root, "--gb-edge"),
node: tokRGB(root, "--gb-node"),
hot: tokRGB(root, "--gb-hot")
};
ctxRef.current = cv.getContext("2d");
fit();
function onPointerDown(e: PointerEvent): void {
const cv2 = cvRef.current;
if (!cv2) return;
const xy = pointerXY(e);
const idx = nearestNode(xy[0], xy[1]);
const px = pxRef.current, py = pyRef.current;
if (idx >= 0 && px && py && Math.hypot(px[idx] - xy[0], py[idx] - xy[1]) <= HIT_R) {
dragIdxRef.current = idx; autoIdxRef.current = -1; autoEntryRef.current = null;
cv2.setPointerCapture(e.pointerId);
}
}
function onPointerMove(e: PointerEvent): void {
if (dragIdxRef.current < 0) return;
const xy = pointerXY(e);
const px = pxRef.current, py = pyRef.current;
if (!px || !py) return;
px[dragIdxRef.current] = xy[0]; py[dragIdxRef.current] = xy[1];
}
function release(): void { dragIdxRef.current = -1; }
cv.addEventListener("pointerdown", onPointerDown);
cv.addEventListener("pointermove", onPointerMove);
cv.addEventListener("pointerup", release);
cv.addEventListener("pointercancel", release);
function onResize(): void {
fit();
if (reduced()) settle();
}
window.addEventListener("resize", onResize);
if (reduced()) {
settle();
setStill(true);
} else {
t0 = -1; last = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cv.removeEventListener("pointerdown", onPointerDown);
cv.removeEventListener("pointermove", onPointerMove);
cv.removeEventListener("pointerup", release);
cv.removeEventListener("pointercancel", release);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, attract, repel]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div
className={rootCls}
ref={rootRef}
style={vars}
role="application"
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>
);
}02 · ドラッグ再配置 — 1つのノードを引くと全体が再びバランスを取る — 計測 累積面積 39.459% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
step — 掴んだノードを固定する
pinnedIdx と一致したノードは速度を0にして積分を飛ばします。ほかのノードは普段どおり力を計算するので、固定点が動くたびに釣り合いの位置が全体で組み直されます。
var cx = W / 2, cy = H / 2;
for (var n = 0; n < N; n++) {
if (n === pinnedIdx) { vx[n] = 0; vy[n] = 0; continue; }
fx[n] += (cx - px[n]) * CENTER_K;
fy[n] += (cy - py[n]) * CENTER_K;
fx[n] += (rand() - 0.5) * JITTER;
fy[n] += (rand() - 0.5) * JITTER;
vx[n] = (vx[n] + fx[n] * dt) * DAMP;
vy[n] = (vy[n] + fy[n] * dt) * DAMP;
px[n] += vx[n] * dt; py[n] += vy[n] * dt;
}
rotateAll — 固定ノードを回転から外す
掴んでいる間、そのノードだけ指の下に留めるため回転から除外します。exclude の1引数が、ポインター操作と剛体回転が衝突しないための仕切りです。
function rotateAll(dtheta, exclude) {
var cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (var i = 0; i < N; i++) {
if (i === exclude) continue;
var rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
03クラスタ色分け — 同グループがより強く引き合い固まる
この盤にはエッジが1本もありません。代わりに同じグループ同士は INTRA_REPEL 0.5倍、違うグループ同士は INTER_REPEL 1.9倍と反発の強さを非対称にし、各グループの重心へ弱く引く CENTROID_K を足します。この2つだけで色ごとの集団が画面の上で自ら分かれる — つながりの情報なしにデータ内のクラスタを力だけで見せる構成です。累積面積55.851%はこの回で2番目に広く、30ノードが分離を続ける間画面の半分超が動きます。

<main class="stage">
<div class="gc is-demo" id="gcRoot" role="img" aria-label="色で分かれた3つの集団が力で集まるネットワーク図" tabindex="0">
<canvas class="gc__cv" id="gcCv"></canvas>
<p class="gc__hint">
<span class="gc__title">クラスタ色分け</span>
<span class="gc__desc">同じ色同士は強く引き合い、違う色同士は強く反発します</span>
</p>
<div class="gc__bar">
<span class="gc__meta">ノード30個 · 3クラスタ · rAF</span>
<span class="gc__meta gc__meta--dim">arc · fillRect · Math.hypot</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 군집 색 구분(03) — 무대 테라코타. @keyframes 없음, 전부 rAF 물리 계산.
// 같은 그룹은 무게중심으로 약하게, 다른 그룹은 척력을 세게 — 색 덩어리가 스스로 갈라진다.
$gc-stage: #c49d92;
@include stage($gc-stage);
$gc-panel: #ead9d1; // 캔버스 바닥 — 무대보다 밝은 테라코타
$gc-text: #3a241c; // 라벨 글자 — 짙은 브라운
$gc-g1: #6f8f7c; // 그룹 1 — 세이지
$gc-g2: #7c85a8; // 그룹 2 — 더스티 바이올렛
$gc-g3: #b0725f; // 그룹 3 — 러스트 로즈
$duration: $dur-loop;
$easing: $ease-out;
$color: $gc-g2;
.gc {
--gc-panel: #{$gc-panel};
--gc-g1: #{$gc-g1};
--gc-g2: #{$gc-g2};
--gc-g3: #{$gc-g3};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px;
overflow: hidden;
}
.gc__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.gc__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: $gc-text;
}
.gc__title { text-shadow: 0 1px 6px rgba($gc-panel, .9); }
.gc__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($gc-text, .78);
text-shadow: 0 1px 6px rgba($gc-panel, .9);
}
.gc__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;
}
.gc__meta {
font-size: 10px; font-weight: 500; color: rgba($gc-text, .8);
background: rgba($gc-panel, .82);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.gc:focus-visible { outline: 2px solid $gc-g2; outline-offset: -2px; }
@media (max-width: 340px) {
.gc__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.gc.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: #c49d92;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gc {
--gc-panel: #ead9d1;
--gc-g1: #6f8f7c;
--gc-g2: #7c85a8;
--gc-g3: #b0725f;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.gc__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.gc__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: #3a241c;
}
.gc__title {
text-shadow: 0 1px 6px rgba(234, 217, 209, 0.9);
}
.gc__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(58, 36, 28, 0.78);
text-shadow: 0 1px 6px rgba(234, 217, 209, 0.9);
}
.gc__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.gc__meta {
font-size: 10px;
font-weight: 500;
color: rgba(58, 36, 28, 0.8);
background: rgba(234, 217, 209, 0.82);
border-radius: 4px;
padding: 4px 4px;
}
.gc:focus-visible {
outline: 2px solid #7c85a8;
outline-offset: -2px;
}
@media (max-width: 340px) {
.gc__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gc.is-still {
cursor: default !important;
}
}// クラスタ色分け(03) — 엣지는 없다. 대신 같은 그룹 노드는 그룹 무게중심 쪽으로
// 약하게 끌리고, 다른 그룹끼리는 척력이 더 세다. 이 비대칭 힘만으로 색깔별
// 덩어리가 화면 위에서 스스로 갈라진다 — 매 프레임 중심을 다시 구해 계속 움직인다.
(function () {
// ── 변형 상수 블록 ──
var N = 30;
var GROUPS = 3;
var INTRA_REPEL = 0.5; // 같은 그룹 척력 배율(작을수록 잘 뭉친다)
var INTER_REPEL = 1.9; // 다른 그룹 척력 배율(클수록 잘 갈라진다)
// ────────────────────────
var REPEL = 640;
var CENTROID_K = 0.05; // 그룹 무게중심으로 끌리는 힘
var CENTER_K = 0.0006;
var DAMP = 0.955;
var JITTER = 46;
var NR = 4.6;
var OMEGA = 0.75; // 전체를 천천히 돌려 색 덩어리가 화면을 넓게 훑게 한다
var root = document.getElementById('gcRoot');
var cvEl = document.getElementById('gcCv');
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('--gc-panel');
var C_G = [tokRGB('--gc-g1'), tokRGB('--gc-g2'), tokRGB('--gc-g3')];
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(0x327003);
var px, py, vx, vy, grp;
function buildGraph() {
px = new Float32Array(N); py = new Float32Array(N);
vx = new Float32Array(N); vy = new Float32Array(N);
grp = new Int8Array(N);
for (var i = 0; i < N; i++) {
px[i] = rand() * W; py[i] = rand() * H;
grp[i] = i % GROUPS;
}
}
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) {
var fx = new Float32Array(N), fy = new Float32Array(N);
for (var i = 0; i < N; i++) {
for (var j = i + 1; j < N; j++) {
var dx = px[j] - px[i], dy = py[j] - py[i];
var d = Math.hypot(dx, dy) || 0.001;
var d2 = d * d; if (d2 < 4) d2 = 4;
var mult = grp[i] === grp[j] ? INTRA_REPEL : INTER_REPEL;
var f = (REPEL * mult) / d2;
var ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
// 그룹 무게중심 — 매 프레임 다시 구해 그 쪽으로 약하게 당긴다
var cxg = new Float32Array(GROUPS), cyg = new Float32Array(GROUPS), cnt = new Float32Array(GROUPS);
for (var n = 0; n < N; n++) { cxg[grp[n]] += px[n]; cyg[grp[n]] += py[n]; cnt[grp[n]]++; }
for (var g = 0; g < GROUPS; g++) { if (cnt[g] > 0) { cxg[g] /= cnt[g]; cyg[g] /= cnt[g]; } }
var cx = W / 2, cy = H / 2;
for (var m = 0; m < N; m++) {
fx[m] += (cxg[grp[m]] - px[m]) * CENTROID_K;
fy[m] += (cyg[grp[m]] - py[m]) * CENTROID_K;
fx[m] += (cx - px[m]) * CENTER_K;
fy[m] += (cy - py[m]) * CENTER_K;
fx[m] += (rand() - 0.5) * JITTER;
fy[m] += (rand() - 0.5) * JITTER;
vx[m] = (vx[m] + fx[m] * dt) * DAMP;
vy[m] = (vy[m] + fy[m] * dt) * DAMP;
px[m] += vx[m] * dt; py[m] += vy[m] * dt;
}
}
function rotateAll(dtheta) {
var cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (var i = 0; i < N; i++) {
var rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
function draw(t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + C_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
for (var i = 0; i < N; i++) {
var c = C_G[grp[i]];
var pulse = 0.5 + 0.5 * Math.sin(t * 1.8 + i * 0.9);
ctx.fillStyle = 'rgba(' + c.join(',') + ',' + (0.16 + 0.18 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(px[i], py[i], NR + 5 + pulse * 6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgb(' + c.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);
rotateAll(OMEGA * dt);
draw(t);
requestAnimationFrame(frame);
}
function settle() {
for (var s = 0; s < 260; s++) step(1 / 60);
draw(0);
}
window.addEventListener('resize', function () {
fit();
if (reduce) settle();
});
fit();
if (reduce) {
settle();
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// GraphForceClusterColor — vanilla 판(run/327/demo/03-graph-force-cluster-color/index.html) 의 React 포트.
// 엣지는 없다. 대신 같은 그룹 노드는 그룹 무게중심 쪽으로 약하게 끌리고, 다른 그룹끼리는
// 척력이 더 세다. 이 비대칭 힘만으로 색깔별 덩어리가 화면 위에서 스스로 갈라진다 —
// 매 프레임 중심을 다시 구해 계속 움직인다. 전체는 rotateAll 로 천천히 강체 회전한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GraphForceClusterColor.module.scss";
export type GraphForceClusterColorProps = {
duration?: string;
easing?: string;
color?: string;
n?: number;
intraRepel?: number; // 같은 그룹 척력 배율(작을수록 잘 뭉친다)
interRepel?: number; // 다른 그룹 척력 배율(클수록 잘 갈라진다)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const N = 30;
const GROUPS = 3;
const INTRA_REPEL = 0.5;
const INTER_REPEL = 1.9;
const REPEL = 640;
const CENTROID_K = 0.05; // 그룹 무게중심으로 끌리는 힘
const CENTER_K = 0.0006;
const DAMP = 0.955;
const JITTER = 46;
const NR = 4.6;
const OMEGA = 0.75; // 전체를 천천히 돌려 색 덩어리가 화면을 넓게 훑게 한다
const SEED = 0x327003;
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 = "노드 30개 · 3군집 · rAF";
const META_RIGHT = "arc · fillRect · Math.hypot";
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 GraphForceClusterColor({
duration = "2s",
easing = "ease-out",
color = "#7c85a8",
n = N,
intraRepel = INTRA_REPEL,
interRepel = INTER_REPEL
}: GraphForceClusterColorProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ panel: RGB; g: 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 pxRef = useRef<Float32Array | null>(null);
const pyRef = useRef<Float32Array | null>(null);
const vxRef = useRef<Float32Array | null>(null);
const vyRef = useRef<Float32Array | null>(null);
const grpRef = useRef<Int8Array | 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 W = wRef.current, H = hRef.current;
const px = new Float32Array(n), py = new Float32Array(n);
const vx = new Float32Array(n), vy = new Float32Array(n);
const grp = new Int8Array(n);
for (let i = 0; i < n; i++) {
px[i] = rand() * W; py[i] = rand() * H;
grp[i] = i % GROUPS;
}
pxRef.current = px; pyRef.current = py; vxRef.current = vx; vyRef.current = vy;
grpRef.current = grp;
}
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): void {
const px = pxRef.current, py = pyRef.current, vx = vxRef.current, vy = vyRef.current, grp = grpRef.current;
const rand = randRef.current;
const W = wRef.current, H = hRef.current;
if (!px || !py || !vx || !vy || !grp) return;
const fx = new Float32Array(n), fy = new Float32Array(n);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const dx = px[j] - px[i], dy = py[j] - py[i];
const d = Math.hypot(dx, dy) || 0.001;
let d2 = d * d; if (d2 < 4) d2 = 4;
const mult = grp[i] === grp[j] ? intraRepel : interRepel;
const f = (REPEL * mult) / d2;
const ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
// 그룹 무게중심 — 매 프레임 다시 구해 그 쪽으로 약하게 당긴다
const cxg = new Float32Array(GROUPS), cyg = new Float32Array(GROUPS), cnt = new Float32Array(GROUPS);
for (let i = 0; i < n; i++) { cxg[grp[i]] += px[i]; cyg[grp[i]] += py[i]; cnt[grp[i]]++; }
for (let g = 0; g < GROUPS; g++) { if (cnt[g] > 0) { cxg[g] /= cnt[g]; cyg[g] /= cnt[g]; } }
const cx = W / 2, cy = H / 2;
for (let m = 0; m < n; m++) {
fx[m] += (cxg[grp[m]] - px[m]) * CENTROID_K;
fy[m] += (cyg[grp[m]] - py[m]) * CENTROID_K;
fx[m] += (cx - px[m]) * CENTER_K;
fy[m] += (cy - py[m]) * CENTER_K;
fx[m] += (rand() - 0.5) * JITTER;
fy[m] += (rand() - 0.5) * JITTER;
vx[m] = (vx[m] + fx[m] * dt) * DAMP;
vy[m] = (vy[m] + fy[m] * dt) * DAMP;
px[m] += vx[m] * dt; py[m] += vy[m] * dt;
}
}
function rotateAll(dtheta: number): void {
const px = pxRef.current, py = pyRef.current;
if (!px || !py) return;
const W = wRef.current, H = hRef.current;
const cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (let i = 0; i < n; i++) {
const rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const px = pxRef.current, py = pyRef.current, grp = grpRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !px || !py || !grp) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < n; i++) {
const c = col.g[grp[i]];
const pulse = 0.5 + 0.5 * Math.sin(t * 1.8 + i * 0.9);
ctx.fillStyle = "rgba(" + c.join(",") + "," + (0.16 + 0.18 * pulse).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(px[i], py[i], NR + 5 + pulse * 6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "rgb(" + c.join(",") + ")";
ctx.beginPath(); ctx.arc(px[i], py[i], NR, 0, Math.PI * 2); ctx.fill();
}
}
function settle(): void {
for (let s = 0; s < 260; s++) step(1 / 60);
draw(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);
rotateAll(OMEGA * dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
panel: tokRGB(root, "--gc-panel"),
g: [tokRGB(root, "--gc-g1"), tokRGB(root, "--gc-g2"), tokRGB(root, "--gc-g3")]
};
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) settle();
}
window.addEventListener("resize", onResize);
if (reduced()) {
settle();
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
}, [n, intraRepel, interRepel]);
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>
);
}03 · クラスタ色分け — 同グループがより強く引き合い固まる — 計測 累積面積 55.851% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
step — 反発の非対称と重心引力
距離の2乗の逆数という形は01と同じで、係数 mult だけグループ関係で変わります。重心は毎フレーム全ノードから数え直します。
for (var i = 0; i < N; i++) {
for (var j = i + 1; j < N; j++) {
var dx = px[j] - px[i], dy = py[j] - py[i];
var d = Math.hypot(dx, dy) || 0.001;
var d2 = d * d; if (d2 < 4) d2 = 4;
var mult = grp[i] === grp[j] ? INTRA_REPEL : INTER_REPEL;
var f = (REPEL * mult) / d2;
var ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
var cxg = new Float32Array(GROUPS), cyg = new Float32Array(GROUPS), cnt = new Float32Array(GROUPS);
for (var n = 0; n < N; n++) { cxg[grp[n]] += px[n]; cyg[grp[n]] += py[n]; cnt[grp[n]]++; }
for (var g = 0; g < GROUPS; g++) { if (cnt[g] > 0) { cxg[g] /= cnt[g]; cyg[g] /= cnt[g]; } }
重心への引力 — 集団を1点にまとめる項
計算した重心へ CENTROID_K 0.05の強さで引きます。反発の非対称が集団の境界を決め、この項が集団の中身を丸めます。
var cx = W / 2, cy = H / 2;
for (var m = 0; m < N; m++) {
fx[m] += (cxg[grp[m]] - px[m]) * CENTROID_K;
fy[m] += (cyg[grp[m]] - py[m]) * CENTROID_K;
fx[m] += (cx - px[m]) * CENTER_K;
fy[m] += (cy - py[m]) * CENTER_K;
fx[m] += (rand() - 0.5) * JITTER;
fy[m] += (rand() - 0.5) * JITTER;
vx[m] = (vx[m] + fx[m] * dt) * DAMP;
vy[m] = (vy[m] + fy[m] * dt) * DAMP;
px[m] += vx[m] * dt; py[m] += vy[m] * dt;
}
04衝突回避 — ノードは自分の半径分だけ押し合い重ならない
ノードごとに半径 6〜15 を与え、反発とランダムな漂遊力(SPEED 78)で自由に泳がせます。力の積分が終わった後に、中心距離が半径の和より近い組を見つけて重なりの半分ずつをその場で押し返す — 力ではなく位置そのものを直すハード衝突解決です。この補正パスが毎フレーム画素をはっきり反転させるため、累積面積31.530%はこの回で最も狭い部類なのに強度平均83.3は最も明るくなります。

<main class="stage">
<div class="gd is-demo" id="gdRoot" role="img" aria-label="ノードが半径の分だけ押し合い重ならない実験版" tabindex="0">
<canvas class="gd__cv" id="gdCv"></canvas>
<p class="gd__hint">
<span class="gd__title">衝突回避</span>
<span class="gd__desc">半径の合計より近づくと即座に押し返し重なりを防ぎます</span>
</p>
<div class="gd__bar">
<span class="gd__meta">ノード22個 · サイズ多様 · rAF</span>
<span class="gd__meta gd__meta--dim">arc · Math.hypot · beginPath</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 충돌 방지(04) — 무대 종이. 힘 계산 뒤 위치 보정 패스까지 전부 rAF 캔버스가 만든다.
$gd-stage: #f1ecdf;
@include stage($gd-stage);
$gd-panel: #e4dcc8; // 캔버스 바닥 — 무대보다 어두운 모래
$gd-text: #3c3324; // 라벨 글자 — 짙은 카키 브라운
$gd-node: #7d8f6a; // 노드 — 올리브 세이지
$gd-ring: #3c3324; // 노드 테두리 — 짙은 카키(경계 강조)
$duration: $dur-loop;
$easing: $ease-out;
$color: $gd-node;
.gd {
--gd-panel: #{$gd-panel};
--gd-node: #{$gd-node};
--gd-ring: #{$gd-ring};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px;
overflow: hidden;
}
.gd__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.gd__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: $gd-text;
}
.gd__title { text-shadow: 0 1px 6px rgba($gd-panel, .9); }
.gd__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($gd-text, .78);
text-shadow: 0 1px 6px rgba($gd-panel, .9);
}
.gd__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;
}
.gd__meta {
font-size: 10px; font-weight: 500; color: rgba($gd-text, .8);
background: rgba($gd-panel, .82);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.gd:focus-visible { outline: 2px solid $gd-node; outline-offset: -2px; }
@media (max-width: 340px) {
.gd__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.gd.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: #f1ecdf;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gd {
--gd-panel: #e4dcc8;
--gd-node: #7d8f6a;
--gd-ring: #3c3324;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.gd__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.gd__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: #3c3324;
}
.gd__title {
text-shadow: 0 1px 6px rgba(228, 220, 200, 0.9);
}
.gd__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(60, 51, 36, 0.78);
text-shadow: 0 1px 6px rgba(228, 220, 200, 0.9);
}
.gd__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.gd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(60, 51, 36, 0.8);
background: rgba(228, 220, 200, 0.82);
border-radius: 4px;
padding: 4px 4px;
}
.gd:focus-visible {
outline: 2px solid #7d8f6a;
outline-offset: -2px;
}
@media (max-width: 340px) {
.gd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gd.is-still {
cursor: default !important;
}
}// 衝突回避(04) — 척력만으로는 순간적으로 겹칠 수 있다. 매 프레임 위치를 적분한
// 뒤, 반지름 합보다 중심 거리가 가까운 쌍을 찾아 그 자리에서 즉시 밀어내는
// 보정 패스(하드 충돌 해소)를 한 겹 더 끼운다 — 힘이 아니라 위치 자체를 고친다.
(function () {
// ── 변형 상수 블록 ──
var N = 22;
var REPEL = 420;
var SPEED = 78; // 자유 유영 속도(무작위 방향 힘의 크기)
// ────────────────────────
var DAMP = 0.965;
var CENTER_K = 0.0016;
var OMEGA = 0.6;
var RMIN = 6, RMAX = 15;
var root = document.getElementById('gdRoot');
var cvEl = document.getElementById('gdCv');
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('--gd-panel');
var C_NODE = tokRGB('--gd-node');
var C_RING = tokRGB('--gd-ring');
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(0x327004);
var px, py, vx, vy, rad;
function buildGraph() {
px = new Float32Array(N); py = new Float32Array(N);
vx = new Float32Array(N); vy = new Float32Array(N);
rad = new Float32Array(N);
for (var i = 0; i < N; i++) {
px[i] = rand() * W; py[i] = rand() * H;
rad[i] = RMIN + rand() * (RMAX - RMIN);
}
}
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) {
var fx = new Float32Array(N), fy = new Float32Array(N);
for (var i = 0; i < N; i++) {
for (var j = i + 1; j < N; j++) {
var dx = px[j] - px[i], dy = py[j] - py[i];
var d = Math.hypot(dx, dy) || 0.001;
var d2 = d * d; if (d2 < 4) d2 = 4;
var f = REPEL / d2;
var ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
var cx = W / 2, cy = H / 2;
for (var n = 0; n < N; n++) {
fx[n] += (cx - px[n]) * CENTER_K;
fy[n] += (cy - py[n]) * CENTER_K;
fx[n] += (rand() - 0.5) * SPEED;
fy[n] += (rand() - 0.5) * SPEED;
vx[n] = (vx[n] + fx[n] * dt) * DAMP;
vy[n] = (vy[n] + fy[n] * dt) * DAMP;
px[n] += vx[n] * dt; py[n] += vy[n] * dt;
}
// 하드 충돌 해소 — 반지름 합보다 가까운 쌍을 그 자리에서 절반씩 밀어낸다
for (var a = 0; a < N; a++) {
for (var b = a + 1; b < N; b++) {
var ddx = px[b] - px[a], ddy = py[b] - py[a];
var dist = Math.hypot(ddx, ddy) || 0.001;
var minDist = rad[a] + rad[b];
if (dist < minDist) {
var push = (minDist - dist) / 2;
var nx = ddx / dist, ny = ddy / dist;
px[a] -= nx * push; py[a] -= ny * push;
px[b] += nx * push; py[b] += ny * push;
}
}
}
}
function rotateAll(dtheta) {
var cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (var i = 0; i < N; i++) {
var rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + C_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
for (var i = 0; i < N; i++) {
ctx.beginPath();
ctx.fillStyle = 'rgb(' + C_NODE.join(',') + ')';
ctx.arc(px[i], py[i], rad[i], 0, Math.PI * 2);
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(' + C_RING.join(',') + ',0.7)';
ctx.stroke();
}
}
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;
step(dt);
rotateAll(OMEGA * dt);
draw();
requestAnimationFrame(frame);
}
function settle() {
for (var s = 0; s < 260; s++) step(1 / 60);
draw();
}
window.addEventListener('resize', function () {
fit();
if (reduce) settle();
});
fit();
if (reduce) {
settle();
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// GraphForceCollisionAvoid — vanilla 판(run/327/demo/04-graph-force-collision-avoid/index.html) 의 React 포트.
// 척력만으로는 순간적으로 겹칠 수 있다. 매 프레임 위치를 적분한 뒤, 반지름 합보다 중심 거리가
// 가까운 쌍을 찾아 그 자리에서 즉시 밀어내는 보정 패스(하드 충돌 해소)를 한 겹 더 끼운다 —
// 힘이 아니라 위치 자체를 고친다. 전체는 rotateAll 로 천천히 강체 회전한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GraphForceCollisionAvoid.module.scss";
export type GraphForceCollisionAvoidProps = {
duration?: string;
easing?: string;
color?: string;
n?: number;
repel?: number;
speed?: number; // 자유 유영 속도(무작위 방향 힘의 크기)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const N = 22;
const REPEL = 420;
const SPEED = 78;
const DAMP = 0.965;
const CENTER_K = 0.0016;
const OMEGA = 0.6;
const RMIN = 6, RMAX = 15;
const SEED = 0x327004;
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 = "노드 22개 · 크기 다양 · rAF";
const META_RIGHT = "arc · Math.hypot · beginPath";
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 GraphForceCollisionAvoid({
duration = "2s",
easing = "ease-out",
color = "#7d8f6a",
n = N,
repel = REPEL,
speed = SPEED
}: GraphForceCollisionAvoidProps) {
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; ring: 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 pxRef = useRef<Float32Array | null>(null);
const pyRef = useRef<Float32Array | null>(null);
const vxRef = useRef<Float32Array | null>(null);
const vyRef = useRef<Float32Array | null>(null);
const radRef = 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 W = wRef.current, H = hRef.current;
const px = new Float32Array(n), py = new Float32Array(n);
const vx = new Float32Array(n), vy = new Float32Array(n);
const rad = new Float32Array(n);
for (let i = 0; i < n; i++) {
px[i] = rand() * W; py[i] = rand() * H;
rad[i] = RMIN + rand() * (RMAX - RMIN);
}
pxRef.current = px; pyRef.current = py; vxRef.current = vx; vyRef.current = vy;
radRef.current = rad;
}
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): void {
const px = pxRef.current, py = pyRef.current, vx = vxRef.current, vy = vyRef.current, rad = radRef.current;
const rand = randRef.current;
const W = wRef.current, H = hRef.current;
if (!px || !py || !vx || !vy || !rad) return;
const fx = new Float32Array(n), fy = new Float32Array(n);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const dx = px[j] - px[i], dy = py[j] - py[i];
const d = Math.hypot(dx, dy) || 0.001;
let d2 = d * d; if (d2 < 4) d2 = 4;
const f = repel / d2;
const ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
const cx = W / 2, cy = H / 2;
for (let i = 0; i < n; i++) {
fx[i] += (cx - px[i]) * CENTER_K;
fy[i] += (cy - py[i]) * CENTER_K;
fx[i] += (rand() - 0.5) * speed;
fy[i] += (rand() - 0.5) * speed;
vx[i] = (vx[i] + fx[i] * dt) * DAMP;
vy[i] = (vy[i] + fy[i] * dt) * DAMP;
px[i] += vx[i] * dt; py[i] += vy[i] * dt;
}
// 하드 충돌 해소 — 반지름 합보다 가까운 쌍을 그 자리에서 절반씩 밀어낸다
for (let a = 0; a < n; a++) {
for (let b = a + 1; b < n; b++) {
const ddx = px[b] - px[a], ddy = py[b] - py[a];
const dist = Math.hypot(ddx, ddy) || 0.001;
const minDist = rad[a] + rad[b];
if (dist < minDist) {
const push = (minDist - dist) / 2;
const nx = ddx / dist, ny = ddy / dist;
px[a] -= nx * push; py[a] -= ny * push;
px[b] += nx * push; py[b] += ny * push;
}
}
}
}
function rotateAll(dtheta: number): void {
const px = pxRef.current, py = pyRef.current;
if (!px || !py) return;
const W = wRef.current, H = hRef.current;
const cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (let i = 0; i < n; i++) {
const rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
function draw(): void {
const ctx = ctxRef.current;
const col = colRef.current;
const px = pxRef.current, py = pyRef.current, rad = radRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !px || !py || !rad) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < n; i++) {
ctx.beginPath();
ctx.fillStyle = "rgb(" + col.node.join(",") + ")";
ctx.arc(px[i], py[i], rad[i], 0, Math.PI * 2);
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = "rgba(" + col.ring.join(",") + ",0.7)";
ctx.stroke();
}
}
function settle(): void {
for (let s = 0; s < 260; s++) step(1 / 60);
draw();
}
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;
step(dt);
rotateAll(OMEGA * dt);
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
panel: tokRGB(root, "--gd-panel"),
node: tokRGB(root, "--gd-node"),
ring: tokRGB(root, "--gd-ring")
};
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) settle();
}
window.addEventListener("resize", onResize);
if (reduced()) {
settle();
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
}, [n, repel, speed]);
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>
);
}04 · 衝突回避 — ノードは自分の半径分だけ押し合い重ならない — 計測 累積面積 31.53% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
衝突補正パス — 位置を半分ずつ押し返す
反発力をどれだけ強くしても瞬間的に重なるフレームは残ります。積分の後に位置を直すこの1枚があって初めて、重なりが完全に消えます。
for (var a = 0; a < N; a++) {
for (var b = a + 1; b < N; b++) {
var ddx = px[b] - px[a], ddy = py[b] - py[a];
var dist = Math.hypot(ddx, ddy) || 0.001;
var minDist = rad[a] + rad[b];
if (dist < minDist) {
var push = (minDist - dist) / 2;
var nx = ddx / dist, ny = ddy / dist;
px[a] -= nx * push; py[a] -= ny * push;
px[b] += nx * push; py[b] += ny * push;
}
}
}
自由漂遊 — 温度としてのランダム力
JITTER の代わりに SPEED 78のランダム力を毎フレーム足し、ノードが止まらない温度にします。04の漂遊はこの温度が主役で、衝突補正がその読みやすさを守ります。
var cx = W / 2, cy = H / 2;
for (var n = 0; n < N; n++) {
fx[n] += (cx - px[n]) * CENTER_K;
fy[n] += (cy - py[n]) * CENTER_K;
fx[n] += (rand() - 0.5) * SPEED;
fy[n] += (rand() - 0.5) * SPEED;
vx[n] = (vx[n] + fx[n] * dt) * DAMP;
vy[n] = (vy[n] + fy[n] * dt) * DAMP;
px[n] += vx[n] * dt; py[n] += vy[n] * dt;
}
05階層ツリー — ルートから放射するレベル別配置
ルート(0番)の下に parent[] で系譜を記録しながらノードを3レベルに付け、Math.atan2 でルート基準の角度を毎フレーム測り直します。目標半径 level×RSTEP へ引く放射ばね(RADIAL_K)と、同じレベルの兄弟を角度で押し離す接線方向の力(TANGENT_K)を合わせると、方向性のある組織図が整列します。この盤にもエッジ描画はなく、parent[] と level[] の2つの配列だけが構造を運びます。累積面積32.872%・強度平均53.2です。

<main class="stage">
<div class="ge is-demo" id="geRoot" role="img" aria-label="ルートから放射する階層ツリーの実験版" tabindex="0">
<canvas class="ge__cv" id="geCv"></canvas>
<p class="ge__hint">
<span class="ge__title">階層ツリー</span>
<span class="ge__desc">深さごとに半径が広がるリングの中だけで力を計算します</span>
</p>
<div class="ge__bar">
<span class="ge__meta">ノード21個 · 3レベル · rAF</span>
<span class="ge__meta ge__meta--dim">lineTo · arc · Math.atan2</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계층 트리(05) — 무대 잉크. 방사 스프링 + 접선 힘 전부 rAF 캔버스 계산.
$ge-stage: #1a1720;
@include stage($ge-stage);
$ge-panel: #241f2d;
$ge-text: #d5cfe6;
$ge-edge: #6f6a82;
$ge-node: #cfd9e7;
$ge-root: #ebe2d6; // 루트 노드 — 따뜻한 살구빛으로 구분(파스텔)
$duration: $dur-loop;
$easing: $ease-out;
$color: $ge-node;
.ge {
--ge-panel: #{$ge-panel};
--ge-edge: #{$ge-edge};
--ge-node: #{$ge-node};
--ge-root: #{$ge-root};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px;
overflow: hidden;
}
.ge__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.ge__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: $ge-text;
}
.ge__title { text-shadow: 0 1px 6px rgba($ge-panel, 1); }
.ge__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($ge-text, .78);
text-shadow: 0 1px 6px rgba($ge-panel, 1);
}
.ge__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;
}
.ge__meta {
font-size: 10px; font-weight: 500; color: rgba($ge-text, .78);
background: rgba($ge-panel, .8);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.ge:focus-visible { outline: 2px solid $ge-node; outline-offset: -2px; }
@media (max-width: 340px) {
.ge__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ge.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: #1a1720;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ge {
--ge-panel: #241f2d;
--ge-edge: #6f6a82;
--ge-node: #cfd9e7;
--ge-root: #ebe2d6;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.ge__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.ge__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: #d5cfe6;
}
.ge__title {
text-shadow: 0 1px 6px #241f2d;
}
.ge__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(213, 207, 230, 0.78);
text-shadow: 0 1px 6px #241f2d;
}
.ge__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.ge__meta {
font-size: 10px;
font-weight: 500;
color: rgba(213, 207, 230, 0.78);
background: rgba(36, 31, 45, 0.8);
border-radius: 4px;
padding: 4px 4px;
}
.ge:focus-visible {
outline: 2px solid #cfd9e7;
outline-offset: -2px;
}
@media (max-width: 340px) {
.ge__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ge.is-still {
cursor: default !important;
}
}// 階層ツリー(05) — 루트에서 깊이가 늘 때마다 반지름이 커지는 링을 목표로 하는
// 방사(radial) 스프링과, 같은 레벨끼리만 각도로 밀어내는 접선 힘을 합쳐 방향성
// 있는 조직도가 저절로 정렬된다. Math.atan2 로 루트 기준 각도를 매 프레임 다시 잰다.
(function () {
// ── 변형 상수 블록 ──
var LEVELS = 3;
var PER_LEVEL = 7; // 레벨마다 노드 수
var RSTEP = 40; // 레벨 하나당 늘어나는 목표 반지름
// ────────────────────────
var RADIAL_K = 0.06;
var TANGENT_K = 60;
var PARENT_K = 0.03;
var DAMP = 0.95;
var JITTER = 46;
var OMEGA = 0.55;
var NR = 5;
var root = document.getElementById('geRoot');
var cvEl = document.getElementById('geCv');
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('--ge-panel');
var C_EDGE = tokRGB('--ge-edge');
var C_NODE = tokRGB('--ge-node');
var C_ROOT = tokRGB('--ge-root');
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(0x327005);
var N, px, py, vx, vy, level, parent;
function buildGraph() {
N = 1 + LEVELS * PER_LEVEL;
px = new Float32Array(N); py = new Float32Array(N);
vx = new Float32Array(N); vy = new Float32Array(N);
level = new Int8Array(N); parent = new Int32Array(N);
var cx = W / 2, cy = H / 2;
px[0] = cx; py[0] = cy; level[0] = 0; parent[0] = -1;
var idx = 1;
for (var lv = 1; lv <= LEVELS; lv++) {
var prevStart = lv === 1 ? 0 : 1 + (lv - 2) * PER_LEVEL;
var prevCount = lv === 1 ? 1 : PER_LEVEL;
for (var k = 0; k < PER_LEVEL; k++) {
level[idx] = lv;
parent[idx] = prevStart + Math.floor(rand() * prevCount);
var a = rand() * Math.PI * 2;
var r = lv * RSTEP;
px[idx] = cx + Math.cos(a) * r; py[idx] = cy + Math.sin(a) * r;
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) {
var fx = new Float32Array(N), fy = new Float32Array(N);
var cx = px[0], cy = py[0];
var ang = new Float32Array(N), rd = new Float32Array(N);
for (var i = 1; i < N; i++) {
var dx = px[i] - cx, dy = py[i] - cy;
rd[i] = Math.hypot(dx, dy) || 0.001;
ang[i] = Math.atan2(dy, dx);
var targetR = level[i] * RSTEP;
var diff = (rd[i] - targetR) * RADIAL_K;
var ux = dx / rd[i], uy = dy / rd[i];
fx[i] -= ux * diff; fy[i] -= uy * diff;
// 부모 쪽으로 약한 스프링 — 트리 모양을 유지
var pdx = px[parent[i]] - px[i], pdy = py[parent[i]] - py[i];
fx[i] += pdx * PARENT_K; fy[i] += pdy * PARENT_K;
}
// 같은 레벨끼리 각도로 밀어내기(접선 힘) — 겹치지 않게 링 위에 퍼진다
for (var a = 1; a < N; a++) {
for (var b = a + 1; b < N; b++) {
if (level[a] !== level[b]) continue;
var da = ang[a] - ang[b];
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
if (Math.abs(da) < 0.001) da = 0.02;
var strength = TANGENT_K / (Math.abs(da) * rd[a] + 8);
var sign = da > 0 ? 1 : -1;
// 접선 방향(반지름에 수직) 힘
var tx = -Math.sin(ang[a]), ty = Math.cos(ang[a]);
fx[a] += tx * strength * sign; fy[a] += ty * strength * sign;
var tx2 = -Math.sin(ang[b]), ty2 = Math.cos(ang[b]);
fx[b] -= tx2 * strength * sign; fy[b] -= ty2 * strength * sign;
}
}
for (var n = 1; n < N; n++) {
fx[n] += (rand() - 0.5) * JITTER;
fy[n] += (rand() - 0.5) * JITTER;
vx[n] = (vx[n] + fx[n] * dt) * DAMP;
vy[n] = (vy[n] + fy[n] * dt) * DAMP;
px[n] += vx[n] * dt; py[n] += vy[n] * dt;
}
}
function rotateAll(dtheta) {
var cx = px[0], cy = py[0], cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (var i = 1; i < N; i++) {
var rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
function draw(t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + C_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
ctx.lineWidth = 1.1;
ctx.strokeStyle = 'rgba(' + C_EDGE.join(',') + ',0.6)';
ctx.beginPath();
for (var i = 1; i < N; i++) {
ctx.moveTo(px[parent[i]], py[parent[i]]);
ctx.lineTo(px[i], py[i]);
}
ctx.stroke();
for (var n = 1; n < N; n++) {
var pulse = 0.5 + 0.5 * Math.sin(t * 2 + n * 0.8);
ctx.fillStyle = 'rgba(' + C_NODE.join(',') + ',' + (0.18 + 0.16 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(px[n], py[n], NR + 4 + pulse * 6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgb(' + C_NODE.join(',') + ')';
ctx.beginPath(); ctx.arc(px[n], py[n], NR, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = 'rgb(' + C_ROOT.join(',') + ')';
ctx.beginPath(); ctx.arc(px[0], py[0], NR + 2.4, 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);
rotateAll(OMEGA * dt);
draw(t);
requestAnimationFrame(frame);
}
function settle() {
for (var s = 0; s < 260; s++) step(1 / 60);
draw(0);
}
window.addEventListener('resize', function () {
fit();
if (reduce) settle();
});
fit();
if (reduce) {
settle();
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// GraphForceHierarchyTree — vanilla 판(run/327/demo/05-graph-force-hierarchy-tree/index.html) 의 React 포트.
// 루트에서 깊이가 늘 때마다 반지름이 커지는 링을 목표로 하는 방사(radial) 스프링과,
// 같은 레벨끼리만 각도로 밀어내는 접선 힘을 합쳐 방향성 있는 조직도가 저절로 정렬된다.
// Math.atan2 로 루트 기준 각도를 매 프레임 다시 잰다. 전체는 루트 기준 rotateAll 강체 회전.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GraphForceHierarchyTree.module.scss";
export type GraphForceHierarchyTreeProps = {
duration?: string;
easing?: string;
color?: string;
levels?: number; // 깊이 레벨 수
perLevel?: number; // 레벨마다 노드 수
rstep?: number; // 레벨 하나당 늘어나는 목표 반지름
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const LEVELS = 3;
const PER_LEVEL = 7;
const RSTEP = 40;
const RADIAL_K = 0.06;
const TANGENT_K = 60;
const PARENT_K = 0.03;
const DAMP = 0.95;
const JITTER = 46;
const OMEGA = 0.55;
const NR = 5;
const SEED = 0x327005;
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 = "노드 21개 · 3레벨 · rAF";
const META_RIGHT = "lineTo · arc · Math.atan2";
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 GraphForceHierarchyTree({
duration = "2s",
easing = "ease-out",
color = "#cfd9e7",
levels = LEVELS,
perLevel = PER_LEVEL,
rstep = RSTEP
}: GraphForceHierarchyTreeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ panel: RGB; edge: RGB; node: RGB; root: 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 pxRef = useRef<Float32Array | null>(null);
const pyRef = useRef<Float32Array | null>(null);
const vxRef = useRef<Float32Array | null>(null);
const vyRef = useRef<Float32Array | null>(null);
const levelRef = useRef<Int8Array | null>(null);
const parentRef = useRef<Int32Array | 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 W = wRef.current, H = hRef.current;
const N = 1 + levels * perLevel;
const px = new Float32Array(N), py = new Float32Array(N);
const vx = new Float32Array(N), vy = new Float32Array(N);
const level = new Int8Array(N), parent = new Int32Array(N);
const cx = W / 2, cy = H / 2;
px[0] = cx; py[0] = cy; level[0] = 0; parent[0] = -1;
let idx = 1;
for (let lv = 1; lv <= levels; lv++) {
const prevStart = lv === 1 ? 0 : 1 + (lv - 2) * perLevel;
const prevCount = lv === 1 ? 1 : perLevel;
for (let k = 0; k < perLevel; k++) {
level[idx] = lv;
parent[idx] = prevStart + Math.floor(rand() * prevCount);
const a = rand() * Math.PI * 2;
const r = lv * rstep;
px[idx] = cx + Math.cos(a) * r; py[idx] = cy + Math.sin(a) * r;
idx++;
}
}
nRef.current = N;
pxRef.current = px; pyRef.current = py; vxRef.current = vx; vyRef.current = vy;
levelRef.current = level; parentRef.current = parent;
}
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): void {
const px = pxRef.current, py = pyRef.current, vx = vxRef.current, vy = vyRef.current;
const level = levelRef.current, parent = parentRef.current;
const rand = randRef.current;
const N = nRef.current;
if (!px || !py || !vx || !vy || !level || !parent) return;
const fx = new Float32Array(N), fy = new Float32Array(N);
const cx = px[0], cy = py[0];
const ang = new Float32Array(N), rd = new Float32Array(N);
for (let i = 1; i < N; i++) {
const dx = px[i] - cx, dy = py[i] - cy;
rd[i] = Math.hypot(dx, dy) || 0.001;
ang[i] = Math.atan2(dy, dx);
const targetR = level[i] * rstep;
const diff = (rd[i] - targetR) * RADIAL_K;
const ux = dx / rd[i], uy = dy / rd[i];
fx[i] -= ux * diff; fy[i] -= uy * diff;
// 부모 쪽으로 약한 스프링 — 트리 모양을 유지
const pdx = px[parent[i]] - px[i], pdy = py[parent[i]] - py[i];
fx[i] += pdx * PARENT_K; fy[i] += pdy * PARENT_K;
}
// 같은 레벨끼리 각도로 밀어내기(접선 힘) — 겹치지 않게 링 위에 퍼진다
for (let a = 1; a < N; a++) {
for (let b = a + 1; b < N; b++) {
if (level[a] !== level[b]) continue;
let da = ang[a] - ang[b];
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
if (Math.abs(da) < 0.001) da = 0.02;
const strength = TANGENT_K / (Math.abs(da) * rd[a] + 8);
const sign = da > 0 ? 1 : -1;
// 접선 방향(반지름에 수직) 힘
const tx = -Math.sin(ang[a]), ty = Math.cos(ang[a]);
fx[a] += tx * strength * sign; fy[a] += ty * strength * sign;
const tx2 = -Math.sin(ang[b]), ty2 = Math.cos(ang[b]);
fx[b] -= tx2 * strength * sign; fy[b] -= ty2 * strength * sign;
}
}
for (let i = 1; i < N; i++) {
fx[i] += (rand() - 0.5) * JITTER;
fy[i] += (rand() - 0.5) * JITTER;
vx[i] = (vx[i] + fx[i] * dt) * DAMP;
vy[i] = (vy[i] + fy[i] * dt) * DAMP;
px[i] += vx[i] * dt; py[i] += vy[i] * dt;
}
}
function rotateAll(dtheta: number): void {
const px = pxRef.current, py = pyRef.current;
const N = nRef.current;
if (!px || !py) return;
const cx = px[0], cy = py[0], cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (let i = 1; i < N; i++) {
const rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const px = pxRef.current, py = pyRef.current, parent = parentRef.current;
const N = nRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !px || !py || !parent) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
ctx.lineWidth = 1.1;
ctx.strokeStyle = "rgba(" + col.edge.join(",") + ",0.6)";
ctx.beginPath();
for (let i = 1; i < N; i++) {
ctx.moveTo(px[parent[i]], py[parent[i]]);
ctx.lineTo(px[i], py[i]);
}
ctx.stroke();
for (let i = 1; i < N; i++) {
const pulse = 0.5 + 0.5 * Math.sin(t * 2 + i * 0.8);
ctx.fillStyle = "rgba(" + col.node.join(",") + "," + (0.18 + 0.16 * 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();
}
ctx.fillStyle = "rgb(" + col.root.join(",") + ")";
ctx.beginPath(); ctx.arc(px[0], py[0], NR + 2.4, 0, Math.PI * 2); ctx.fill();
}
function settle(): void {
for (let s = 0; s < 260; s++) step(1 / 60);
draw(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);
rotateAll(OMEGA * dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
panel: tokRGB(root, "--ge-panel"),
edge: tokRGB(root, "--ge-edge"),
node: tokRGB(root, "--ge-node"),
root: tokRGB(root, "--ge-root")
};
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) settle();
}
window.addEventListener("resize", onResize);
if (reduced()) {
settle();
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
}, [levels, perLevel, 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>
);
}05 · 階層ツリー — ルートから放射するレベル別配置 — 計測 累積面積 32.872% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
放射ばね — 角度を測り半径を目標へ引く
角度と半径を毎フレームatan2で読み直し、半径の差分を力に変換します。親への弱いばね PARENT_K が枝のつながりを画面に残します。
function step(dt) {
var fx = new Float32Array(N), fy = new Float32Array(N);
var cx = px[0], cy = py[0];
var ang = new Float32Array(N), rd = new Float32Array(N);
for (var i = 1; i < N; i++) {
var dx = px[i] - cx, dy = py[i] - cy;
rd[i] = Math.hypot(dx, dy) || 0.001;
ang[i] = Math.atan2(dy, dx);
var targetR = level[i] * RSTEP;
var diff = (rd[i] - targetR) * RADIAL_K;
var ux = dx / rd[i], uy = dy / rd[i];
fx[i] -= ux * diff; fy[i] -= uy * diff;
var pdx = px[parent[i]] - px[i], pdy = py[parent[i]] - py[i];
fx[i] += pdx * PARENT_K; fy[i] += pdy * PARENT_K;
}
接線分離 — 兄弟をリングの上に広げる力
放射ばねだけでは同じレベルの兄弟が同じ角度に集まります。角度差に反比例する強さの接線力が、兄弟をリングの上に均等に押し広げます。
for (var a = 1; a < N; a++) {
for (var b = a + 1; b < N; b++) {
if (level[a] !== level[b]) continue;
var da = ang[a] - ang[b];
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
if (Math.abs(da) < 0.001) da = 0.02;
var strength = TANGENT_K / (Math.abs(da) * rd[a] + 8);
var sign = da > 0 ? 1 : -1;
var tx = -Math.sin(ang[a]), ty = Math.cos(ang[a]);
fx[a] += tx * strength * sign; fy[a] += ty * strength * sign;
var tx2 = -Math.sin(ang[b]), ty2 = Math.cos(ang[b]);
fx[b] -= tx2 * strength * sign; fy[b] -= ty2 * strength * sign;
}
}
06放射軌道 — 同心円レベルを回りながら位置を定める
この盤は力の積分ではなく極座標で座標を直接計算します。ノードを4つのリングに6個ずつ付け、リングごとに符号が逆の角速度 RING_OMEGA で角度を進め、同じリング内で角度が近づくと SEP_K で押し離し、半径を BREATHE 14の幅で呼吸させます。回転はリング自身の角速度が担うため rotateAll はなく、それでも累積面積35.355%・強度平均57.3と十分に生きて動きます。

<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 に同梱 · パスワードを見る
step — リングごとの角速度と角度の分離
角度空間でのみ衝突を解きます。最小間隔 minGap は円周をPER_RINGで割った7割で、近づきすぎた組だけを押し返します。
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;
}
}
}
極座標への書き出し — 呼吸する半径
角度が決まれば座標は cos・sin の1行ずつです。半径に位相のずれた sin を足すことで、リング全体が静的な円ではなく呼吸する輪になります。
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;
}
}
07関係の強さ — 重みがリンクの長さと太さを変える
各エッジに重み w(0.15〜1.0)を付け、目標ばね長を LINK_BASE×(1.5−w) で計算します。重い関係ほど目標距離が短く、ばねの強さにも (0.5+w) が掛かります。描画側でも線幅 0.6+w×3.2 と不透明度 0.2+w×0.65 が w に比例し、強い関係は短く太く、弱い関係は長く細く描かれる — データの値を視覚言語に翻訳する1項です。累積面積47.947%・強度平均55.6。LINK_BASE は60から160まで動かせるのが3番目のバリエーションです。

<main class="stage">
<div class="gg is-demo" id="ggRoot" role="img" aria-label="重みがリンクの長さと太さを変えるネットワーク図の実験版" tabindex="0">
<canvas class="gg__cv" id="ggCv"></canvas>
<p class="gg__hint">
<span class="gg__title">関係の強さ</span>
<span class="gg__desc">強い関係は短く太く、弱い関係は長く細く描かれます</span>
</p>
<div class="gg__bar">
<span class="gg__meta">ノード16個 · 加重リンク · rAF</span>
<span class="gg__meta gg__meta--dim">strokeStyle · globalAlpha · lineTo</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 관계 강도(07) — 무대 테라코타. 링크 굵기·투명도까지 전부 rAF 매 프레임 재계산.
$gg-stage: #c49d92;
@include stage($gg-stage);
$gg-panel: #ead9d1;
$gg-text: #3a241c;
$gg-edge: #6b4f42;
$gg-node: #7c85a8;
$duration: $dur-loop;
$easing: $ease-out;
$color: $gg-node;
.gg {
--gg-panel: #{$gg-panel};
--gg-edge: #{$gg-edge};
--gg-node: #{$gg-node};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px;
overflow: hidden;
}
.gg__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.gg__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: $gg-text;
}
.gg__title { text-shadow: 0 1px 6px rgba($gg-panel, .9); }
.gg__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($gg-text, .78);
text-shadow: 0 1px 6px rgba($gg-panel, .9);
}
.gg__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;
}
.gg__meta {
font-size: 10px; font-weight: 500; color: rgba($gg-text, .8);
background: rgba($gg-panel, .82);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.gg:focus-visible { outline: 2px solid $gg-node; outline-offset: -2px; }
@media (max-width: 340px) {
.gg__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.gg.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: #c49d92;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gg {
--gg-panel: #ead9d1;
--gg-edge: #6b4f42;
--gg-node: #7c85a8;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.gg__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.gg__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: #3a241c;
}
.gg__title {
text-shadow: 0 1px 6px rgba(234, 217, 209, 0.9);
}
.gg__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(58, 36, 28, 0.78);
text-shadow: 0 1px 6px rgba(234, 217, 209, 0.9);
}
.gg__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.gg__meta {
font-size: 10px;
font-weight: 500;
color: rgba(58, 36, 28, 0.8);
background: rgba(234, 217, 209, 0.82);
border-radius: 4px;
padding: 4px 4px;
}
.gg:focus-visible {
outline: 2px solid #7c85a8;
outline-offset: -2px;
}
@media (max-width: 340px) {
.gg__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gg.is-still {
cursor: default !important;
}
}// 関係の強さ(07) — 엣지마다 무작위 가중치를 매기고, 그 값에 비례해 스프링 목표
// 길이와 선 굵기·투명도를 계산한다. 강한 관계는 짧고 굵고 진하게, 약한 관계는
// 길고 얇고 흐리게 — 데이터 값이 곧 그림의 언어가 된다.
(function () {
// ── 변형 상수 블록 ──
var N = 16;
var LINK_BASE = 60; // 목표 링크 거리 기준 — 변형: 60px → 160px
var ATTRACT = 0.03;
// ────────────────────────
var REPEL = 780;
var DAMP = 0.955;
var CENTER_K = 0.001;
var JITTER = 40;
var OMEGA = 0.4;
var NR = 5;
var root = document.getElementById('ggRoot');
var cvEl = document.getElementById('ggCv');
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('--gg-panel');
var C_EDGE = tokRGB('--gg-edge');
var C_NODE = tokRGB('--gg-node');
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(0x327007);
var px, py, vx, vy, edges;
function buildGraph() {
px = new Float32Array(N); py = new Float32Array(N);
vx = new Float32Array(N); vy = new Float32Array(N);
for (var i = 0; i < N; i++) { px[i] = rand() * W; py[i] = rand() * H; }
edges = [];
for (var a = 1; a < N; a++) {
edges.push({ a: a, b: Math.floor(rand() * a), w: 0.15 + rand() * 0.85 });
}
var extra = Math.round(N * 0.35);
for (var k = 0; k < extra; k++) {
var u = Math.floor(rand() * N), v = Math.floor(rand() * N);
if (u !== v) edges.push({ a: u, b: v, w: 0.15 + rand() * 0.85 });
}
}
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) {
var fx = new Float32Array(N), fy = new Float32Array(N);
for (var i = 0; i < N; i++) {
for (var j = i + 1; j < N; j++) {
var dx = px[j] - px[i], dy = py[j] - py[i];
var d2 = dx * dx + dy * dy; if (d2 < 4) d2 = 4;
var d = Math.sqrt(d2);
var f = REPEL / d2;
var ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
for (var e = 0; e < edges.length; e++) {
var ed = edges[e], a = ed.a, b = ed.b;
// 가중치가 클수록(관계가 강할수록) 목표 거리가 짧다
var rest = LINK_BASE * (1.5 - ed.w);
var dx2 = px[b] - px[a], dy2 = py[b] - py[a];
var d3 = Math.hypot(dx2, dy2) || 0.001;
var diff = (d3 - rest) * ATTRACT * (0.5 + ed.w);
var ux2 = dx2 / d3, uy2 = dy2 / d3;
fx[a] += ux2 * diff; fy[a] += uy2 * diff;
fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
}
var cx = W / 2, cy = H / 2;
for (var n = 0; n < N; n++) {
fx[n] += (cx - px[n]) * CENTER_K;
fy[n] += (cy - py[n]) * CENTER_K;
fx[n] += (rand() - 0.5) * JITTER;
fy[n] += (rand() - 0.5) * JITTER;
vx[n] = (vx[n] + fx[n] * dt) * DAMP;
vy[n] = (vy[n] + fy[n] * dt) * DAMP;
px[n] += vx[n] * dt; py[n] += vy[n] * dt;
}
}
function rotateAll(dtheta) {
var cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (var i = 0; i < N; i++) {
var rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + C_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
for (var e = 0; e < edges.length; e++) {
var ed = edges[e];
ctx.lineWidth = 0.6 + ed.w * 3.2;
ctx.strokeStyle = 'rgb(' + C_EDGE.join(',') + ')';
ctx.globalAlpha = 0.2 + ed.w * 0.65;
ctx.beginPath();
ctx.moveTo(px[ed.a], py[ed.a]);
ctx.lineTo(px[ed.b], py[ed.b]);
ctx.stroke();
}
ctx.globalAlpha = 1;
for (var i = 0; i < N; i++) {
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;
step(dt);
rotateAll(OMEGA * dt);
draw();
requestAnimationFrame(frame);
}
function settle() {
for (var s = 0; s < 260; s++) step(1 / 60);
draw();
}
window.addEventListener('resize', function () {
fit();
if (reduce) settle();
});
fit();
if (reduce) {
settle();
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// GraphForceLinkWeight — vanilla 판(run/327/demo/07-graph-force-link-weight/index.html) 의 React 포트.
// 엣지마다 무작위 가중치를 매기고, 그 값에 비례해 스프링 목표 길이·선 굵기·투명도를 계산한다.
// 강한 관계는 짧고 굵고 진하게, 약한 관계는 길고 얇고 흐리게 — 데이터 값이 그림의 언어가 된다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GraphForceLinkWeight.module.scss";
export type GraphForceLinkWeightProps = {
duration?: string;
easing?: string;
color?: string;
n?: number;
linkBase?: number; // 목표 링크 거리 기준
attract?: number;
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const N = 16;
const LINK_BASE = 60;
const ATTRACT = 0.03;
const REPEL = 780;
const DAMP = 0.955;
const CENTER_K = 0.001;
const JITTER = 40;
const OMEGA = 0.4;
const NR = 5;
const SEED = 0x327007;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Edge = { a: number; b: number; w: number };
const BOARD_LABEL = "가중치가 링크 길이와 굵기를 바꾸는 관계도 실험판";
const META_LEFT = "노드 16개 · 가중 링크 · rAF";
const META_RIGHT = "strokeStyle · globalAlpha · lineTo";
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 GraphForceLinkWeight({
duration = "2s",
easing = "ease-out",
color = "#7c85a8",
n = N,
linkBase = LINK_BASE,
attract = ATTRACT
}: GraphForceLinkWeightProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ panel: RGB; edge: RGB; node: 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 pxRef = useRef<Float32Array | null>(null);
const pyRef = useRef<Float32Array | null>(null);
const vxRef = useRef<Float32Array | null>(null);
const vyRef = useRef<Float32Array | null>(null);
const edgesRef = useRef<Edge[] | 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 W = wRef.current, H = hRef.current;
const px = new Float32Array(n), py = new Float32Array(n);
const vx = new Float32Array(n), vy = new Float32Array(n);
for (let i = 0; i < n; i++) { px[i] = rand() * W; py[i] = rand() * H; }
const edges: Edge[] = [];
for (let a = 1; a < n; a++) {
edges.push({ a, b: Math.floor(rand() * a), w: 0.15 + rand() * 0.85 });
}
const extra = Math.round(n * 0.35);
for (let k = 0; k < extra; k++) {
const u = Math.floor(rand() * n), v = Math.floor(rand() * n);
if (u !== v) edges.push({ a: u, b: v, w: 0.15 + rand() * 0.85 });
}
pxRef.current = px; pyRef.current = py; vxRef.current = vx; vyRef.current = vy;
edgesRef.current = edges;
}
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): void {
const px = pxRef.current, py = pyRef.current, vx = vxRef.current, vy = vyRef.current, edges = edgesRef.current;
const rand = randRef.current;
const W = wRef.current, H = hRef.current;
if (!px || !py || !vx || !vy || !edges) return;
const fx = new Float32Array(n), fy = new Float32Array(n);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const dx = px[j] - px[i], dy = py[j] - py[i];
let d2 = dx * dx + dy * dy; if (d2 < 4) d2 = 4;
const d = Math.sqrt(d2);
const f = REPEL / d2;
const ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
for (let e = 0; e < edges.length; e++) {
const ed = edges[e], a = ed.a, b = ed.b;
const rest = linkBase * (1.5 - ed.w);
const dx2 = px[b] - px[a], dy2 = py[b] - py[a];
const d3 = Math.hypot(dx2, dy2) || 0.001;
const diff = (d3 - rest) * attract * (0.5 + ed.w);
const ux2 = dx2 / d3, uy2 = dy2 / d3;
fx[a] += ux2 * diff; fy[a] += uy2 * diff;
fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
}
const cx = W / 2, cy = H / 2;
for (let i = 0; i < n; i++) {
fx[i] += (cx - px[i]) * CENTER_K;
fy[i] += (cy - py[i]) * CENTER_K;
fx[i] += (rand() - 0.5) * JITTER;
fy[i] += (rand() - 0.5) * JITTER;
vx[i] = (vx[i] + fx[i] * dt) * DAMP;
vy[i] = (vy[i] + fy[i] * dt) * DAMP;
px[i] += vx[i] * dt; py[i] += vy[i] * dt;
}
}
function rotateAll(dtheta: number): void {
const px = pxRef.current, py = pyRef.current;
if (!px || !py) return;
const W = wRef.current, H = hRef.current;
const cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (let i = 0; i < n; i++) {
const rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
function draw(): void {
const ctx = ctxRef.current;
const col = colRef.current;
const px = pxRef.current, py = pyRef.current, edges = edgesRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !px || !py || !edges) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
for (let e = 0; e < edges.length; e++) {
const ed = edges[e];
ctx.lineWidth = 0.6 + ed.w * 3.2;
ctx.strokeStyle = "rgb(" + col.edge.join(",") + ")";
ctx.globalAlpha = 0.2 + ed.w * 0.65;
ctx.beginPath();
ctx.moveTo(px[ed.a], py[ed.a]);
ctx.lineTo(px[ed.b], py[ed.b]);
ctx.stroke();
}
ctx.globalAlpha = 1;
for (let i = 0; i < n; i++) {
ctx.fillStyle = "rgb(" + col.node.join(",") + ")";
ctx.beginPath(); ctx.arc(px[i], py[i], NR, 0, Math.PI * 2); ctx.fill();
}
}
function settle(): void {
for (let s = 0; s < 260; s++) step(1 / 60);
draw();
}
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;
step(dt);
rotateAll(OMEGA * dt);
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
panel: tokRGB(root, "--gg-panel"),
edge: tokRGB(root, "--gg-edge"),
node: tokRGB(root, "--gg-node")
};
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) settle();
}
window.addEventListener("resize", onResize);
if (reduced()) {
settle();
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
}, [n, linkBase, attract]);
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>
);
}07 · 関係の強さ — 重みがリンクの長さと太さを変える — 計測 累積面積 47.947% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
重みつきばね — 目標距離を w から作る
01と同じ反発・スパニングツリー+ランダムエッジの土台に、エッジごとの w という1つの項を足すだけです。rest の式が変わればグラフ全体の肌理が変わります。
for (var e = 0; e < edges.length; e++) {
var ed = edges[e], a = ed.a, b = ed.b;
var rest = LINK_BASE * (1.5 - ed.w);
var dx2 = px[b] - px[a], dy2 = py[b] - py[a];
var d3 = Math.hypot(dx2, dy2) || 0.001;
var diff = (d3 - rest) * ATTRACT * (0.5 + ed.w);
var ux2 = dx2 / d3, uy2 = dy2 / d3;
fx[a] += ux2 * diff; fy[a] += uy2 * diff;
fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
}
08大規模パン&ズーム — 数百のノードをキャンバス座標で見渡す
140個のノードを1400×900のワールド座標に置き、力の計算はその元の座標系のままで回します。画面に描くときだけ translate・scale のカメラ変換を掛け、人が操作しない間は autoCam が sin・cos でゆっくり見渡します。ホイール(×0.89〜1.12)とドラッグの値はそのままカメラに引き継がれ、物理とカメラが完全に分離します。累積面積70.841%はこの回で最も広く、強度平均28.3が最も低い — 140ノードが画面全体を埋め替える一方、ノード1個は小さく画素あたりの差は浅くなります。この盤にも rotateAll はなく、カメラの移動が画面の運動を担います。

<main class="stage">
<div class="gh is-demo" id="ghRoot" role="application" aria-label="数百個のノードを持つグラフを見渡すパンズーム実験版" tabindex="0">
<canvas class="gh__cv" id="ghCv"></canvas>
<p class="gh__hint">
<span class="gh__title">大規模パンズーム</span>
<span class="gh__desc">キャンバス変換だけを変え、力の計算は元の座標系のまま動きます</span>
</p>
<div class="gh__bar">
<span class="gh__meta">ノード140個 · ホイール·ドラッグでパンズーム · rAF</span>
<span class="gh__meta gh__meta--dim">arc · lineTo · Math.hypot</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 대규모 팬줌(08) — 무대 종이. 카메라 변환·물리 둘 다 rAF 매 프레임 재계산.
$gh-stage: #f1ecdf;
@include stage($gh-stage);
$gh-panel: #e4dcc8;
$gh-text: #3c3324;
$gh-edge: #a99b7c;
$gh-node: #6a5a70;
$duration: $dur-loop;
$easing: $ease-out;
$color: $gh-node;
.gh {
--gh-panel: #{$gh-panel};
--gh-edge: #{$gh-edge};
--gh-node: #{$gh-node};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px;
overflow: hidden;
}
.gh__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; }
.gh__cv:active { cursor: grabbing; }
.gh__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: $gh-text;
}
.gh__title { text-shadow: 0 1px 6px rgba($gh-panel, .9); }
.gh__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($gh-text, .78);
text-shadow: 0 1px 6px rgba($gh-panel, .9);
}
.gh__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;
}
.gh__meta {
font-size: 10px; font-weight: 500; color: rgba($gh-text, .8);
background: rgba($gh-panel, .82);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.gh:focus-visible { outline: 2px solid $gh-node; outline-offset: -2px; }
@media (max-width: 340px) {
.gh__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.gh.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: #f1ecdf;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gh {
--gh-panel: #e4dcc8;
--gh-edge: #a99b7c;
--gh-node: #6a5a70;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.gh__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.gh__cv:active {
cursor: grabbing;
}
.gh__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: #3c3324;
}
.gh__title {
text-shadow: 0 1px 6px rgba(228, 220, 200, 0.9);
}
.gh__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(60, 51, 36, 0.78);
text-shadow: 0 1px 6px rgba(228, 220, 200, 0.9);
}
.gh__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.gh__meta {
font-size: 10px;
font-weight: 500;
color: rgba(60, 51, 36, 0.8);
background: rgba(228, 220, 200, 0.82);
border-radius: 4px;
padding: 4px 4px;
}
.gh:focus-visible {
outline: 2px solid #6a5a70;
outline-offset: -2px;
}
@media (max-width: 340px) {
.gh__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gh.is-still {
cursor: default !important;
}
}// 大規模パンズーム(08) — 물리 계산은 항상 원래(월드) 좌표계에서 돈다. 화면에 보이는
// 것은 카메라 변환(이동 camX·camY, 배율 zoom)을 씌운 결과일 뿐이다. .is-demo 동안은
// 카메라가 스스로 서서히 훑고, 실제 휠·드래그가 들어오면 그 값을 그대로 이어받는다.
(function () {
// ── 변형 상수 블록 ──
var N = 140;
var WORLD_W = 1400, WORLD_H = 900;
// ────────────────────────
var REPEL = 2600;
var ATTRACT = 0.018;
var REST = 42;
var DAMP = 0.93;
var CENTER_K = 0.0007;
var JITTER = 30;
var NR = 3.2;
var ZOOM_MIN = 0.28, ZOOM_MAX = 1.6;
var root = document.getElementById('ghRoot');
var cvEl = document.getElementById('ghCv');
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('--gh-panel');
var C_EDGE = tokRGB('--gh-edge');
var C_NODE = tokRGB('--gh-node');
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(0x327008);
var px, py, vx, vy, edges;
var camX = WORLD_W / 2, camY = WORLD_H / 2, zoom = 0.32;
var userDrag = false, lastPX = 0, lastPY = 0, userActive = false;
function buildGraph() {
px = new Float32Array(N); py = new Float32Array(N);
vx = new Float32Array(N); vy = new Float32Array(N);
for (var i = 0; i < N; i++) { px[i] = rand() * WORLD_W; py[i] = rand() * WORLD_H; }
edges = [];
for (var a = 1; a < N; a++) edges.push([a, Math.floor(rand() * a)]);
var extra = Math.round(N * 0.25);
for (var k = 0; k < extra; k++) {
var u = Math.floor(rand() * N), v = Math.floor(rand() * N);
if (u !== v) edges.push([u, v]);
}
}
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);
buildGraph();
}
function step(dt) {
var fx = new Float32Array(N), fy = new Float32Array(N);
for (var i = 0; i < N; i++) {
for (var j = i + 1; j < N; j++) {
var dx = px[j] - px[i], dy = py[j] - py[i];
var d2 = dx * dx + dy * dy; if (d2 < 9) d2 = 9;
if (d2 > 40000) continue; // 먼 쌍은 생략 — 큰 그래프 성능 보호
var d = Math.sqrt(d2);
var f = REPEL / d2;
var ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
for (var e = 0; e < edges.length; e++) {
var a = edges[e][0], b = edges[e][1];
var dx2 = px[b] - px[a], dy2 = py[b] - py[a];
var d3 = Math.hypot(dx2, dy2) || 0.001;
var diff = (d3 - REST) * ATTRACT;
var ux2 = dx2 / d3, uy2 = dy2 / d3;
fx[a] += ux2 * diff; fy[a] += uy2 * diff;
fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
}
var cx = WORLD_W / 2, cy = WORLD_H / 2;
for (var n = 0; n < N; n++) {
fx[n] += (cx - px[n]) * CENTER_K;
fy[n] += (cy - py[n]) * CENTER_K;
fx[n] += (rand() - 0.5) * JITTER;
fy[n] += (rand() - 0.5) * JITTER;
vx[n] = (vx[n] + fx[n] * dt) * DAMP;
vy[n] = (vy[n] + fy[n] * dt) * DAMP;
px[n] += vx[n] * dt; py[n] += vy[n] * dt;
}
}
// .is-demo 자동 카메라 훑기 — 사람이 조작하지 않는 동안 서서히 팬·줌한다
function autoCam(t) {
camX = WORLD_W / 2 + Math.sin(t * 0.3) * WORLD_W * 0.32;
camY = WORLD_H / 2 + Math.cos(t * 0.24) * WORLD_H * 0.28;
zoom = 0.62 + 0.4 * Math.sin(t * 0.5);
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + C_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.translate(W / 2, H / 2);
ctx.scale(zoom, zoom);
ctx.translate(-camX, -camY);
ctx.lineWidth = 1 / zoom;
ctx.strokeStyle = 'rgba(' + C_EDGE.join(',') + ',0.5)';
ctx.beginPath();
for (var e = 0; e < edges.length; e++) {
var a = edges[e][0], b = edges[e][1];
ctx.moveTo(px[a], py[a]); ctx.lineTo(px[b], py[b]);
}
ctx.stroke();
ctx.fillStyle = 'rgb(' + C_NODE.join(',') + ')';
for (var i = 0; i < N; i++) {
ctx.beginPath(); ctx.arc(px[i], py[i], NR, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
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);
if (!userActive && root.classList.contains('is-demo')) autoCam(t);
draw();
requestAnimationFrame(frame);
}
function worldXY(clientX, clientY) {
var r = cvEl.getBoundingClientRect();
var sx = (clientX - r.left) - W / 2;
var sy = (clientY - r.top) - H / 2;
return [camX + sx / zoom, camY + sy / zoom];
}
cvEl.addEventListener('pointerdown', function (e) {
userDrag = true; userActive = true;
lastPX = e.clientX; lastPY = e.clientY;
cvEl.setPointerCapture(e.pointerId);
});
cvEl.addEventListener('pointermove', function (e) {
if (!userDrag) return;
camX -= (e.clientX - lastPX) / zoom;
camY -= (e.clientY - lastPY) / zoom;
lastPX = e.clientX; lastPY = e.clientY;
});
window.addEventListener('pointerup', function () { userDrag = false; });
cvEl.addEventListener('wheel', function (e) {
e.preventDefault();
userActive = true;
var before = worldXY(e.clientX, e.clientY);
zoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * (e.deltaY < 0 ? 1.12 : 0.89)));
var after = worldXY(e.clientX, e.clientY);
camX += before[0] - after[0];
camY += before[1] - after[1];
}, { passive: false });
function settle() {
for (var s = 0; s < 200; s++) step(1 / 60);
autoCam(1.4);
draw();
}
window.addEventListener('resize', function () {
fit();
if (reduce) settle();
});
fit();
if (reduce) {
settle();
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// GraphForcePanZoomLarge — vanilla 판(run/327/demo/08-graph-force-pan-zoom-large/index.html) 의 React 포트.
// 물리 계산은 항상 원래(월드) 좌표계에서 돈다. 화면에 보이는 것은 카메라 변환
// (이동 camX·camY, 배율 zoom)을 씌운 결과일 뿐이다. 자동 상태에서는 카메라가 스스로
// 서서히 훑고, 실제 휠·드래그가 들어오면 그 값을 그대로 이어받는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GraphForcePanZoomLarge.module.scss";
export type GraphForcePanZoomLargeProps = {
duration?: string;
easing?: string;
color?: string;
n?: number;
worldW?: number;
worldH?: number;
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const N = 140;
const WORLD_W = 1400, WORLD_H = 900;
const REPEL = 2600;
const ATTRACT = 0.018;
const REST = 42;
const DAMP = 0.93;
const CENTER_K = 0.0007;
const JITTER = 30;
const NR = 3.2;
const ZOOM_MIN = 0.28, ZOOM_MAX = 1.6;
const SEED = 0x327008;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Edge = [number, number];
const BOARD_LABEL = "노드 수백 개짜리 그래프를 훑는 팬줌 실험판";
const META_LEFT = "노드 140개 · 휠·드래그 팬줌 · rAF";
const META_RIGHT = "arc · lineTo · Math.hypot";
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 GraphForcePanZoomLarge({
duration = "2s",
easing = "ease-out",
color = "#6a5a70",
n = N,
worldW = WORLD_W,
worldH = WORLD_H
}: GraphForcePanZoomLargeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ panel: RGB; edge: RGB; node: 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 pxRef = useRef<Float32Array | null>(null);
const pyRef = useRef<Float32Array | null>(null);
const vxRef = useRef<Float32Array | null>(null);
const vyRef = useRef<Float32Array | null>(null);
const edgesRef = useRef<Edge[] | null>(null);
const camRef = useRef({ x: worldW / 2, y: worldH / 2, zoom: 0.32 }) as Box<{ x: number; y: number; zoom: number }>;
const userDragRef = useRef(false) as Box<boolean>;
const lastPXRef = useRef(0) as Box<number>;
const lastPYRef = useRef(0) as Box<number>;
const userActiveRef = useRef(false) as Box<boolean>;
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 px = new Float32Array(n), py = new Float32Array(n);
const vx = new Float32Array(n), vy = new Float32Array(n);
for (let i = 0; i < n; i++) { px[i] = rand() * worldW; py[i] = rand() * worldH; }
const edges: Edge[] = [];
for (let a = 1; a < n; a++) edges.push([a, Math.floor(rand() * a)]);
const extra = Math.round(n * 0.25);
for (let k = 0; k < extra; k++) {
const u = Math.floor(rand() * n), v = Math.floor(rand() * n);
if (u !== v) edges.push([u, v]);
}
pxRef.current = px; pyRef.current = py; vxRef.current = vx; vyRef.current = vy;
edgesRef.current = edges;
}
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): void {
const px = pxRef.current, py = pyRef.current, vx = vxRef.current, vy = vyRef.current, edges = edgesRef.current;
const rand = randRef.current;
if (!px || !py || !vx || !vy || !edges) return;
const fx = new Float32Array(n), fy = new Float32Array(n);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const dx = px[j] - px[i], dy = py[j] - py[i];
let d2 = dx * dx + dy * dy; if (d2 < 9) d2 = 9;
if (d2 > 40000) continue; // 먼 쌍은 생략 — 큰 그래프 성능 보호
const d = Math.sqrt(d2);
const f = REPEL / d2;
const ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
for (let e = 0; e < edges.length; e++) {
const a = edges[e][0], b = edges[e][1];
const dx2 = px[b] - px[a], dy2 = py[b] - py[a];
const d3 = Math.hypot(dx2, dy2) || 0.001;
const diff = (d3 - REST) * ATTRACT;
const ux2 = dx2 / d3, uy2 = dy2 / d3;
fx[a] += ux2 * diff; fy[a] += uy2 * diff;
fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
}
const cx = worldW / 2, cy = worldH / 2;
for (let i = 0; i < n; i++) {
fx[i] += (cx - px[i]) * CENTER_K;
fy[i] += (cy - py[i]) * CENTER_K;
fx[i] += (rand() - 0.5) * JITTER;
fy[i] += (rand() - 0.5) * JITTER;
vx[i] = (vx[i] + fx[i] * dt) * DAMP;
vy[i] = (vy[i] + fy[i] * dt) * DAMP;
px[i] += vx[i] * dt; py[i] += vy[i] * dt;
}
}
// 자동 카메라 훑기 — 사람이 조작하지 않는 동안 서서히 팬·줌한다(vanilla .is-demo 상태 그대로)
function autoCam(t: number): void {
const cam = camRef.current;
cam.x = worldW / 2 + Math.sin(t * 0.3) * worldW * 0.32;
cam.y = worldH / 2 + Math.cos(t * 0.24) * worldH * 0.28;
cam.zoom = 0.62 + 0.4 * Math.sin(t * 0.5);
}
function draw(): void {
const ctx = ctxRef.current;
const col = colRef.current;
const px = pxRef.current, py = pyRef.current, edges = edgesRef.current;
const W = wRef.current, H = hRef.current;
const cam = camRef.current;
if (!ctx || !col || !px || !py || !edges) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.translate(W / 2, H / 2);
ctx.scale(cam.zoom, cam.zoom);
ctx.translate(-cam.x, -cam.y);
ctx.lineWidth = 1 / cam.zoom;
ctx.strokeStyle = "rgba(" + col.edge.join(",") + ",0.5)";
ctx.beginPath();
for (let e = 0; e < edges.length; e++) {
const a = edges[e][0], b = edges[e][1];
ctx.moveTo(px[a], py[a]); ctx.lineTo(px[b], py[b]);
}
ctx.stroke();
ctx.fillStyle = "rgb(" + col.node.join(",") + ")";
for (let i = 0; i < n; i++) {
ctx.beginPath(); ctx.arc(px[i], py[i], NR, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
function worldXY(clientX: number, clientY: number): [number, number] {
const cv = cvRef.current;
const cam = camRef.current;
const W = wRef.current, H = hRef.current;
if (!cv) return [cam.x, cam.y];
const r = cv.getBoundingClientRect();
const sx = (clientX - r.left) - W / 2;
const sy = (clientY - r.top) - H / 2;
return [cam.x + sx / cam.zoom, cam.y + sy / cam.zoom];
}
function settle(): void {
for (let s = 0; s < 200; s++) step(1 / 60);
autoCam(1.4);
draw();
}
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);
if (!userActiveRef.current) autoCam(t);
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
panel: tokRGB(root, "--gh-panel"),
edge: tokRGB(root, "--gh-edge"),
node: tokRGB(root, "--gh-node")
};
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) settle();
}
function onPointerDown(e: PointerEvent): void {
const cv2 = cvRef.current;
if (!cv2) return;
userDragRef.current = true; userActiveRef.current = true;
lastPXRef.current = e.clientX; lastPYRef.current = e.clientY;
cv2.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent): void {
if (!userDragRef.current) return;
const cam = camRef.current;
cam.x -= (e.clientX - lastPXRef.current) / cam.zoom;
cam.y -= (e.clientY - lastPYRef.current) / cam.zoom;
lastPXRef.current = e.clientX; lastPYRef.current = e.clientY;
}
function onPointerUp(): void {
userDragRef.current = false;
}
function onWheel(e: WheelEvent): void {
e.preventDefault();
userActiveRef.current = true;
const cam = camRef.current;
const before = worldXY(e.clientX, e.clientY);
cam.zoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, cam.zoom * (e.deltaY < 0 ? 1.12 : 0.89)));
const after = worldXY(e.clientX, e.clientY);
cam.x += before[0] - after[0];
cam.y += before[1] - after[1];
}
window.addEventListener("resize", onResize);
cv.addEventListener("pointerdown", onPointerDown);
cv.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
cv.addEventListener("wheel", onWheel, { passive: false });
if (reduced()) {
settle();
setStill(true);
} else {
t0 = -1; last = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cv.removeEventListener("pointerdown", onPointerDown);
cv.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
cv.removeEventListener("wheel", onWheel);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, worldW, worldH]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div
className={rootCls}
ref={rootRef}
style={vars}
role="application"
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>
);
}08 · 大規模パン&ズーム — 数百のノードをキャンバス座標で見渡す — 計測 累積面積 70.841% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
autoCam — 人が触れない間の自動見渡し
カメラの3つの値 camX・camY・zoom を周期の違う sin・cos で動かします。利用者のドラッグ・ホイールが入れば自動制御は退場し、その値を引き継ぎます。
function autoCam(t) {
camX = WORLD_W / 2 + Math.sin(t * 0.3) * WORLD_W * 0.32;
camY = WORLD_H / 2 + Math.cos(t * 0.24) * WORLD_H * 0.28;
zoom = 0.62 + 0.4 * Math.sin(t * 0.5);
}
draw — ワールド座標を画面へ射影する3行
save から restore までの間だけカメラ変換が効きます。線幅を 1/zoom で割るのは、縮小したときに線が消えないためです。
ctx.save();
ctx.translate(W / 2, H / 2);
ctx.scale(zoom, zoom);
ctx.translate(-camX, -camY);
ctx.lineWidth = 1 / zoom;
ctx.strokeStyle = 'rgba(' + C_EDGE.join(',') + ',0.5)';
ctx.beginPath();
for (var e = 0; e < edges.length; e++) {
var a = edges[e][0], b = edges[e][1];
ctx.moveTo(px[a], py[a]); ctx.lineTo(px[b], py[b]);
}
ctx.stroke();
ctx.restore();
09表紙の関係図 — サービス機能を力で結んで見せるヒーロー
製品機能名(ログイン・決済・通知など10個)をラベルにしたノードへ、01と同じスパニングツリー+ランダムエッジのグラフを力で配置します。各ノードは createRadialGradient の柔らかい光をまとい、位相の違う sin パルスで脈動します。力の計算と OMEGA 0.32の回転が合わさって、初訪問でサービスの構造がひと目で生き生きと動く — 計算シーン1つが第一印象になる組立例です。累積面積36.535%・強度平均52.7。

<main class="stage">
<div class="gi is-demo" id="giRoot" role="img" aria-label="サービス機能を力で結び付けるトップページ関係図" tabindex="0">
<canvas class="gi__cv" id="giCv"></canvas>
<p class="gi__hint">
<span class="gi__title">トップページ関係図</span>
<span class="gi__desc">関連する機能同士だけにばねを掛け、構造を一目で見せます</span>
</p>
<div class="gi__bar">
<span class="gi__meta">機能ノード10個 · ヒーローシーン · rAF</span>
<span class="gi__meta gi__meta--dim">createRadialGradient · arc · lineTo</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 대문 관계도(09) — 히어로 씬. 기능 이름 노드마다 radial gradient 광을 두른다.
$gi-stage: #d8cbe8;
@include stage($gi-stage);
$gi-panel: #eee5f5;
$gi-text: #322a45;
$gi-edge: #a99bc4;
$gi-node: #6a5a90;
$gi-glow: #b98fd6;
$duration: $dur-loop;
$easing: $ease-out;
$color: $gi-node;
.gi {
--gi-panel: #{$gi-panel};
--gi-edge: #{$gi-edge};
--gi-node: #{$gi-node};
--gi-glow: #{$gi-glow};
--gi-label: #{$gi-text};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px;
overflow: hidden;
}
.gi__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.gi__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: $gi-text;
}
.gi__title { text-shadow: 0 1px 6px rgba($gi-panel, .9); }
.gi__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($gi-text, .78);
text-shadow: 0 1px 6px rgba($gi-panel, .9);
}
.gi__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;
}
.gi__meta {
font-size: 10px; font-weight: 500; color: rgba($gi-text, .8);
background: rgba($gi-panel, .82);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.gi:focus-visible { outline: 2px solid $gi-node; outline-offset: -2px; }
@media (max-width: 340px) {
.gi__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.gi.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: #d8cbe8;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gi {
--gi-panel: #eee5f5;
--gi-edge: #a99bc4;
--gi-node: #6a5a90;
--gi-glow: #b98fd6;
--gi-label: #322a45;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.gi__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.gi__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: #322a45;
}
.gi__title {
text-shadow: 0 1px 6px rgba(238, 229, 245, 0.9);
}
.gi__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(50, 42, 69, 0.78);
text-shadow: 0 1px 6px rgba(238, 229, 245, 0.9);
}
.gi__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.gi__meta {
font-size: 10px;
font-weight: 500;
color: rgba(50, 42, 69, 0.8);
background: rgba(238, 229, 245, 0.82);
border-radius: 4px;
padding: 4px 4px;
}
.gi:focus-visible {
outline: 2px solid #6a5a90;
outline-offset: -2px;
}
@media (max-width: 340px) {
.gi__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gi.is-still {
cursor: default !important;
}
}// トップページ関係図(09) — 제품 기능 이름을 노드로 두고 관련된 것끼리만 스프링을 건다.
// 각 노드는 createRadialGradient 로 은은한 광을 두르고, 힘 계산과 회전으로
// 화면 전체가 첫 방문에 살아 움직이는 느낌을 준다.
(function () {
// ── 변형 상수 블록 ──
var LABELS = ['ログイン', '決済', '通知', '検索', 'チャット', 'ファイル', '設定', '統計', 'ダッシュボード', '連携'];
var ATTRACT = 0.026;
// ────────────────────────
var REPEL = 1500;
var REST = 70;
var DAMP = 0.95;
var CENTER_K = 0.0012;
var JITTER = 34;
var OMEGA = 0.32;
var NR = 6.4;
var root = document.getElementById('giRoot');
var cvEl = document.getElementById('giCv');
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('--gi-panel');
var C_EDGE = tokRGB('--gi-edge');
var C_NODE = tokRGB('--gi-node');
var C_GLOW = tokRGB('--gi-glow');
var C_TEXT = tokRGB('--gi-label');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
var N = LABELS.length;
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(0x327009);
var px, py, vx, vy, edges;
function buildGraph() {
px = new Float32Array(N); py = new Float32Array(N);
vx = new Float32Array(N); vy = new Float32Array(N);
for (var i = 0; i < N; i++) { px[i] = rand() * W; py[i] = rand() * H; }
edges = [];
for (var a = 1; a < N; a++) edges.push([a, Math.floor(rand() * a)]);
var extra = Math.round(N * 0.4);
for (var k = 0; k < extra; k++) {
var u = Math.floor(rand() * N), v = Math.floor(rand() * N);
if (u !== v) edges.push([u, v]);
}
}
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) {
var fx = new Float32Array(N), fy = new Float32Array(N);
for (var i = 0; i < N; i++) {
for (var j = i + 1; j < N; j++) {
var dx = px[j] - px[i], dy = py[j] - py[i];
var d2 = dx * dx + dy * dy; if (d2 < 4) d2 = 4;
var d = Math.sqrt(d2);
var f = REPEL / d2;
var ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
for (var e = 0; e < edges.length; e++) {
var a = edges[e][0], b = edges[e][1];
var dx2 = px[b] - px[a], dy2 = py[b] - py[a];
var d3 = Math.hypot(dx2, dy2) || 0.001;
var diff = (d3 - REST) * ATTRACT;
var ux2 = dx2 / d3, uy2 = dy2 / d3;
fx[a] += ux2 * diff; fy[a] += uy2 * diff;
fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
}
var cx = W / 2, cy = H / 2;
for (var n = 0; n < N; n++) {
fx[n] += (cx - px[n]) * CENTER_K;
fy[n] += (cy - py[n]) * CENTER_K;
fx[n] += (rand() - 0.5) * JITTER;
fy[n] += (rand() - 0.5) * JITTER;
vx[n] = (vx[n] + fx[n] * dt) * DAMP;
vy[n] = (vy[n] + fy[n] * dt) * DAMP;
px[n] += vx[n] * dt; py[n] += vy[n] * dt;
}
}
function rotateAll(dtheta) {
var cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (var i = 0; i < N; i++) {
var rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
function draw(t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + C_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
ctx.lineWidth = 1.2;
ctx.strokeStyle = 'rgba(' + C_EDGE.join(',') + ',0.55)';
ctx.beginPath();
for (var e = 0; e < edges.length; e++) {
var a = edges[e][0], b = edges[e][1];
ctx.moveTo(px[a], py[a]); ctx.lineTo(px[b], py[b]);
}
ctx.stroke();
ctx.font = '600 10px sans-serif';
ctx.textAlign = 'center';
for (var i = 0; i < N; i++) {
var pulse = 0.5 + 0.5 * Math.sin(t * 2 + i * 0.65);
var glowR = NR + 10 + pulse * 8;
var grad = ctx.createRadialGradient(px[i], py[i], 0, px[i], py[i], glowR);
grad.addColorStop(0, 'rgba(' + C_GLOW.join(',') + ',' + (0.5 + 0.3 * pulse).toFixed(3) + ')');
grad.addColorStop(1, 'rgba(' + C_GLOW.join(',') + ',0)');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(px[i], py[i], glowR, 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();
ctx.fillStyle = 'rgb(' + C_TEXT.join(',') + ')';
ctx.fillText(LABELS[i], px[i], py[i] - NR - 6);
}
}
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);
rotateAll(OMEGA * dt);
draw(t);
requestAnimationFrame(frame);
}
function settle() {
for (var s = 0; s < 260; s++) step(1 / 60);
draw(0);
}
window.addEventListener('resize', function () {
fit();
if (reduce) settle();
});
fit();
if (reduce) {
settle();
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// GraphForceHeroScene — vanilla 판(run/327/demo/09-graph-force-hero-scene/index.html) 의 React 포트.
// 제품 기능 이름을 노드로 두고 관련된 것끼리만 스프링을 건다. 각 노드는
// createRadialGradient 로 은은한 광을 두르고, 힘 계산과 rotateAll 강체 회전으로
// 화면 전체가 첫 방문에 살아 움직이는 느낌을 준다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GraphForceHeroScene.module.scss";
export type GraphForceHeroSceneProps = {
duration?: string;
easing?: string;
color?: string;
attract?: number;
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const LABELS = ["로그인", "결제", "알림", "검색", "채팅", "파일", "설정", "통계", "대시보드", "연동"];
const ATTRACT = 0.026;
const REPEL = 1500;
const REST = 70;
const DAMP = 0.95;
const CENTER_K = 0.0012;
const JITTER = 34;
const OMEGA = 0.32;
const NR = 6.4;
const SEED = 0x327009;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Edge = [number, number];
const BOARD_LABEL = "서비스 기능을 힘으로 엮어 보여주는 대문 관계도";
const META_LEFT = "기능 노드 10개 · 히어로 씬 · rAF";
const META_RIGHT = "createRadialGradient · arc · lineTo";
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 GraphForceHeroScene({
duration = "2s",
easing = "ease-out",
color = "#6a5a90",
attract = ATTRACT
}: GraphForceHeroSceneProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ panel: RGB; edge: RGB; node: RGB; glow: RGB; label: 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 n = LABELS.length;
const pxRef = useRef<Float32Array | null>(null);
const pyRef = useRef<Float32Array | null>(null);
const vxRef = useRef<Float32Array | null>(null);
const vyRef = useRef<Float32Array | null>(null);
const edgesRef = useRef<Edge[] | 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 W = wRef.current, H = hRef.current;
const px = new Float32Array(n), py = new Float32Array(n);
const vx = new Float32Array(n), vy = new Float32Array(n);
for (let i = 0; i < n; i++) { px[i] = rand() * W; py[i] = rand() * H; }
const edges: Edge[] = [];
for (let a = 1; a < n; a++) edges.push([a, Math.floor(rand() * a)]);
const extra = Math.round(n * 0.4);
for (let k = 0; k < extra; k++) {
const u = Math.floor(rand() * n), v = Math.floor(rand() * n);
if (u !== v) edges.push([u, v]);
}
pxRef.current = px; pyRef.current = py; vxRef.current = vx; vyRef.current = vy;
edgesRef.current = edges;
}
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): void {
const px = pxRef.current, py = pyRef.current, vx = vxRef.current, vy = vyRef.current, edges = edgesRef.current;
const rand = randRef.current;
const W = wRef.current, H = hRef.current;
if (!px || !py || !vx || !vy || !edges) return;
const fx = new Float32Array(n), fy = new Float32Array(n);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const dx = px[j] - px[i], dy = py[j] - py[i];
let d2 = dx * dx + dy * dy; if (d2 < 4) d2 = 4;
const d = Math.sqrt(d2);
const f = REPEL / d2;
const ux = dx / d, uy = dy / d;
fx[i] -= ux * f; fy[i] -= uy * f;
fx[j] += ux * f; fy[j] += uy * f;
}
}
for (let e = 0; e < edges.length; e++) {
const a = edges[e][0], b = edges[e][1];
const dx2 = px[b] - px[a], dy2 = py[b] - py[a];
const d3 = Math.hypot(dx2, dy2) || 0.001;
const diff = (d3 - REST) * attract;
const ux2 = dx2 / d3, uy2 = dy2 / d3;
fx[a] += ux2 * diff; fy[a] += uy2 * diff;
fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
}
const cx = W / 2, cy = H / 2;
for (let i = 0; i < n; i++) {
fx[i] += (cx - px[i]) * CENTER_K;
fy[i] += (cy - py[i]) * CENTER_K;
fx[i] += (rand() - 0.5) * JITTER;
fy[i] += (rand() - 0.5) * JITTER;
vx[i] = (vx[i] + fx[i] * dt) * DAMP;
vy[i] = (vy[i] + fy[i] * dt) * DAMP;
px[i] += vx[i] * dt; py[i] += vy[i] * dt;
}
}
function rotateAll(dtheta: number): void {
const px = pxRef.current, py = pyRef.current;
if (!px || !py) return;
const W = wRef.current, H = hRef.current;
const cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
for (let i = 0; i < n; i++) {
const rx = px[i] - cx, ry = py[i] - cy;
px[i] = cx + rx * cs - ry * sn;
py[i] = cy + rx * sn + ry * cs;
}
}
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const px = pxRef.current, py = pyRef.current, edges = edgesRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !px || !py || !edges) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
ctx.lineWidth = 1.2;
ctx.strokeStyle = "rgba(" + col.edge.join(",") + ",0.55)";
ctx.beginPath();
for (let e = 0; e < edges.length; e++) {
const a = edges[e][0], b = edges[e][1];
ctx.moveTo(px[a], py[a]); ctx.lineTo(px[b], py[b]);
}
ctx.stroke();
ctx.font = "600 10px sans-serif";
ctx.textAlign = "center";
for (let i = 0; i < n; i++) {
const pulse = 0.5 + 0.5 * Math.sin(t * 2 + i * 0.65);
const glowR = NR + 10 + pulse * 8;
const grad = ctx.createRadialGradient(px[i], py[i], 0, px[i], py[i], glowR);
grad.addColorStop(0, "rgba(" + col.glow.join(",") + "," + (0.5 + 0.3 * pulse).toFixed(3) + ")");
grad.addColorStop(1, "rgba(" + col.glow.join(",") + ",0)");
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(px[i], py[i], glowR, 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();
ctx.fillStyle = "rgb(" + col.label.join(",") + ")";
ctx.fillText(LABELS[i], px[i], py[i] - NR - 6);
}
}
function settle(): void {
for (let s = 0; s < 260; s++) step(1 / 60);
draw(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);
rotateAll(OMEGA * dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
panel: tokRGB(root, "--gi-panel"),
edge: tokRGB(root, "--gi-edge"),
node: tokRGB(root, "--gi-node"),
glow: tokRGB(root, "--gi-glow"),
label: tokRGB(root, "--gi-label")
};
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) settle();
}
window.addEventListener("resize", onResize);
if (reduced()) {
settle();
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
}, [attract]);
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>
);
}09 · 表紙の関係図 — サービス機能を力で結んで見せるヒーロー — 計測 累積面積 36.535% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
buildGraph — スパニングツリーで連結を保証する
a=1..N−1 で必ず過去のノードへ1本ずつ結ぶことでグラフが断片に分れないことを保証し、その上に N×0.4 本のランダムエッジで循環を作ります。シードつき乱数なので毎回同じグラフが再現されます。
function buildGraph() {
px = new Float32Array(N); py = new Float32Array(N);
vx = new Float32Array(N); vy = new Float32Array(N);
for (var i = 0; i < N; i++) { px[i] = rand() * W; py[i] = rand() * H; }
edges = [];
for (var a = 1; a < N; a++) edges.push([a, Math.floor(rand() * a)]);
var extra = Math.round(N * 0.4);
for (var k = 0; k < extra; k++) {
var u = Math.floor(rand() * N), v = Math.floor(rand() * N);
if (u !== v) edges.push([u, v]);
}
}
脈動するグロウ — 位相のずれた sin パルス
光の半径と不透明度を同じパルスで動かし、放射グラデーションの端で0に落とします。ノードごとに位相 i×0.65が違うため、全体がそろって点滅せず立ち上がり続けます。
for (var i = 0; i < N; i++) {
var pulse = 0.5 + 0.5 * Math.sin(t * 2 + i * 0.65);
var glowR = NR + 10 + pulse * 8;
var grad = ctx.createRadialGradient(px[i], py[i], 0, px[i], py[i], glowR);
grad.addColorStop(0, 'rgba(' + C_GLOW.join(',') + ',' + (0.5 + 0.3 * pulse).toFixed(3) + ')');
grad.addColorStop(1, 'rgba(' + C_GLOW.join(',') + ',0)');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(px[i], py[i], glowR, 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();
ctx.fillStyle = 'rgb(' + C_TEXT.join(',') + ')';
ctx.fillText(LABELS[i], px[i], py[i] - NR - 6);
}
なぜ数個の力だけで関係図が整うのか — 反発力とばね
力の種類はどの盤でも2つだけです。すべての組に働く距離の2乗に反比例する反発と、エッジだけに働く目標長へのばね。反発は近い組ほど強く、ばねは目標距離から離れるほど強い — この2つの釣り合いの点が、読みやすい図の配置そのものになります。ばねが短くしたい組は隣に置き、反発が重なりを禁じるためラベルが隠れず、結果として「つながったものは近く、独立なものは離れる」配置が物理的に必然として現れます。03が示すように、エッジが1本もなくても反発の強さの差だけで集団は分かれます。つまり力の設計とは、どの組を近くに置きたいかを強さで書くことです。
この2力を毎フレーム足し算し速度に積分するという形は、計算で座標を作るという点で311以降の計算軸の流れを汲みます。群れの規則だけで隊形が決まるボイドの9盤や、濃度の勾配だけで道が伸びる粘菌の9盤が個体の規則から形を作ったのと同じく、ここでは関係の強さというデータだけが形を決めます。spreadsheet の表から同じエンジンで関係図を起こすとき、変えるべきは edges の配列の中身だけで、力の部分は1文字も変わりません。
配布する zip はフォースダイレクテッドグラフ9盤を番号フォルダーごとに分け、各フォルダーに index.html と style.css だけを置いた形です。開くためのパスワードは6bpjfdyjで、この記事で引いた step()・rotateAll・autoCam の各関数もフォルダー内のソースにコメントごと収まっているため、手元で1行ずつ突き合わせて確かめられます。
引力の値ひとつでアクセシビリティ対応まで一緒に変わる3つの変形
- 引力の強さ: 01 attraction k=0.02 → 0.08。引く力が強くなるほど集団は速くまとまりますが、振動の幅も大きくなります。
- ノード数: 01 ノード20個 → 80個。個数が増えると安定までのフレームが伸び、反発の計算量は2乗で大きくなります。
- リンクの目標距離: 07 LINK_BASE 60 → 160。目標距離を伸ばすとグラフ全体が疎に広がり、ラベルの重なりが減ります。
reduced-motion では、@media (prefers-reduced-motion: reduce) の利用者にはループを回さず、step() を200〜260回先に走らせて収束した配置に固定し、キャンバスに .is-still クラスを付けます。残すのは散らばった初期配置ではなく収束済みの結果です。実験盤には「フォース配置ネットワーク図の実験版」などの aria-label と tabindex を与え、実際にポインターで操作する02・08に加えて01にも role="application" が付いており、残りの六つの盤(03〜07・09)は role="img" で区別してあります。画面の下にはノード数・技法・rAF の計測メタを1行で常に表示し、キャンバスの中身を文章でも読めるようにしています。設定画面を持たない軽量なネットワークグラフ ツールとして、この9つはそのままコピペで使えます。
CSSトランジションに置き換えると壊れる落とし穴
座標をCSSのトランジションや@keyframesで動かすと反発とばねが消え、決められたアニメーションに置き換わります。この回の前提は力を計算して座標そのものを更新することで、プロパティのトゥイーンはその対極です。
スパニングツリーなしにランダムエッジだけで作ると、グラフが複数の断片に分かれる確率が高くなります。a=1..N−1 の各ノードが過去の1点へ必ず結ぶことで連結を保証してから、循環を足します。
JITTER(温度)を外すと、力が収束した時点で速度も0に収束し画面が完全に止まります。毎フレームの小さなランダム力が、静止した配置の上でも画面を生かし続けます。
rotateAll を step() の前に呼ぶと、そのフレームの力計算が回転前の座標を基準に歪みます。また1ノードごとに違う角度で回すと剛体でなくなり、ばねの長さが保存されません。常に同じ中心・同じ角度で、step() の後に呼びます。
04の衝突を力だけで解こうとすると、REPELをどれだけ大きくしても瞬間的に重なるフレームが残ります。積分後の位置補正パスが別にあってこそ重なりが完全に消えます。05・06で接線方向の分離力を外すと、同じレベルの兄弟が全部同じ角度に集まります。08で力の計算を画面座標で行うと、ズームのたびに物理のスケールが変わります — 計算は常にワールド座標で、描画のときだけ変換します。
この画面はどこで使う — 組織図とおすすめ関係網
9盤の中で最初に貼る場所として向いているのは、09 表紙の関係図のようなヒーローセクションです。見出しの裏でサービス機能同士が力で結ばれる様子がそのまま第一印象になり、02 ドラッグ再配置を並べれば訪問者が自分でノードを引いて全体が再びバランスを取る様子まで体験できます。
組織図やチームページには05 階層ツリーのほうが向いています。ルートからレベルごとに放射する配置なので上下関係が位置だけで読み取れ、03 クラスタ色分けを添えれば部署ごとの塊が説明文なしでも一目でわかります。
フォース配置グラフを試す前によくある質問
ネットワークグラフ javascriptで自作するとき、まず何を書けばいいですか?
01の step() をそのまま写すのが最短です。全ペアの反発 REPEL/d² とエッジのばね (距離−REST)×ATTRACT を足し、DAMP で減衰して座標を動かす22行が本体で、残りの行はグラフをInitializationする配列の準備です。D3-force も WebGL も要らず、getContext('2d') のキャンバス1枚で動きます。
D3-force などのライブラリとの違いや、ノードが増えたときの計算量はどう変わりますか?
自作では力の1つ1つが自分のコードとして読める点が違います。03の反発の非対称、04の位置補正、07の重みつきばねのような「項を1つ足す」改造が、ライブラリの内部を学ばなくても自分の式に1行足すだけで済みます。この回の9つはどれもその積み重ねで、全ソースを合わせてもライブラリ1個分の理解コストに届きません。計算量では反発が全ペア走査の O(n²) なので、ノードを2倍にすると反発の計算量は4倍になります。08は140ノードで d²>40000 の遠い組を打ち切る工夫を入れています。1,000ノードを超えるくらいから空間分割や Barnes-Hut 式の近似が要りますが、100ノード前後までのグラフなら毎フレーム全走査でも十分に追いつきます。
収束した後も画面が動き続けるのはなぜですか?
2つの仕掛けの合わせ技です。毎フレームの小さなランダム力 JITTER(温度)が速度を完全には0にさせず、力の計算結果には触れず画面全体だけを回す rotateAll が収束済みの配置を剛体として回し続けます。06と08は回転の代わりにリング自身の角速度とカメラの見渡しが同じ役割を担います。









