본문으로 건너뛰기

인터랙션과 접근성을 모두 고려한 차트 만들기

시작하기 전에

차트 한 장을 만들 때도 이 세 가지를 동시에 고려해야합니다: 데이터를 정확하게 보여주기, 마우스·키보드 모두에게 자연스러운 인터랙션 주기, 그리고 시각 정보에 접근하기 어려운 사용자에게도 같은 내용을 전달하기.

7종류, 44개 데모를 만들어봤습니다.

사용한 도구

  • React — 컴포넌트 단위 상태 관리
  • D3 — 차트
  • Three.js — 3D scatter
  • Canvas 2D — 5만 점 그리기

인터랙션 원칙

마우스 호버는 가벼운 탐색입니다. 차트 위를 지나가면서 어디에 어떤 값이 있는지 미리 본다는 의미죠. 그래서 호버에는 컨테이너 outline 같은 큰 시각 표시를 주지 않고, 활성 데이터 포인트만 강조합니다.

키보드 포커스는 의도적인 진입입니다. Tab을 눌러서 “이 차트에서 작업할 거다”라고 선언한 상태죠. 그래서 컨테이너에 점선 outline + 활성 항목 강조를 모두 줍니다. 키보드 사용자는 시각적으로 자기가 어느 차트에 있는지 정확히 알 필요가 있으니까요.

같은 focusIdx 상태를 마우스·키보드·터치가 모두 갱신할 수 있도록 설계해서, 셋 사이에 충돌이 없습니다.

접근성 원칙

  • 색이 정보를 단독으로 전달하지 않게 → 패턴·모양·텍스트 라벨 함께 (WCAG 1.4.1)
  • 드래그가 핵심인 인터랙션은 슬라이더/버튼 대체 (WCAG 2.5.7)
  • 모든 시각 정보의 텍스트 대안 → 표 fallback
  • aria-live는 차트당 하나, 자연어로 요약 — 변경을 일일이 알리면 스크린리더 난리남
  • prefers-reduced-motion 감지 시 자동 회전·애니메이션 비활성

각 데모마다 어떤 인터랙션이 어떤 접근성 고려를 가지는지 직접 확인할 수 있도록, 카드 하단에 펼침 패널로 명시했습니다.


기본 차트

17개의 기본 차트입니다. 라인·바·도넛·스캐터·에어리어 같은 익숙한 차트에 호버 툴팁, 클릭 선택, 영역 brush, 시리즈 토글, 키보드 탐색, 검색 강조, before/after 비교 등 다양한 인터랙션을 붙였습니다.


네트워크 · 계층 · 흐름

단일 축으로 그려지지 않는 데이터를 다루는 여섯 가지 시각화입니다.


지리 · 3D · WebGL

공간을 다루는 다섯 가지 차트로, Choropleth tile grid, Flow map, 3D scatter, 대량 포인트 Canvas, Globe를 그렸습니다.


대시보드 · 연동

여러 차트가 서로 대화하는 네 가지 패턴입니다.


통계 차트

평균과 막대만으로는 드러나지 않는 것들을 또 다른 시각화 장치로 보여주는 네 가지 통계 차트입니다.


시간 시리즈 분석

단일 시계열을 네 가지 각도로 봅니다.


어노테이션 · 스토리텔링

숫자뿐만 아니라 스토리로써 전해지는 데이터가 있는 시각화입니다.


몇 가지 신경 쓴 요소들

HTML overlay (SVG 시각 / HTML 인터랙션 분리)

<div style={{ position: 'relative' }}>
  {/* 시각 레이어 */}
  <svg preserveAspectRatio="none"
       style={{ pointerEvents: 'none' }}
       aria-hidden="true">
    {/* 막대, 선, 면 */}
  </svg>

  {/* 인터랙션 레이어 */}
  <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
    {items.map((it, i) => (
      <button
        ref={(el) => { refs.current[i] = el; }}
        style={{
          position: 'absolute',
          left: `${(x / W) * 100}%`,
          top: `${(y / H) * 100}%`,
          width: `${(w / W) * 100}%`,
          height: `${(h / H) * 100}%`,
          pointerEvents: 'auto',
        }}
        /* onClick, onMouseEnter, onFocus, onKeyDown */
      />
    ))}
  </div>
</div>

SVG focus는 브라우저별로 들쑥날쑥합니다. HTML button은 어디서나 일관되게 동작하니까, SVG는 그림만 그리고 button이 모든 이벤트를 받습니다. SVG의 viewBox 좌표를 button의 % 위치로 변환하면 정확히 같은 자리에 겹칩니다.

여기에 preserveAspectRatio="none" 으로 viewBox가 SVG box를 그대로 fill하게 해두면 button overlay와의 정합도 정확해집니다. 단, SVG 안에 <text> 가 있으면 텍스트도 가로로 늘어나기 때문에 텍스트는 HTML로 따로 분리합니다.

preserveAspectRatio + vectorEffect

<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
  <rect x={x} y={y} width={w} height={h}
        stroke={C.ink} strokeWidth={1.5}
        vectorEffect="non-scaling-stroke" />
</svg>

preserveAspectRatio="none" 으로 viewBox가 SVG box에 정확히 맞고, vectorEffect="non-scaling-stroke" 로 선 두께는 그대로 유지됩니다. SVG가 가로로 늘어나도 막대 stroke가 두꺼워지지 않습니다.

Roving tabindex

const [focusIdx, setFocusIdx] = useState(0);
const refs = useRef([]);

const moveFocus = (next) => {
  refs.current[next]?.focus();
  setFocusIdx(next);
};

// 각 button:
tabIndex={focusIdx === i ? 0 : -1}
onClick={() => { refs.current[i]?.focus(); setFocusIdx(i); /* + action */ }}
onMouseEnter={() => setFocusIdx(i)}
onFocus={() => setFocusIdx(i)}
onKeyDown={(e) => {
  if (e.key === 'ArrowRight') moveFocus(Math.min(items.length - 1, i + 1));
  /* ... */
}}

차트당 한 번만 Tab으로 진입하고, 안에서는 ← → 로 탐색합니다. 활성 항목만 tabIndex={0}, 나머지는 -1이라 Tab을 다시 누르면 다음 차트로 자연스럽게 넘어갑니다. 클릭 시 focus() 를 직접 호출하는 게 중요합니다. 브라우저에 따라 button을 클릭해도 자동으로 focus가 안 가는 경우가 있어요.

useChartFocus 훅

const useChartFocus = () => {
  const [hasFocus, setHasFocus] = useState(false);   // 키보드 포커스
  const [hasHover, setHasHover] = useState(false);   // 마우스 호버
  const isActive = hasFocus || hasHover;
  return {
    hasFocus: isActive,        // 내부 데이터 강조용
    isKeyboardFocus: hasFocus, // 컨테이너 outline 전용
    handlers: {
      onFocusCapture: () => setHasFocus(true),
      onBlurCapture: (e) => {
        if (!e.currentTarget.contains(e.relatedTarget)) setHasFocus(false);
      },
      onMouseEnter: () => setHasHover(true),
      onMouseLeave: () => setHasHover(false),
    },
    style: {
      // 컨테이너 outline은 키보드 포커스 시에만
      outline: hasFocus ? `1.5px dashed ${C.focus}` : '1.5px dashed transparent',
    },
  };
};

onBlurCapture에서 e.currentTarget.contains(e.relatedTarget) 으로 차트 안의 자식 사이 이동을 거르는 게 핵심입니다. 그게 없으면 ← → 누를 때마다 outline이 깜빡거립니다.

호버는 컨테이너 outline까지 켜지 않고 내부 강조만 — 마우스로 차트 위를 지나가는 건 탐색 이지 활성화 가 아니니까요.

키보드 + 마우스 + 터치 모두 작동

// 좌표 — 마우스/터치 통합
const pointerX = (e) => e.touches?.[0]?.clientX ?? e.clientX;

<rect
  onMouseDown={onDragStart}
  onTouchStart={onDragStart}
  style={{ touchAction: 'none', cursor: 'ew-resize' }}
/>

useEffect(() => {
  if (!dragging) return;
  const move = (e) => { e.preventDefault(); /* pointerX(e) 사용 */ };
  window.addEventListener('mousemove', move);
  window.addEventListener('touchmove', move, { passive: false });
  return () => { /* removeEventListener */ };
}, [dragging]);

키보드는 roving tabindex로 잡혀 있지만, 같은 차트가 마우스와 터치로도 동작해야 합니다. 두 경로를 따로 짜면 분기가 갈리니까 좌표만 추상화하면 거의 같은 로직이 됩니다. e.clientX 만 쓰면 터치에서 undefined 가 떨어지는 게 함정인데, e.touches?.[0]?.clientX ?? e.clientX 한 줄로 둘 다 처리합니다.

터치는 그대로 두면 페이지 스크롤로 흘러갑니다. 핸들 element에 touchAction: 'none' 을 주고, window-level touchmove{ passive: false } 로 등록해야 preventDefault() 가 먹습니다. 둘 중 하나라도 빠지면 차트 위 가로 드래그가 스크롤로 hijack됩니다.

데이터 표 fallback

const [tableOpen, setTableOpen] = useState(false);

<button
  onClick={() => setTableOpen((v) => !v)}
  aria-expanded={tableOpen}
  aria-controls="d20-table"
  className="ml-auto">
  {tableOpen ? '데이터 표 닫기' : '평탄화된 계층 데이터'}
</button>

{tableOpen && (
  <div id="d20-table">
    <table>
      <caption style={srOnly}>계층 표</caption>
      <thead><tr>{headers.map((h) => <th key={h} scope="col">{h}</th>)}</tr></thead>
      <tbody>{/* ... */}</tbody>
    </table>
  </div>
)}

모든 시각 정보가 표로도 노출됩니다. 색 강도로 값을 표현한 데이터는 표로도 정확히 읽힐 수 있어야 합니다. 5만 점처럼 모든 행을 표시할 수 없는 경우엔 상위 N개 + 요약 통계로 대체합니다.

차트마다 tableOpen 상태를 따로 두고, 토글 버튼을 만들어서, 표를 그 아래에 펼칩니다. aria-expanded / aria-controls 로 버튼과 패널을 연결해두면 스크린리더에서도 펼침 상태가 자연스럽게 읽힙니다.

aria-live 는 차트당 한 곳

<div aria-live="polite" className="font-mono text-[10px] mt-2">
  <strong>{cur.name}</strong> — N={cur.n}, 평균 {cur.mean.toFixed(2)},
  중앙값 {cur.median.toFixed(2)}, IQR {cur.q1.toFixed(2)}–{cur.q3.toFixed(2)}
</div>

차트가 한 번 변할 때마다 상태를 일일이 알리면 스크리리더가 폭주합니다. 한 곳에 “지금 보이는 것”을 자연어로 요약하면 충분합니다. 변경의 결과만 전달하면 됩니다.


필요한 게 있을 때마다 틈틈이 더 만들어보겠습니다.