GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

네트워크 그래프 9종 — 힘으로 밀고 당겨 스스로 배치되는 캔버스

네트워크 그래프는 점(노드)과 그 사이를 잇는 선(엣지)으로 관계를 나타낸 그림인데, 이 글은 그 점들의 자리를 사람이 정하지 않고 힘 계산만으로 매 프레임 다시 잡는 캔버스 엔진 아홉 판을 줍니다. 점과 선 몇 개에 척력과 인력만 주면 얽힌 관계가 저절로 정돈된 그림으로 풀립니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

데모

아홉 판 전부 파일 하나씩으로 열리자마자 계산이 시작되고, 화면의 모든 변화는 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기본 힘 배치 — 척력과 스프링만으로 흩어진 점이 정돈된다

모든 노드 쌍끼리 밀어내는 척력과 엣지로 이어진 쌍끼리 당기는 스프링을 매 프레임 더하는데, 힘 기반 그래프 배치의 최소 구현입니다. 그래프는 스패닝 트리로 전체 연결을 먼저 보장한 뒤 무작위 엣지를 더 얹어 순환을 만듭니다.

기본 힘 배치 — 척력과 스프링만으로 흩어진 점이 정돈된다
새 창에서 열기

01 · 기본 힘 배치 — 척력과 스프링만으로 흩어진 점이 정돈된다 — 측정 누적면적 46.931% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

arclineTorequestAnimationFrameMath.hypot

모든 쌍의 척력과 엣지의 스프링을 더하는 핵심 루프

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초)이 반복 호출합니다.

드래그 재배치 — 노드 하나를 끌면 전체가 다시 균형 잡는다
새 창에서 열기

02 · 드래그 재배치 — 노드 하나를 끌면 전체가 다시 균형 잡는다 — 측정 누적면적 39.459% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointermovearclineTorequestAnimationFrame

잡은 노드는 힘 적분과 회전 둘 다에서 빠진다

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 를 더하면 엣지·스프링이 하나도 없이 척력 비대칭만으로 색깔별 덩어리가 화면 위에서 스스로 갈라지는데, 데이터 안의 군집을 힘만으로 드러내는 항목입니다.

군집 색 구분 — 그룹끼리 더 세게 당겨 뭉친다
새 창에서 열기

03 · 군집 색 구분 — 그룹끼리 더 세게 당겨 뭉친다 — 측정 누적면적 55.851% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

arcfillRectrequestAnimationFrameMath.hypot

그룹별 척력 배율과 무게중심 재계산

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충돌 방지 — 노드 반지름만큼 서로 밀어내 겹침이 없다

노드마다 반지름을 다르게 주고 척력과 무작위 유영으로 자유롭게 떠다니게 한 뒤, 힘 적분이 끝난 다음 중심 거리가 반지름 합보다 가까운 쌍을 찾아 그 자리에서 절반씩 즉시 밀어내는 위치 보정 패스를 한 겹 더 끼웁니다. 힘이 아니라 위치 자체를 고치는 하드 충돌 해소라 실제 서비스에 바로 쓸 수 있게 라벨이 겹치지 않습니다.

충돌 방지 — 노드 반지름만큼 서로 밀어내 겹침이 없다
새 창에서 열기

04 · 충돌 방지 — 노드 반지름만큼 서로 밀어내 겹침이 없다 — 측정 누적면적 31.53% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

arcMath.hypotrequestAnimationFramebeginPath

힘 적분 뒤에 따로 도는 위치 보정 패스

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 처럼 무작위 엣지를 두지 않고 부모-자식 관계 하나로만 스프링을 겁니다.

계층 트리 — 루트에서 방사하는 레벨별 배치
새 창에서 열기

05 · 계층 트리 — 루트에서 방사하는 레벨별 배치 — 측정 누적면적 32.872% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

lineToarcrequestAnimationFrameMath.atan2

목표 반지름과의 차를 미는 방사 스프링, 부모를 당기는 스프링

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 회전을 함께 버린 항목입니다.

방사형 궤도 — 동심원 레벨을 따라 회전하며 자리 잡는다
새 창에서 열기

06 · 방사형 궤도 — 동심원 레벨을 따라 회전하며 자리 잡는다 — 측정 누적면적 35.355% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

arcMath.sinMath.cosrequestAnimationFrame

링별 각속도로 각도를 돌리고 반지름·각도로 좌표를 직접 정한다

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 과 같은 스패닝 트리+무작위 엣지 그래프 위에 가중치 항 하나만 얹어 강한 관계는 짧고 굵게, 약한 관계는 길고 얇게 그려집니다.

관계 강도 — 가중치가 링크 길이와 굵기를 바꾼다
새 창에서 열기

07 · 관계 강도 — 가중치가 링크 길이와 굵기를 바꾼다 — 측정 누적면적 47.947% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

lineTostrokeStyleglobalAlpharequestAnimationFrame

가중치가 목표 거리를 정하고, 같은 가중치가 선 굵기·투명도를 정한다

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 로 스스로 서서히 훑다가 실제 휠·드래그가 들어오면 그 값을 그대로 이어받는데, 물리와 카메라가 완전히 분리된 구성입니다.

대규모 팬줌 — 노드 수백 개를 캔버스 좌표로 훑는다
새 창에서 열기

08 · 대규모 팬줌 — 노드 수백 개를 캔버스 좌표로 훑는다 — 측정 누적면적 70.841% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramearclineToMath.hypot

휠 아래 좌표를 월드 좌표로 바꿔 그 자리를 축으로 확대한다

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 회전이 합쳐져 화면 전체가 첫 방문에 살아 움직이는 인상을 줍니다.

대문 관계도 — 서비스 기능들을 힘으로 엮어 보여주는 히어로
새 창에서 열기

09 · 대문 관계도 — 서비스 기능들을 힘으로 엮어 보여주는 히어로 — 측정 누적면적 36.535% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

arclineTorequestAnimationFramecreateRadialGradient

노드마다 다른 위상의 펄스가 글로우 반경과 밝기를 흔든다

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 정지 상태를 보고 있는 경우입니다.

더 읽을거리

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.