시작하기 전에
차트 한 장을 만들 때도 이 세 가지를 동시에 고려해야합니다: 데이터를 정확하게 보여주기, 마우스·키보드 모두에게 자연스러운 인터랙션 주기, 그리고 시각 정보에 접근하기 어려운 사용자에게도 같은 내용을 전달하기.
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>
차트가 한 번 변할 때마다 상태를 일일이 알리면 스크리리더가 폭주합니다. 한 곳에 “지금 보이는 것”을 자연어로 요약하면 충분합니다. 변경의 결과만 전달하면 됩니다.
필요한 게 있을 때마다 틈틈이 더 만들어보겠습니다.