네트워크 그래프 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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 기본 힘 배치 — 척력과 스프링만으로 흩어진 점이 정돈된다
- 02 드래그 재배치 — 노드 하나를 끌면 전체가 다시 균형 잡는다
- 03 군집 색 구분 — 그룹끼리 더 세게 당겨 뭉친다
- 04 충돌 방지 — 노드 반지름만큼 서로 밀어내 겹침이 없다
- 05 계층 트리 — 루트에서 방사하는 레벨별 배치
- 06 방사형 궤도 — 동심원 레벨을 따라 회전하며 자리 잡는다
- 07 관계 강도 — 가중치가 링크 길이와 굵기를 바꾼다
- 08 대규모 팬줌 — 노드 수백 개를 캔버스 좌표로 훑는다
- 09 대문 관계도 — 서비스 기능들을 힘으로 엮어 보여주는 히어로
데모
아홉 판 전부 파일 하나씩으로 열리자마자 계산이 시작되고, 화면의 모든 변화는 CSS 키프레임이 아니라 노드 좌표를 매 프레임 다시 계산해 다시 그린 결과입니다. 24프레임 녹화에서 아홉 판 모두 23프레임이 움직였습니다(23/23). 04 는 반지름 합보다 가까운 노드 쌍을 직접 겨냥한 위치 보정 패스 때문에 강도 평균이 83.3 으로 가장 밝고, 08 은 노드 140개를 넓은 좌표계에 흩어 두고 카메라만 훑기 때문에 누적면적이 70.841% 로 가장 넓습니다.
24프레임 실측 한눈에
| 항목 | 초점 | 누적면적(%) | 강도 평균 |
|---|---|---|---|
| 01 기본 힘 배치 | 척력+스프링의 최소 구현 | 46.931 | 53.7 |
| 02 드래그 재배치 | 잡은 노드 강제 고정 | 39.459 | 53.7 |
| 03 군집 색 구분 | 그룹별 척력 비대칭 | 55.851 | 42.1 |
| 04 충돌 방지 | 힘 뒤의 위치 보정 패스 | 31.53 | 83.3 |
| 05 계층 트리 | 방사 스프링+접선 분리 | 32.872 | 53.2 |
| 06 방사형 궤도 | 링별 각속도+극좌표 | 35.355 | 57.3 |
| 07 관계 강도 | 가중치가 정하는 목표 거리 | 47.947 | 55.6 |
| 08 대규모 팬줌 | 월드 좌표+카메라 분리 | 70.841 | 28.3 |
| 09 대문 관계도 | 글로우 펄스+스프링 | 36.535 | 52.7 |
수치는 계산 방식의 차이로 읽는다
누적면적이 08 에서 유독 넓은 것은 카메라가 넓은 월드를 훑으며 노드 140개가 프레임마다 다른 자리를 채우기 때문이지, 노드 하나하나가 밝아서가 아닙니다 — 그래서 강도 평균은 28.3 으로 아홉 판 중 가장 낮습니다. 반대로 04 충돌 방지는 누적면적이 31.53% 로 가장 좁은데도 강도 평균이 83.3 으로 가장 밝습니다. 힘 적분이 끝난 뒤 반지름 합보다 가까운 쌍을 그 자리에서 절반씩 밀어내는 보정 패스가 프레임마다 픽셀을 뚜렷하게 뒤집기 때문입니다. 03 군집 색 구분(55.851%)은 엣지가 하나도 없이 척력 비대칭만으로 화면 전역이 계속 재배치되어 두 번째로 넓고, 나머지 항목은 32~48% 대에 모입니다.
척력·스프링에서 극좌표까지, 그래프 좌표를 다시 구한 순서
순서는 화려함이 아니라 좌표가 어디서 나오는가를 한 겹씩 넓히는 서열입니다. 먼저 척력과 스프링만 더하는 최소 구현으로 힘 배치의 뼈대를 보이고(01), 그 힘 중 하나를 포인터가 대신 쥐게 해 정적인 그림을 만지는 도구로 바꿉니다(02). 세 번째는 엣지를 아예 빼고 척력의 크기만 그룹에 따라 다르게 주어 색깔별 덩어리가 갈라지는 것을 보이고(03), 네 번째는 힘 적분 뒤에 위치를 직접 고치는 보정 패스를 얹어 겹침 없는 실사용 화면을 만듭니다(04). 다섯째·여섯째는 좌표 계산 자체를 바꿉니다 — 05 는 부모-자식 관계를 반지름과 각도로 옮기고, 06 은 힘을 아예 쓰지 않고 극좌표와 링별 각속도로 좌표를 직접 정합니다. 일곱 번째는 다시 01 의 엣지 구조로 돌아와 가중치 하나를 얹어 강한 관계를 짧고 굵게 그리고(07), 여덟 번째는 같은 힘 계산을 월드 좌표로 확장해 노드 140개를 카메라로 훑습니다(08). 마지막은 제품 기능 이름을 노드로 두고 글로우 펄스까지 더해 대문 장면으로 조립합니다(09). 매 프레임 좌표를 다시 계산해 관계를 드러낸다는 점에서 떼를 이룬 점들이 이웃의 속도만 보고 대형을 만드는 보이드 군집 시뮬레이션과 같은 계산 계보의 이웃이고, 얽힌 것을 규칙 하나로 풀어 읽기 좋은 그림으로 만든다는 점은 핀과 실의 감기 순서를 계산해 곡선을 얻는 스트링아트와도 통합니다.
항목별 코드
01기본 힘 배치 — 척력과 스프링만으로 흩어진 점이 정돈된다
모든 노드 쌍끼리 밀어내는 척력과 엣지로 이어진 쌍끼리 당기는 스프링을 매 프레임 더하는데, 힘 기반 그래프 배치의 최소 구현입니다. 그래프는 스패닝 트리로 전체 연결을 먼저 보장한 뒤 무작위 엣지를 더 얹어 순환을 만듭니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
모든 쌍의 척력과 엣지의 스프링을 더하는 핵심 루프
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;
}
N=18·REPEL=900·ATTRACT=0.02·REST=46·DAMP=0.955·JITTER=50·OMEGA=0.5 로 돌아가며, 누적면적 46.931%·강도 평균 53.7 이 실측값입니다.
02드래그 재배치 — 노드 하나를 끌면 전체가 다시 균형 잡는다
01 과 같은 힘 계산에 포인터로 잡은 노드 하나를 매 프레임 좌표에 강제 고정해 적분에서 빼는 항 하나만 끼웁니다. 그 힘이 이웃 스프링을 타고 퍼져 나머지 노드가 실시간으로 재배치되는데, .is-demo 동안은 사람이 만지지 않아도 같은 함수를 자동 각본(3구간 반복, 주기 5.6초)이 반복 호출합니다.

<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 · 드래그 재배치 — 노드 하나를 끌면 전체가 다시 균형 잡는다 — 측정 누적면적 39.459% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
잡은 노드는 힘 적분과 회전 둘 다에서 빠진다
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;
}
}
N=14·REPEL=760·ATTRACT=0.03·REST=44·DAMP=0.95·HIT_R=16·OMEGA=0.4, 누적면적 39.459%·강도 평균 53.7 이 실측값입니다.
03군집 색 구분 — 그룹끼리 더 세게 당겨 뭉친다
노드를 그룹으로 나누고(grp[i]=i%GROUPS) 같은 그룹끼리는 척력 배율 INTRA_REPEL(작게), 다른 그룹끼리는 INTER_REPEL(크게)을 곱합니다. 그룹 무게중심으로 당기는 CENTROID_K 를 더하면 엣지·스프링이 하나도 없이 척력 비대칭만으로 색깔별 덩어리가 화면 위에서 스스로 갈라지는데, 데이터 안의 군집을 힘만으로 드러내는 항목입니다.

<main class="stage">
<div class="gc is-demo" id="gcRoot" role="img" aria-label="색으로 나뉜 세 군집이 힘으로 뭉치는 관계도" 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
그룹별 척력 배율과 무게중심 재계산
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]; } }
N=30·GROUPS=3·REPEL=640·INTRA_REPEL=0.5·INTER_REPEL=1.9·CENTROID_K=0.05·OMEGA=0.75, 누적면적 55.851%·강도 평균 42.1 이 실측값입니다.
04충돌 방지 — 노드 반지름만큼 서로 밀어내 겹침이 없다
노드마다 반지름을 다르게 주고 척력과 무작위 유영으로 자유롭게 떠다니게 한 뒤, 힘 적분이 끝난 다음 중심 거리가 반지름 합보다 가까운 쌍을 찾아 그 자리에서 절반씩 즉시 밀어내는 위치 보정 패스를 한 겹 더 끼웁니다. 힘이 아니라 위치 자체를 고치는 하드 충돌 해소라 실제 서비스에 바로 쓸 수 있게 라벨이 겹치지 않습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
힘 적분 뒤에 따로 도는 위치 보정 패스
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;
}
}
}
N=22·REPEL=420·RMIN=6·RMAX=15·SPEED=78·DAMP=0.965·OMEGA=0.6, 누적면적 31.53%·강도 평균 83.3(아홉 판 중 가장 밝음)이 실측값입니다.
05계층 트리 — 루트에서 방사하는 레벨별 배치
루트 노드 아래 레벨마다 자식을 붙이고 parent[] 로 계보를 기록합니다. Math.atan2 로 매 프레임 루트 기준 각도를 다시 재고, 부모 쪽으로 당기는 방사 스프링과 같은 레벨끼리 각도로 밀어내는 접선 힘을 합쳐 방향성 있는 조직도가 저절로 정렬되는데, 01 처럼 무작위 엣지를 두지 않고 부모-자식 관계 하나로만 스프링을 겁니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
목표 반지름과의 차를 미는 방사 스프링, 부모를 당기는 스프링
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;
}
LEVELS=3·PER_LEVEL=7(N=22)·RSTEP=40·RADIAL_K=0.06·TANGENT_K=60·PARENT_K=0.03·OMEGA=0.55, 누적면적 32.872%·강도 평균 53.2 가 실측값입니다.
06방사형 궤도 — 동심원 레벨을 따라 회전하며 자리 잡는다
노드를 동심원 레벨에 나눠 배치하고 힘 배치가 아니라 극좌표(Math.cos·Math.sin)로 좌표를 직접 계산합니다. 링마다 다른 각속도(부호도 반대)로 돌리고 같은 링 안에서 각도 간격이 좁아지면 접선 방향으로 밀어내는 힘으로 겹침을 막으며 반지름이 숨쉬듯 흔드는데, 아홉 판 중 유일하게 속도 적분과 rotateAll 회전을 함께 버린 항목입니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
링별 각속도로 각도를 돌리고 반지름·각도로 좌표를 직접 정한다
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;
}
RINGS=4·PER_RING=6(N=24)·RSTEP=32·RING_OMEGA=[0.5,-0.38,0.62,-0.46]·SEP_K=0.9·BREATHE=14, 누적면적 35.355%·강도 평균 57.3 이 실측값입니다.
07관계 강도 — 가중치가 링크 길이와 굵기를 바꾼다
엣지마다 가중치 w(0.15~1.0)를 매기고 목표 스프링 길이를 LINK_BASE×(1.5−w) 로 계산합니다(가중치가 클수록 목표 거리가 짧습니다). 선 굵기와 투명도도 w 에 비례시키면 01 과 같은 스패닝 트리+무작위 엣지 그래프 위에 가중치 항 하나만 얹어 강한 관계는 짧고 굵게, 약한 관계는 길고 얇게 그려집니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
가중치가 목표 거리를 정하고, 같은 가중치가 선 굵기·투명도를 정한다
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;
}
for (var e2 = 0; e2 < edges.length; e2++) {
var ed2 = edges[e2];
ctx.lineWidth = 0.6 + ed2.w * 3.2;
ctx.globalAlpha = 0.2 + ed2.w * 0.65;
ctx.beginPath();
ctx.moveTo(px[ed2.a], py[ed2.a]);
ctx.lineTo(px[ed2.b], py[ed2.b]);
ctx.stroke();
}
N=16·LINK_BASE=60(변형: 60→160)·ATTRACT=0.03·REPEL=780·OMEGA=0.4, 누적면적 47.947%·강도 평균 55.6 이 실측값입니다.
08대규모 팬줌 — 노드 수백 개를 캔버스 좌표로 훑는다
노드 140개를 1400×900 의 넓은 좌표계에 두고 힘 계산은 그 원래 좌표계에서 그대로 돌립니다. 화면에 그릴 때만 카메라 변환(이동·배율)을 씌우고, .is-demo 동안은 카메라가 sin·cos 로 스스로 서서히 훑다가 실제 휠·드래그가 들어오면 그 값을 그대로 이어받는데, 물리와 카메라가 완전히 분리된 구성입니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
휠 아래 좌표를 월드 좌표로 바꿔 그 자리를 축으로 확대한다
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('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 });
N=140·WORLD 1400×900·REPEL=2600·ZOOM 0.28~1.6·DAMP=0.93, 누적면적 70.841%(아홉 판 중 가장 넓음)·강도 평균 28.3(아홉 판 중 가장 낮음)이 실측값입니다.
09대문 관계도 — 서비스 기능들을 힘으로 엮어 보여주는 히어로
제품 기능 이름(로그인·결제·알림 등 10개)을 노드로 두고 01 과 같은 스패닝 트리+무작위 엣지 그래프를 힘으로 배치합니다. 각 노드는 createRadialGradient 로 은은한 광을 두른 채 위상이 다른 sin 펄스로 맥동하고, 힘 계산과 rotateAll 회전이 합쳐져 화면 전체가 첫 방문에 살아 움직이는 인상을 줍니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
노드마다 다른 위상의 펄스가 글로우 반경과 밝기를 흔든다
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();
}
N=10(라벨 고정)·REPEL=1500·ATTRACT=0.026·REST=70·OMEGA=0.32, 누적면적 36.535%·강도 평균 52.7 이 실측값입니다.
인력 값 하나만 바꿔도 접근성 처리까지 함께 드러나는 세 변형
값 하나를 바꾼 세 변형
| 이름 | 값 | 느낌 |
|---|---|---|
| 인력 강도 | 01 attraction k=0.02 → k=0.08 | 당기는 힘이 강해질수록 군집이 빨리 뭉치지만 진동 폭도 커진다 |
| 노드 개수 | 01 노드 20개 → 80개 | 개수가 늘면 안정되기까지 프레임이 늘고 척력 계산량이 제곱으로 커진다 |
| 링크 목표 거리 | 07 link-distance 60px → 160px | 목표 거리를 늘리면 그래프 전체가 성기게 펼쳐져 라벨이 덜 겹친다 |
동작을 줄인 사용자에게 남는 장면
prefers-reduced-motion: reduce 사용자에게는 requestAnimationFrame 루프를 돌리지 않고 step() 을 200~260회 미리 돌려 수렴 상태로 고정한 뒤 캔버스에 .is-still 클래스를 붙입니다. 흩어진 초기 배치가 아니라 척력과 스프링이 이미 상쇄된 정돈된 결과가 남는데, 06 은 애초에 힘 적분이 없어 각도 갱신만 멈추면 되고 08 은 카메라 자동 훑기만 정지합니다.
포인터로 만지는 두 판과 화면 낭독
실제 포인터로 상호작용하는 02·08 과 함께 01 도 role="application" 이 붙어 있고, 나머지 여섯 판(03~07·09)은 role="img" 로 구분해 두었습니다. 캔버스를 담은 실험판마다 사람이 읽을 수 있는 aria-label(「힘 배치 관계도 실험판」류)과 tabindex 를 주어 화면 낭독 사용자도 무엇을 보고 있는지 알 수 있게 했고, 화면 아래 한 줄에 노드 수·기법·rAF 같은 측정 메타를 항상 표시해 노드 엣지 그래프 데모라는 것이 텍스트로도 전달되게 했습니다.
CSS 트랜지션으로 좌표를 옮기면 함정이 시작된다
좌표를 CSS 트랜지션으로 옮기면 힘이 사라진다
좌표를 CSS 트랜지션이나 @keyframes 로 옮기면 이 편의 원리 자체가 사라집니다 — 힘을 계산해 좌표를 갱신하는 것과 정해진 애니메이션을 재생하는 것은 전혀 다른 화면입니다. 04 의 충돌도 척력만으로 해결하려 들면 REPEL 값을 아주 크게 잡아도 순간적으로 겹치는 프레임이 생기는데, 힘 적분 뒤에 반지름 합보다 가까운 쌍을 절반씩 밀어내는 위치 보정 패스가 따로 있어야 겹침이 완전히 사라집니다. 05·06 에서 같은 레벨(링)끼리 밀어내는 접선 힘을 빼면 방사 스프링·각속도만으로는 형제 노드가 전부 같은 각도로 몰려 링 위에 퍼지지 않습니다.
무작위 힘을 빼면 수렴한 화면이 완전히 멈춘다
JITTER(온도)를 빼면 힘이 수렴한 뒤 속도가 0 에 가까워져 화면이 완전히 멈춥니다. 01·02·03·05·07·08·09 는 매 프레임 작은 무작위 힘을 더해 정지 상태에서도 화면을 살려 두고, 04 는 같은 역할을 자유 유영 힘(SPEED)이, 06 은 반지름 숨쉬기(BREATHE)와 링별 각속도가 대신합니다. rotateAll 을 힘 계산 전에 부르거나 노드별로 다른 각도로 돌리는 것도 같은 계열의 실수입니다 — 이 강체 회전은 모든 노드를 같은 중심·같은 각도로 돌려 거리·각도를 보존하는 트릭이라 step() 전에 부르면 그 프레임의 힘 계산이 회전 전 좌표를 기준으로 틀어지고, 08 처럼 화면(스크린) 좌표에서 힘을 계산하면 확대·축소할 때마다 물리 스케일 자체가 달라집니다. 이 아홉 판은 노드 배치부터 회전 루프까지 통째로 index.html 하나씩으로 들어 있는데 그 원본을 묶은 암호 zip 의 비밀번호는 6bpjfdyj 이고, 풀면 본문의 코드 조각과 줄 단위로 나란히 대조해 보실 수 있습니다.
왜 힘 몇 개로 관계도가 저절로 정돈되는가 — 척력과 스프링
척력과 스프링이 서로를 상쇄하는 자리에서 배치가 멈춘다
모든 노드 쌍에 거리제곱의 역수에 비례하는 척력을 서로 반대 방향으로 더하고, 엣지가 있는 쌍에는 목표 길이와의 차에 비례하는 스프링을 더합니다. 두 힘을 속도에 누적해 DAMP 로 감쇠하고 그 속도로 좌표를 갱신하는 식이라 노드는 속성 트윈이 아니라 힘 적분으로 움직입니다. 너무 가까운 쌍은 척력이 커서 밀려나고 너무 먼 엣지 쌍은 스프링이 당겨 오는데, 이 둘이 서로 상쇄되는 지점에서 각 노드가 멈추면 그게 곧 읽기 좋은 배치입니다 — 사람이 좌표 하나를 정하지 않아도 관계도가 스스로 정돈되는 이유입니다. 이 네트워크 그래프 시각화의 핵심은 좌표를 미리 그려 두지 않는다는 점인데, 화면을 캡처해 한 장으로 고정하면 척력·스프링이 사라져 포스 배치가 아니라 그냥 고정된 다이어그램이 됩니다.
스패닝 트리로 연결을 먼저 보장하는 이유
01·02·07·08·09 다섯 항목은 a=1..N-1 구간에서 edges.push([a, floor(rand()*a)]) 로 스패닝 트리를 먼저 만들어 전체 연결을 보장한 뒤 무작위 엣지를 더 얹습니다. 무작위 엣지만으로 그래프를 만들면 노드가 여러 조각으로 갈라질 확률이 높아 스프링이 걸리지 않는 섬이 생기기 때문입니다. 반대로 03·04·05·06 네 항목은 엣지 배열 자체가 없습니다 — 03 은 척력 배율만으로, 04 는 힘 뒤의 위치 보정 패스로, 05 는 부모-자식 스프링으로, 06 은 힘 계산 없이 극좌표로 좌표를 대신 정합니다. 노드·엣지가 어디서 나오는지는 항목마다 고유한 시드로 만든 결정적 난수(makeRand)가 정해 매번 같은 그래프가 재현됩니다.
이런 화면에서 쓴다 — 조직도와 추천 관계망
이 아홉 판이 가장 먼저 붙는 자리는 09 대문 관계도처럼 서비스 기능을 한눈에 엮어 보여주는 랜딩 히어로입니다. 방문자가 스크롤하지 않아도 기능들이 서로 어떻게 연결되는지 한 화면에서 느끼게 되는데, 02 드래그 재배치를 붙이면 사람이 노드 하나를 직접 끌어 보며 관계망이 스스로 재균형 잡는 모습까지 체험할 수 있습니다.
조직도나 팀 페이지에는 05 계층 트리가 더 어울립니다. 루트에서 레벨별로 방사되는 배치라 상하 관계가 위치만으로 읽히고, 03 군집 색 구분을 곁들이면 같은 부서끼리 뭉쳐 보여 조직 구조를 설명하는 텍스트 없이도 한눈에 파악됩니다.
힘 배치 그래프를 붙이기 전에 자주 나오는 질문
네트워크 그래프 그리기를 이 데모로 처음 시작해도 따라할 수 있나요?
핵심은 모든 노드 쌍의 척력과 엣지의 스프링을 매 프레임 더해 속도에 누적하는 루프 하나인데, 01 판의 서른 줄 남짓이 그 전부입니다. zip 안에는 아홉 판을 같은 조건으로 다시 짜라고 시키는 지시 문서가 들어 있어 코드 도구에 붙여 넣으면 같은 화면부터 시작하고, 그다음은 상수 블록의 REPEL·ATTRACT 값을 하나씩 바꿔 보는 순서가 가장 빠릅니다.
내 데이터로 관계도를 그리려면 어디를 바꾸면 되나요?
01·02·07·08·09 는 edges 배열에 [a, b] 쌍만 채우면 그 관계가 곧바로 스프링으로 이어집니다. 03 처럼 그룹을 색으로 나누고 싶으면 grp[i] 를 데이터의 카테고리 번호로 바꾸고, 07 처럼 관계의 세기를 표현하고 싶으면 edges[e].w 에 0~1 사이 값을 넣으면 선 굵기와 목표 거리가 그 값을 그대로 따라갑니다.
화면이 멈춘 것처럼 보이면 무엇이 깨진 건가요?
정상적으로 수렴해도 완전히 멈추지는 않습니다 — JITTER·SPEED·BREATHE 중 그 항목이 쓰는 무작위 힘이 항상 살아 있고, 09 는 노드마다 위상이 다른 펄스로 글로우가 계속 맥동합니다. 화면이 한 프레임에서 완전히 정지해 보인다면 requestAnimationFrame 호출이 끊겼거나 prefers-reduced-motion 이 켜진 브라우저에서 .is-still 정지 상태를 보고 있는 경우입니다.









