Search

Search through articles and snippets by title, tags, or content.

RSS
Back to Articles
d3-force 캔버스 그래프를 모바일에서 제대로 동작하게 만들기

d3-force 캔버스 그래프를 모바일에서 제대로 동작하게 만들기

마우스만 생각하고 만든 Canvas + d3-force 그래프를 모바일에서 쓸 수 있게 고친 기록이에요. Pointer Events로 터치·핀치 처리, 드래그와 탭 구분, React onWheel의 passive 문제, 화면 맞춤과 라벨 겹침 해결을 코드와 함께 정리해요.

D
DevBlog
2026년 10월 29일
4 min read
On This Page8

이 블로그의 그래프 페이지(/graph)는 Canvas에 d3-force로 글과 태그의 연결을 그려요. 처음 만들 때는 데스크톱 마우스만 생각해서, 모바일에서는 화면이 잘리고 손가락으로는 아무것도 할 수 없었어요. 이번 글에서는 이 캔버스 그래프를 모바일에서 쓸 수 있게 고치면서 부딪힌 문제와 해결 방법을 코드와 함께 정리할게요.

작업 전 상태는 이랬어요.

  • 마우스 이벤트(onMouseDown 등)만 있어서 터치로 이동·확대·드래그가 안 됨
  • 정보는 마우스를 올려야만 보이고, 모바일에서는 탭하는 순간 바로 글로 이동
  • 처음 배율이 1로 고정돼 390px 화면에서 그래프가 오른쪽으로 치우쳐 절반이 잘림
  • 노드를 드래그하기만 해도 글로 이동하는 버그
  • 휠로 확대하면 페이지까지 같이 스크롤됨

1. Pointer Events로 마우스와 터치를 한 번에

마우스와 터치를 따로 처리하는 대신 Pointer Events를 쓰면 한 코드로 둘 다 다룰 수 있어요. 브라우저가 캔버스 위의 터치를 스크롤·확대로 가로채지 않도록 touch-action: none을 함께 줘야 해요(Tailwind의 touch-none).

<canvas
  className="block touch-none"
  onPointerDown={handlePointerDown}
  onPointerMove={handlePointerMove}
  onPointerUp={handlePointerUp}
  onPointerCancel={handlePointerUp}
/>
tsx

두 손가락 확대는 지금 눌려 있는 포인터들을 Map에 모아 두고, 두 점 사이 거리의 비율로 배율을 바꿔요. 두 손가락의 가운데 점이 움직인 만큼 화면도 함께 옮기면 확대와 이동이 자연스럽게 섞여요.

const [a, b] = [...pointersRef.current.values()];
const distance = Math.hypot(a.x - b.x, a.y - b.y);
const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2;
 
const zoomed = zoomAt(transform, midX, midY, distance / pinch.distance);
transform = {
  ...zoomed,
  x: zoomed.x + midX - pinch.midX,
  y: zoomed.y + midY - pinch.midY,
};
pinch = { distance, midX, midY };
ts

zoomAt은 손가락(또는 마우스 커서) 아래의 지점이 확대 전후로 같은 자리에 있도록 이동량을 계산하는 순수 함수예요. 순수 함수로 빼 두면 테스트하기도 쉬워요.

export function zoomAt(t: ViewTransform, sx: number, sy: number, factor: number) {
  const k = clampZoom(t.k * factor);
  return { x: sx - (sx - t.x) * (k / t.k), y: sy - (sy - t.y) * (k / t.k), k };
}
ts

2. 드래그와 탭을 구분하기

"노드를 끌기만 해도 글로 이동"하는 버그는 누르고 뗀 동작이 click으로도 처리됐기 때문이었어요. 그래서 누른 위치에서 일정 거리 이상 움직였는지를 기준으로 드래그와 탭을 나눴어요.

const DRAG_THRESHOLD = 6;
export function isDrag(dx: number, dy: number) {
  return dx * dx + dy * dy >= DRAG_THRESHOLD * DRAG_THRESHOLD;
}
ts
  • 6px 미만으로 움직이고 뗐으면 탭: 노드면 선택(정보 카드 표시), 빈 곳이면 선택 해제
  • 6px 이상이면 드래그: 노드 위에서 시작했으면 노드를 옮기고, 빈 곳이면 화면을 이동
  • 손가락은 마우스보다 크기 때문에 노드를 찾을 때 터치는 12px, 마우스는 4px의 여유를 더 줬어요

그리고 탭해도 바로 글로 이동하지 않게 했어요. 모바일에서는 탭하는 순간 이동하면 어떤 글인지 확인할 수가 없거든요. 탭하면 아래에 정보 카드(제목, 카테고리, 연결된 태그)가 뜨고, 카드의 "Read article" 버튼으로 이동해요. 데스크톱도 같은 방식으로 맞췄어요.

3. React onWheel에서 preventDefault가 안 먹는 이유

휠로 확대할 때 페이지까지 스크롤되던 문제는 React의 이벤트 처리 방식 때문이었어요. React는 wheel 리스너를 passive로 등록해서, onWheel 안에서 e.preventDefault()를 호출해도 무시돼요. 그래서 캔버스에 직접 { passive: false }로 등록했어요.

useEffect(() => {
  const canvas = canvasRef.current;
  if (!canvas) return;
  const onWheel = (e: WheelEvent) => {
    e.preventDefault();
    // zoomAt으로 배율 변경
  };
  canvas.addEventListener("wheel", onWheel, { passive: false });
  return () => canvas.removeEventListener("wheel", onWheel);
}, []);
ts

4. 첫 화면을 화면 크기에 맞추기

예전에는 시뮬레이션을 시작하자마자 그리면서 노드가 퍼져 나가는 애니메이션을 보여 줬어요. 이 방식은 배치가 끝나기 전이라 화면 맞춤을 할 수가 없어요. 그래서 시뮬레이션을 미리 300틱 돌려 정지 상태를 만든 뒤, 모든 노드가 들어가는 배율과 위치를 계산해 그렸어요.

const sim = forceSimulation(nodes) /* ...forces */ .stop();
sim.tick(300);
fit(); // 모든 노드가 화면에 들어오도록 배율·이동 계산
ts
  • 노드가 적을 때 점이 지나치게 커지지 않도록, 화면 맞춤으로는 최대 2배까지만 확대해요
  • 사용자가 이동·확대한 뒤에는 화면 크기가 바뀌어도 다시 맞추지 않아요. 모바일에서는 주소창이 나타나고 사라질 때마다 크기가 바뀌어서, 이걸 안 하면 보던 위치가 계속 튀어요

5. 세로로 긴 화면에 맞는 배치

화면 맞춤까지 하고 나니 새 문제가 보였어요. 세로로 긴 모바일 캔버스에서 그래프가 작은 원으로 뭉쳐 위아래가 텅 비었어요. d3-force의 배치는 기본적으로 둥글게 퍼지기 때문이에요. 그래서 캔버스 비율에 따라 가로·세로로 당기는 힘을 다르게 줬어요.

const stretch = Math.sqrt(rect.height / rect.width);
sim
  .force("x", forceX(0).strength(0.06 * stretch))
  .force("y", forceY(0).strength(0.06 / stretch));
ts

세로로 길면 가로로 더 세게 당기니까 그래프가 세로로 길쭉하게 퍼지고, 화면을 훨씬 넓게 써요.

6. 라벨이 읽히게 그리기

태그 라벨을 항상 보이게 하자 가운데 밀집 구역에서 라벨이 서로 겹쳐 읽을 수 없었어요. 세 가지 규칙으로 정리했어요.

  • 중요한 것부터 그리고, 겹치면 건너뛰기: 선택한 노드, 글이 많은 태그 순으로 그리면서 이미 그린 라벨 상자와 겹치면 그리지 않아요
  • 화면 밖 노드는 건너뛰기: 처음에는 잘리지 않게 라벨을 화면 안쪽으로 당겼는데, 그러자 화면 밖 노드의 라벨까지 가장자리에 붙는 문제가 생겼어요
  • 글자 크기는 화면 기준: 캔버스가 확대돼도 글자는 같은 크기로 보이도록 fontSize / k로 그려요

7. 어떻게 테스트했나, 그리고 한계

화면 맞춤, 확대 기준점, 드래그 판정은 순수 함수로 빼서 단위 테스트를 먼저 썼어요. 터치 동작은 Chrome DevTools의 모바일 에뮬레이션에서 합성 PointerEvent를 보내 확인했어요.

여기서 한계가 하나 있었어요. 두 번째 손가락에 해당하는 합성 포인터는 실제로 활성화된 포인터가 아니라서 setPointerCapture가 예외를 던져요. 테스트 스크립트에서 이 함수를 비활성화해야 핀치를 시험할 수 있었어요. 실제 기기에서는 생기지 않는 문제지만, 에뮬레이션만으로는 확신할 수 없어서 마지막에는 휴대폰으로 직접 확인했어요.

마치며

항목이전이후
모바일 조작불가한 손가락 이동, 두 손가락 확대, 노드 드래그
탭바로 글로 이동정보 카드 표시
첫 화면치우쳐 잘림화면에 맞춤
휠 확대페이지도 스크롤캔버스만 확대

Canvas는 노드가 많아도 빠르지만 DOM 요소가 없어서 입력 처리, 클릭 판정, 접근성을 모두 직접 만들어야 해요. 이번 문제들은 대부분 그 "직접 만들어야 하는 부분"이 빠져 있던 데서 나왔어요. 비슷한 캔버스 시각화를 만든다면 처음부터 Pointer Events와 드래그 판정, 화면 맞춤을 기본으로 넣어 두기를 추천해요.

Comments