@mogiyoon/react-stable-timeline
팬·줌 중에도 사건 카드가 같은 행에 유지되는 React 타임라인 라이브러리
타임라인 데모 로딩 중…
개요
기간
2026.05 ~ 2026.08
개요
개인 프로젝트
주요 기능
전체 데이터 기준 first-fit 행 배치, Canvas2D 라벨 폭 측정, 앵커 줌, 트랙패드·터치 인터랙션, 드래그 앤 드롭, useTimeline 헤드리스 훅, 가상화 렌더링, Controlled/Uncontrolled viewport
사용 기술
주요 기능과 개발 과정
줌·팬 때마다 행이 바뀌는 문제를 확인하고 직접 구현함
도감의 연표에 사용할 타임라인 컴포넌트를 처음에는 vis-timeline으로 구성함. 같은 시간대의 사건이 겹치지 않도록 여러 행에 나누는 기능은 정상적으로 동작함. 그러나 화면에 보이는 구간만 기준으로 행을 매번 다시 계산하는 구조여서, 줌·팬을 할 때 사건 카드가 다른 행으로 이동했음.
한번 정한 행은 줌·팬 뒤에도 유지되는 편이 사용하기 쉽다고 판단함.
세부 옵션으로 해결할 수 있는 문제가 아니라 행을 정하는 기준 자체의 문제였음. 이에 vis-timeline을 제거하고, 행을 고정하는 모드를 포함한 타임라인을 직접 구현함.
행을 고정하는 배치 알고리즘을 설계하고 TypeScript로 구현함
행 고정 모드의 핵심은 행 계산과 화면 이동을 분리하는 것임. 모든 사건 데이터를 기준으로 행을 먼저 확정하고, 이후 줌·팬에서는 화면 좌표만 바뀌도록 설계함. 이를 위해 큐 A(사건을 정해진 순서로 처리하는 목록)와 딕셔너리 B(각 행의 끝점을 저장하는 키-값 구조)를 사용한 의사코드를 직접 작성함. 먼저 사건을 시간과 이름의 오름차순으로 정렬해 큐 A를 만들고, 행 번호별 끝점을 저장할 딕셔너리 B를 초기화함. 큐에서 사건 C를 하나씩 꺼내 0번 행의 끝점과 비교함. C의 시작점이 끝점보다 뒤에 있으면 0번 행에 배치하고 끝점을 갱신함. 그렇지 않으면 다른 행을 차례로 확인하고, 빈자리가 없으면 끝점과의 차이 절대값이 가장 작은 행에 배치하도록 구상했음.
실제 구현에서는 이 설계를 수정해 큐 A를 (start, label) 기준의 정렬 배열로, 딕셔너리 B를 rowEndsPx 배열로 구현함. 처음에는 빈자리가 없을 때 끝점과의 차이 절대값이 가장 작은 행에 넣도록 구상했으나, 라벨이 겹치지 않도록 새 행을 추가하는 방식으로 변경함.
행을 하나씩 확인하던 배치를 세그먼트 트리로 바꿔, 시간 복잡도는 정렬과 배치 모두 O(n log n)임. 입력이 같으면 결과도 항상 같음.
글자 수로 추정하지 않고 실제 픽셀 폭을 측정함
라벨이 시간 구간보다 길어지면 다음 사건 카드와 겹치므로, 라벨의 실제 폭을 알아야 배치를 정할 수 있었음.
이 문제를 줄이기 위해 Canvas2D measureText(브라우저에서 텍스트 폭을 측정하는 API)를 사용함. 측정값을 행 배치 계산에 쓸 수 있도록 measurer(라벨 폭 측정 함수)도 별도로 추가함.
라벨이 시간 구간보다 길면 라벨의 실제 폭을 기준으로 다음 사건 카드와 겹치지 않게 배치함. 시간 구간이 더 길면 시간 구간의 길이를 기준으로 배치함.
커서 위치를 기준으로 확대하고 여러 팬 입력을 지원함
줌 범위는 100%(fit, 전체 데이터가 화면에 맞는 기본 배율)부터 5000%까지로 설정함. zoomFactor 값으로 한 단계의 확대·축소 비율을 1.05~1.5 사이에서 조절할 수 있음.
팬은 두 손가락 가로 스크롤과 Shift + 세로 휠로 조작할 수 있음. zoomStable 옵션을 true로 설정하면 fit 배율에서 정한 행 배치를 이후 모든 줌 레벨에서도 유지해, 확대·축소 중에 행이 바뀌지 않음. 기본값은 false이며, 이때는 줌을 할 때 현재 화면을 기준으로 행을 다시 계산함.
문제 해결
한글 라벨 폭이 실제보다 약 30% 작게 계산되는 문제
글자 수로 라벨 폭을 추정했을 때 한글의 실제 픽셀 폭보다 약 30% 작게 계산되어 라벨이 서로 겹쳤음.
여러 폰트와 텍스트 조합에서 추정한 폭과 실제로 측정한 폭의 차이를 비교함.
Canvas2D measureText를 사용해 현재 폰트 설정이 반영된 실제 픽셀 폭을 측정함.
한글·영문 혼용 텍스트에서도 라벨 폭을 정확히 계산해 라벨이 겹치는 문제를 제거함.
기본 UI 고정으로 커스터마이징 여지가 없던 문제
기본 UI가 고정되어 있어 디자인 요구가 다른 프로젝트에서는 수정할 수 없었음.
전체 데이터를 기준으로 행 배치가 미리 확정되므로 렌더링 전에 카드 좌표를 계산할 수 있음. 이에 따라 화면 렌더링과 동작 처리를 분리할 수 있다고 판단함.
useTimeline 훅은 줌·팬·행 배치·드래그 상태를 관리하고, Timeline 컴포넌트는 화면 렌더링만 담당하도록 분리함.
사용처에서 기본 UI, renderItem, 헤드리스 훅의 세 단계 중 필요한 커스터마이징 범위를 선택할 수 있게 됨.
사건 수가 늘어날 때 팬·줌이 느려지던 문제
사건 수가 수만 개로 늘어나면 모든 카드를 DOM에 렌더링해야 함. 이에 따라 팬과 줌 성능이 눈에 띄게 저하됨.
좌표가 미리 계산되므로 화면 밖 카드를 렌더링하지 않아도 배치가 유지됨.
화면에 표시되는 카드만 DOM에 렌더링하는 가상화를 추가함.
사건 2만 개를 입력해도 실제 DOM 노드는 약 200개로 유지됨.
결과와 회고
결과
직접 설계한 자료구조와 알고리즘을 AI와 함께 다듬어, 빈자리가 있는 첫 번째 행에 배치하는 first-fit 행 배치 라이브러리로 구현하고 NPM에 공개함
Canvas2D로 라벨 폭을 측정해 한글·CJK·혼용 텍스트의 라벨 충돌을 제거함. first-fit 배치 함수를 외부로 export해 워커에서 별도로 실행할 수 있도록 함
0.5.0까지 useTimeline 헤드리스 훅과 가상화를 추가해, 사건 2만 개 기준 실제 DOM 노드를 약 200개로 유지함
배치 알고리즘과 줌·팬 동작을 테스트 87개로 검증했고, 그중 10개가 행 배치 전용임
0.4.0에서 날짜를 담은 aria-label, 키보드 이동·크기 조절, 포커스 윤곽선, 터치 입력을 추가함
회고
vis-timeline의 옵션만으로는 프로젝트에 필요한 행 고정 방식을 구현할 수 없음을 확인하고 직접 구현으로 전환함. 사용처를 라이브러리에 맞추지 않고, 사용처에 맞는 도구를 직접 만든 경험이었음
큐와 딕셔너리로 작성한 의사코드가 first-fit interval partitioning(겹치지 않는 첫 번째 행에 시간 구간을 배치하는 방식)에 해당함을 확인함. 알고리즘 설계는 사용자가 맡고 실제 구현은 AI와 함께 진행한 작업이었음
Storytect에 라이브러리를 적용하고 다시 수정하는 검증 과정을 반복하면서, 실제 사용처에서 controlled 모드 버그를 빠르게 발견하고 고칠 수 있었음
라이선스
