캔버스에 UI 컨트롤을 올려놓으면 줌 아웃했을 때 문제가 생긴다. UI가 너무 작아져서 클릭하기 어렵거나, 반대로 너무 커져서 레이어를 가린다.
그리고 디테일한 작업을 할때 방향키로 1px씩 움직이는 라벨이 가이드라인을 가려서 정확한 비교를 하려할때 불편하다.
그래서 줌 레벨에 따라 UI를 다르게 처리하여 적용했다.
문제 정의
요구사항
레이어를 선택하면 라벨(이름)과 슬라이더(투명도 조절)가 표시된다. 문제는 줌 레벨이 바뀔 때다.
줌 아웃 시 (전체 보기)
- 슬라이더가 3px짜리로 줄어든다. 터치는커녕 마우스로도 클릭이 안 된다.
- 라벨 텍스트가 깨알같이 작아져서 읽을 수 없다.
줌 인 시 (세밀한 작업)
- 슬라이더가 화면의 절반을 차지한다.
- 1px 단위로 레이어를 정렬하려는데, 라벨이 가이드라인을 가린다.
- UI를 치우려고 레이어 선택을 해제하면, 다시 선택해야 해서 작업 흐름이 끊긴다.
기존 방식의 한계
// 단순 조건부 렌더링
label.visible = zoom > 0.5;
이 방식의 문제:
- 뚝 끊기는 전환: 49%에서 51%로 줌할 때 UI가 갑자기 나타난다. 사용자는 줌 레벨 숫자를 보고 있지 않기 때문에, 왜 UI가 갑자기 튀어나왔는지 맥락을 잃는다.
- 일괄 처리의 한계: 라벨과 슬라이더를 같은 임계값에서 처리하면, 정작 필요한 UI까지 사라진다. 줌 30%에서 라벨은 읽기 어렵지만, 슬라이더는 아직 쓸 만하다.
- 크기 문제 미해결: 표시/숨김만으로는 "작아서 못 누르는" 문제를 해결할 수 없다. 줌 20%에서 슬라이더가 보이긴 하는데 클릭이 안 된다.
선택지 분석
| 방식 | 장점 | 한계 | 적합한 상황 |
|---|---|---|---|
| 즉시 표시/숨김 | 구현 간단 | 뚝뚝 끊기는 느낌 | 프로토타입 |
| CSS transition | DOM에서는 간단 | Pixi.js에서 불가능 | DOM 기반 UI |
| Ticker 기반 애니메이션 | 완전한 제어, 60fps 보장 | 직접 구현 필요 | 캔버스 기반 UI |
| GSAP 등 라이브러리 | 풍부한 easing | 추가 의존성 | 복잡한 애니메이션 |
Pixi.js 환경이라 Ticker 기반으로 직접 구현하는 게 가장 자연스럽다. 이미 Pixi.js의 Ticker를 사용하고 있어서 추가 의존성도 없다.
전략
세 가지 전략을 조합했다.
1. 다단계 임계값
- 슬라이더: 10% 이하에서 숨김 (큰 UI부터)
- 라벨: 30% 이하에서 숨김 (작은 UI는 더 오래 유지)
2. Fade 애니메이션
- 150ms ease-out으로 자연스러운 전환
- 뚝 사라지는 느낌 제거
3. 역스케일링 (Reverse Scaling)
- 줌 아웃해도 슬라이더는 일정 크기 이상 유지
- 최대 3.5배까지만 확대 (너무 커지는 것 방지)
구현
상수 정의
const LABEL_HIDE_ZOOM_THRESHOLD = 0.3; // 30% 이하에서 라벨 숨김
const SLIDER_HIDE_ZOOM_THRESHOLD = 0.1; // 10% 이하에서 슬라이더 숨김
const FADE_DURATION = 150; // ms
const SLIDER_MAX_UI_SCALE = 3.5; // 슬라이더 최대 확대 배율
Fade 상태 관리
각 UI 요소마다 애니메이션 상태를 별도로 관리한다.
// 라벨 fade 애니메이션
private labelTargetAlpha: number = 1;
private labelFadeStartTime: number = 0;
private labelFadeStartAlpha: number = 1;
// 슬라이더 fade 애니메이션
private sliderTargetAlpha: number = 1;
private sliderFadeStartTime: number = 0;
private sliderFadeStartAlpha: number = 1;
가시성 설정
줌 레벨이 변경될 때마다 각 요소의 목표 alpha 값을 설정한다.
public updateZoom(zoom: number) {
// 라벨: 30% 이상에서 표시
this.setLabelVisibility(zoom >= LABEL_HIDE_ZOOM_THRESHOLD);
// 슬라이더: 10% 이상에서 표시
this.setSliderVisibility(zoom >= SLIDER_HIDE_ZOOM_THRESHOLD);
}
private setLabelVisibility(visible: boolean) {
const targetAlpha = visible ? 1 : 0;
if (this.labelTargetAlpha === targetAlpha) return;
this.labelTargetAlpha = targetAlpha;
this.labelFadeStartTime = performance.now();
this.labelFadeStartAlpha = this.label.alpha; // 현재 값에서 시작
}
현재 alpha 값에서 시작하는 게 중요하다. 페이드 도중에 방향이 바뀌어도 자연스럽게 이어진다.
Ticker 애니메이션
매 프레임마다 alpha 값을 보간한다.
private onTick() {
// 라벨 fade
if (this.label.alpha !== this.labelTargetAlpha) {
const elapsed = performance.now() - this.labelFadeStartTime;
const progress = Math.min(1, elapsed / FADE_DURATION);
// ease-out: 1 - (1 - t)^2
const eased = 1 - Math.pow(1 - progress, 2);
this.label.alpha = this.labelFadeStartAlpha +
(this.labelTargetAlpha - this.labelFadeStartAlpha) * eased;
if (progress >= 1) {
this.label.alpha = this.labelTargetAlpha;
}
}
// 슬라이더 fade도 동일한 패턴
}
역스케일링
줌 아웃해도 슬라이더가 너무 작아지지 않게 한다.
// 줌 50% → sliderScale = 2 (2배 확대)
// 줌 10% → sliderScale = 3.5 (최대 제한)
// 줌 200% → sliderScale = 0.5 (줌인 시에는 축소)
const sliderScale = Math.min(SLIDER_MAX_UI_SCALE, 1 / zoom);
이 스케일을 슬라이더의 각 요소에 적용한다.
private updateSliderPosition(sliderScale: number) {
const scaledRadius = THUMB_RADIUS * sliderScale;
const scaledStemHeight = THUMB_STEM_HEIGHT * sliderScale;
// ... 이 크기로 thumb 그리기
}
이동 중 숨김
레이어를 드래그하는 동안에는 UI를 잠시 숨긴다.
const MOVING_FADE_IN_DELAY = 300; // 멈춘 후 300ms 후에 나타남
public setMoving(isMoving: boolean) {
if (this.movingFadeDelayTimer) {
clearTimeout(this.movingFadeDelayTimer);
}
if (isMoving) {
this.startMovingFade(0); // 즉시 fade out
} else {
// 300ms 후에 fade in
this.movingFadeDelayTimer = setTimeout(() => {
this.startMovingFade(1);
}, MOVING_FADE_IN_DELAY);
}
}
줌 레벨별 동작 정리
| 줌 레벨 | 라벨 | 슬라이더 |
|---|---|---|
| 3% ~ 10% | fade out | fade out |
| 10% ~ 30% | fade out | 역스케일 적용 (최대 3.5x) |
| 30% ~ 100% | 표시 | 역스케일 적용 |
| 100% ~ 500% | 표시 | 원래 크기 ~ 축소 |
줌 레벨에 따른 UI 처리는 단순히 보이고 안 보이고의 문제가 아니다.
- 임계값은 용도에 맞게: 자주 쓰는 UI는 더 오래 보이게
- 역스케일링으로 최소 크기 보장: 터치/클릭 가능한 크기 유지
- Fade로 자연스러운 전환: 뚝 끊기는 느낌 제거
이 패턴은 지도 앱의 마커, 다이어그램 에디터의 노드 라벨, 게임의 미니맵 등 줌이 가능한 캔버스라면 어디든 적용할 수 있다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd' 카테고리의 다른 글
| S3 Presigned URL로 클라이언트 직접 업로드 (0) | 2026.01.22 |
|---|---|
| 풀페이지 스크린샷 타일 스티칭 구현 (0) | 2026.01.20 |
| pixelmatch로 픽셀 비교 알고리즘 구현하기 (0) | 2026.01.15 |
| JavaScript 클로저(Closure) 이해하기 (0) | 2023.05.31 |
