본문으로 건너뛰기

@mogiyoon/react-stable-timeline

팬·줌 중에도 사건 카드가 같은 행에 유지되는 React 타임라인 라이브러리

사용자 알고리즘 설계 + Claude TypeScript 구현

타임라인 데모 로딩 중…

개요

기간

2026.05 ~ 2026.08

개요

개인 프로젝트

주요 기능

전체 데이터 기준 first-fit 행 배치, Canvas2D 라벨 폭 측정, 앵커 줌, 트랙패드·터치 인터랙션, 드래그 앤 드롭, useTimeline 헤드리스 훅, 가상화 렌더링, Controlled/Uncontrolled viewport

사용 기술

ReactTypeScriptCanvas2Dtsupvitestnpm Package

주요 기능과 개발 과정

줌·팬 때마다 행이 바뀌는 문제를 확인하고 직접 구현함

옵션이 아닌 배치 기준의 문제vis-timeline화면 구간 기준 행재계산줌·팬 시 카드 행 이동vis-timeline 제거행 고정 모드 포함직접 구현
행 이동의 원인이 세부 옵션이 아니라 화면 구간 기준 재계산 구조에 있어, vis-timeline을 제거하고 행 고정 모드를 포함해 직접 구현함.
문제

도감의 연표에 사용할 타임라인 컴포넌트를 처음에는 vis-timeline으로 구성함. 같은 시간대의 사건이 겹치지 않도록 여러 행에 나누는 기능은 정상적으로 동작함. 그러나 화면에 보이는 구간만 기준으로 행을 매번 다시 계산하는 구조여서, 줌·팬을 할 때 사건 카드가 다른 행으로 이동했음.

분석

한번 정한 행은 줌·팬 뒤에도 유지되는 편이 사용하기 쉽다고 판단함.

해결

세부 옵션으로 해결할 수 있는 문제가 아니라 행을 정하는 기준 자체의 문제였음. 이에 vis-timeline을 제거하고, 행을 고정하는 모드를 포함한 타임라인을 직접 구현함.

행을 고정하는 배치 알고리즘을 설계하고 TypeScript로 구현함

시작 시각과 라벨순으로 한 번 정렬사건 하나 꺼냄현재 줌 대신 고정 배율 사용실제로 잰 라벨 폭에 24px 추가기간 끝과 라벨 끝 중 먼 쪽까지 차지사건 시작점까지 끝난 맨 위 행, O(log R)사용 가능한 행 있음사용 가능한 행 없음사건이 차지한 끝 위치 기록사건이 차지한 끝 위치 기록다음 사건 꺼냄모든 사건 배치 완료줌과 팬에서는 화면 좌표만 변환사건 목록정렬된 배열사건의 차지 폭 계산전체 맞춤 배율라벨의 끝 위치사용 가능 행 찾기최소값 세그먼트 트리찾은 행에 배치맨 아래 행 추가행의 끝 위치 저장행 번호 확정화면 좌표 변환
사건마다 기간 막대 끝과 라벨 끝 중 더 먼 쪽까지를 차지 폭으로 잡고, 그 시작점까지 비어 있는 맨 위 행에 넣음. 행 찾기를 최소값 세그먼트 트리로 처리해 사건당 O(log R), 최초 1회 정렬을 포함해 전체 O(n log n)임. 현재 줌이 아닌 전체 맞춤 배율로 계산하므로 행 번호는 줌·팬에서 바뀌지 않음.
분석

행 고정 모드의 핵심은 행 계산과 화면 이동을 분리하는 것임. 모든 사건 데이터를 기준으로 행을 먼저 확정하고, 이후 줌·팬에서는 화면 좌표만 바뀌도록 설계함. 이를 위해 큐 A(사건을 정해진 순서로 처리하는 목록)와 딕셔너리 B(각 행의 끝점을 저장하는 키-값 구조)를 사용한 의사코드를 직접 작성함. 먼저 사건을 시간과 이름의 오름차순으로 정렬해 큐 A를 만들고, 행 번호별 끝점을 저장할 딕셔너리 B를 초기화함. 큐에서 사건 C를 하나씩 꺼내 0번 행의 끝점과 비교함. C의 시작점이 끝점보다 뒤에 있으면 0번 행에 배치하고 끝점을 갱신함. 그렇지 않으면 다른 행을 차례로 확인하고, 빈자리가 없으면 끝점과의 차이 절대값이 가장 작은 행에 배치하도록 구상했음.

해결

실제 구현에서는 이 설계를 수정해 큐 A를 (start, label) 기준의 정렬 배열로, 딕셔너리 B를 rowEndsPx 배열로 구현함. 처음에는 빈자리가 없을 때 끝점과의 차이 절대값이 가장 작은 행에 넣도록 구상했으나, 라벨이 겹치지 않도록 새 행을 추가하는 방식으로 변경함.

결과

행을 하나씩 확인하던 배치를 세그먼트 트리로 바꿔, 시간 복잡도는 정렬과 배치 모두 O(n log n)임. 입력이 같으면 결과도 항상 같음.

글자 수로 추정하지 않고 실제 픽셀 폭을 측정함

실제로 잰 픽셀 폭라벨·구간 중 넓은 쪽 기준사건 라벨Canvas2D measureTextmeasurer 함수행 배치 계산시간 구간 폭겹침 없는 배치
라벨의 실제 픽셀 폭을 Canvas2D measureText로 직접 재서, 라벨과 시간 구간 중 넓은 쪽을 기준으로 다음 카드와 겹치지 않게 배치함.
문제

라벨이 시간 구간보다 길어지면 다음 사건 카드와 겹치므로, 라벨의 실제 폭을 알아야 배치를 정할 수 있었음.

해결

이 문제를 줄이기 위해 Canvas2D measureText(브라우저에서 텍스트 폭을 측정하는 API)를 사용함. 측정값을 행 배치 계산에 쓸 수 있도록 measurer(라벨 폭 측정 함수)도 별도로 추가함.

해결

라벨이 시간 구간보다 길면 라벨의 실제 폭을 기준으로 다음 사건 카드와 겹치지 않게 배치함. 시간 구간이 더 길면 시간 구간의 길이를 기준으로 배치함.

커서 위치를 기준으로 확대하고 여러 팬 입력을 지원함

커서 위치 고정 · 100%~5000%zoomStable truezoomStable false두 손가락 가로 스크롤팬Shift + 세로 휠휠 줌배율 변경뷰포트 갱신fit 행 배치 유지현재 화면 기준 재계산
팬과 커서 기준 줌 입력이 뷰포트 갱신 하나로 모이고, zoomStable 값에 따라 fit 배율의 행 배치를 유지할지 다시 계산할지 갈라짐.

줌 범위는 100%(fit, 전체 데이터가 화면에 맞는 기본 배율)부터 5000%까지로 설정함. zoomFactor 값으로 한 단계의 확대·축소 비율을 1.05~1.5 사이에서 조절할 수 있음.

팬은 두 손가락 가로 스크롤과 Shift + 세로 휠로 조작할 수 있음. zoomStable 옵션을 true로 설정하면 fit 배율에서 정한 행 배치를 이후 모든 줌 레벨에서도 유지해, 확대·축소 중에 행이 바뀌지 않음. 기본값은 false이며, 이때는 줌을 할 때 현재 화면을 기준으로 행을 다시 계산함.

문제 해결

한글 라벨 폭이 실제보다 약 30% 작게 계산되는 문제

글자 수 추정Canvas2D measureText행 배치 입력라벨 폭 계산한글 -30% 오차실제로 잰 픽셀 폭라벨 겹침 제거
글자 수 추정을 Canvas2D로 직접 재는 방식으로 바꿔 라벨 겹침을 제거함.
문제

글자 수로 라벨 폭을 추정했을 때 한글의 실제 픽셀 폭보다 약 30% 작게 계산되어 라벨이 서로 겹쳤음.

분석

여러 폰트와 텍스트 조합에서 추정한 폭과 실제로 측정한 폭의 차이를 비교함.

해결

Canvas2D measureText를 사용해 현재 폰트 설정이 반영된 실제 픽셀 폭을 측정함.

결과

한글·영문 혼용 텍스트에서도 라벨 폭을 정확히 계산해 라벨이 겹치는 문제를 제거함.

기본 UI 고정으로 커스터마이징 여지가 없던 문제

로직 (헤드리스)화면상태와 좌표콜백으로 새 시간 전달useTimeline 훅Timeline 컴포넌트기본 UI드래그 앤 드롭
로직(훅·드래그)과 화면(컴포넌트·기본 UI)을 분리한 헤드리스 구성.
문제

기본 UI가 고정되어 있어 디자인 요구가 다른 프로젝트에서는 수정할 수 없었음.

분석

전체 데이터를 기준으로 행 배치가 미리 확정되므로 렌더링 전에 카드 좌표를 계산할 수 있음. 이에 따라 화면 렌더링과 동작 처리를 분리할 수 있다고 판단함.

해결

useTimeline 훅은 줌·팬·행 배치·드래그 상태를 관리하고, Timeline 컴포넌트는 화면 렌더링만 담당하도록 분리함.

결과

사용처에서 기본 UI, renderItem, 헤드리스 훅의 세 단계 중 필요한 커스터마이징 범위를 선택할 수 있게 됨.

사건 수가 늘어날 때 팬·줌이 느려지던 문제

좌표는 미리 계산되어 있음2만 건 입력 시사건 수만 건모든 카드 DOM 렌더팬·줌 성능 저하화면 밖 카드 렌더생략가상화 적용DOM 노드 약 200개유지
좌표가 미리 계산되어 있어 화면 밖 카드를 렌더링하지 않아도 배치가 유지됨 — 가상화로 2만 건 입력에도 DOM 노드를 약 200개로 유지함.
문제

사건 수가 수만 개로 늘어나면 모든 카드를 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 모드 버그를 빠르게 발견하고 고칠 수 있었음

라이선스