프레임당 고정 비율(0.35)로 좁히면 주사율에 따라 속도가 달라짐 — 120Hz 화면에서는 두 배 빨라 툭 끊겨 보이고, 프레임을 한 번 놓치면 그만큼 덜컥 뜀. 흐른 시간으로 환산해 어느 화면에서나 같은 시간에 도착하게 함. 탭이 잠들었다 깬 큰 간격은 64ms로 자름. 자체검증(공용 브라우저 임시 탭, 휠 20칸): 프레임 중앙값 16.6ms(60fps) 유지, 최대 프레임 47.6 → 34.3ms, 20ms 초과 프레임 3 → 2건. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
87 lines
4.1 KiB
TypeScript
87 lines
4.1 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_Profile_Wheel.ts
|
|
* 세로 휠 → 가로 이동. 종단 그래프·유토곡선·측점 테이블 세 스크롤러가 같은 문법을 쓴다
|
|
* (2026-08-04 사용자 지시). 셋에 똑같이 복사돼 있던 것을 한 벌로 모았다(2026-09-04).
|
|
*
|
|
* **부드럽게 이동**(2026-09-04 사용자 지시) — 예전처럼 `scrollLeft`에 곧바로 더하면 휠
|
|
* 한 칸(120px)이 통째로 점프해 툭툭 끊긴다.
|
|
*
|
|
* 브라우저 기본 `scrollTo({ behavior: "smooth" })`는 쓰지 않는다. 휠을 굴리는 동안 매번
|
|
* 애니메이션이 **처음부터 다시 시작**돼 느린 구간만 반복하기 때문이다 — 실측으로 1.6초
|
|
* 동안 1,200px 중 17px 만 움직였다(2026-09-04). 대신 목표만 쌓아 두고 프레임마다 남은
|
|
* 거리의 일정 비율씩 좁힌다. 목표가 늘어나도 속도가 이어지므로 굴릴수록 뒤처지지 않는다.
|
|
*
|
|
* Shift+휠은 브라우저 기본 가로 스크롤이라 건드리지 않는다.
|
|
* ========================================================================== */
|
|
|
|
/** 프레임마다 남은 거리에서 좁히는 비율. 0.35면 60fps에서 약 0.13초에 도착한다 —
|
|
* 눈으로는 이어져 보이면서, 멈춘 뒤 세로 눈금이 따라오는 시간도 예전만큼 짧다. */
|
|
const EASE = 0.35;
|
|
/** 이보다 가까우면 도착으로 보고 딱 맞춘다(px). */
|
|
const SNAP_PX = 0.5;
|
|
/** EASE의 기준 프레임 간격(ms) — 60Hz 한 칸. */
|
|
const BASE_FRAME_MS = 1000 / 60;
|
|
/** 한 프레임으로 인정하는 최대 간격(ms). 탭이 잠들었다 깨면 간격이 수백 ms라 그대로
|
|
* 쓰면 한 번에 목표까지 튄다. */
|
|
const MAX_FRAME_MS = 64;
|
|
|
|
export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
|
|
let target = -1;
|
|
/** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */
|
|
let applied = -1;
|
|
let frame = 0;
|
|
/** 직전 프레임 시각 — 좁히는 양을 **흐른 시간**에 맞추는 데 쓴다. */
|
|
let lastFrameAt = 0;
|
|
|
|
function stop(): void {
|
|
if (frame) cancelAnimationFrame(frame);
|
|
frame = 0;
|
|
target = -1;
|
|
applied = -1;
|
|
lastFrameAt = 0;
|
|
}
|
|
|
|
function step(now: number): void {
|
|
frame = 0;
|
|
// 스크롤바 끌기·스크롤 동기화처럼 다른 경로가 자리를 옮겼으면 이동을 접는다.
|
|
if (applied >= 0 && Math.abs(scroller.scrollLeft - applied) > 1) return stop();
|
|
const limit = scroller.scrollWidth - scroller.clientWidth;
|
|
const goal = Math.min(limit, Math.max(0, target));
|
|
const rest = goal - scroller.scrollLeft;
|
|
if (Math.abs(rest) <= SNAP_PX) {
|
|
scroller.scrollLeft = goal;
|
|
return stop();
|
|
}
|
|
// 프레임당 고정 비율로 좁히면 화면 주사율에 따라 속도가 달라진다 — 120Hz에서는 두 배
|
|
// 빨라 툭 끊겨 보이고, 프레임을 한 번 놓치면 그만큼 덜컥 뛴다. 흐른 시간으로 환산해
|
|
// **어느 화면에서나 같은 시간**에 도착하게 한다(2026-09-04).
|
|
const elapsed = lastFrameAt ? Math.min(MAX_FRAME_MS, now - lastFrameAt) : BASE_FRAME_MS;
|
|
lastFrameAt = now;
|
|
const ratio = 1 - Math.pow(1 - EASE, elapsed / BASE_FRAME_MS);
|
|
scroller.scrollLeft = scroller.scrollLeft + rest * ratio;
|
|
applied = scroller.scrollLeft;
|
|
frame = requestAnimationFrame(step);
|
|
}
|
|
|
|
scroller.addEventListener(
|
|
"wheel",
|
|
(event) => {
|
|
if (event.shiftKey || event.deltaY === 0) return;
|
|
const limit = scroller.scrollWidth - scroller.clientWidth;
|
|
if (limit <= 0) return;
|
|
// 이동 중이면 **직전 목표**에 이어 더한다 — 현재 위치에 더하면 아직 못 간 거리를
|
|
// 잃어 굴릴수록 뒤처진다.
|
|
const from = frame ? target : scroller.scrollLeft;
|
|
const next = Math.min(limit, Math.max(0, from + event.deltaY));
|
|
if (next === from && (next <= 0 || next >= limit)) return;
|
|
target = next;
|
|
if (!frame) {
|
|
applied = scroller.scrollLeft;
|
|
frame = requestAnimationFrame(step);
|
|
}
|
|
event.preventDefault();
|
|
},
|
|
{ passive: false },
|
|
);
|
|
}
|