Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Wheel.ts
T
eomsangdonandClaude Opus 5 a1a5081f28 fix(B05): 종단 그래프를 끌 때 글자가 선택되던 것 막음
포인터 잡기를 끌기 시작 뒤로 미룬 뒤(측점 선택 복구), 그 전까지 브라우저 기본
글자 선택이 살아나 끌 때마다 라벨·수치가 파랗게 잡혔다.

끌기가 실제로 시작되는 순간 `user-select: none` 을 걸고 이미 잡힌 선택을 푼다.
끝나면 원래대로 되돌린다.

검증(공용 브라우저, 실제 마우스): 끄는 동안 선택된 글자 0자, 끝난 뒤 속성 원복,
측점 클릭 선택은 그대로 동작(60m).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 16:43:01 +09:00

179 lines
8.9 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+휠은 브라우저 기본 가로 스크롤이라 건드리지 않는다.
*
* **끌어서 이동**(2026-09-06 사용자 지시) — 스크롤바를 잡지 않고 그림 위에서 바로 끌어
* 옮긴다. 좌클릭·휠 버튼 둘 다 되며, 여기 한 곳만 고치면 이것을 쓰는 세 곳(종단 그래프·
* 유토곡선·측점 테이블)이 함께 얻는다.
* ========================================================================== */
/** 프레임마다 남은 거리에서 좁히는 비율. 0.35면 60fps에서 약 0.13초에 도착한다 —
* 눈으로는 이어져 보이면서, 멈춘 뒤 세로 눈금이 따라오는 시간도 예전만큼 짧다. */
const EASE = 0.35;
/** 이보다 가까우면 도착으로 보고 딱 맞춘다(px). */
const SNAP_PX = 0.5;
/** 이만큼 넘게 움직여야 "끌기"로 본다(px). 그 아래는 클릭(측점 선택)으로 흘려보낸다. */
/** 이만큼 움직여야 「끌기」로 본다 — 그 아래는 클릭이다.
* 4px 은 손 떨림에도 넘어가 측점을 고르기 어려웠다(2026-09-06 사용자 지적). */
const DRAG_THRESHOLD_PX = 6;
/** 끌기를 시작하면 안 되는 조작 부품 — 이 위에서 누른 것은 그 부품의 몫이다.
* 구조물 마크는 자기 `pointerdown`에서 전파를 끊으므로 여기 적지 않아도 된다. */
const DRAG_BLOCK_SELECTOR =
"button, input, select, textarea, a, .ui-resizer, .b05-profile-edit__btn";
/** 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 },
);
/* ── 끌어서 이동 ─────────────────────────────────────────────────────────
* 좌클릭(0)·휠 버튼(1)으로 잡아 끈다. 문턱을 넘긴 뒤에야 이동으로 보고, 넘긴 경우에만
* 뒤따르는 click을 한 번 삼킨다 — 그래야 "그림을 조금 움직였는데 측점 선택이 풀리는"
* 일이 없다.
*
* ⚠ 포인터 잡기(`setPointerCapture`)는 **문턱을 넘긴 뒤에만** 한다(2026-09-06).
* 누르는 즉시 잡으면 뒤따르는 click 의 대상이 측점 `<g>` 가 아니라 스크롤러가 되어,
* 측점 클릭이 배경 클릭으로 처리되고 선택이 그 자리에서 풀렸다 — 「측점을 골라도
* 하이라이트가 안 된다」는 증상의 원인. 끌기가 실제로 시작된 뒤에는 잡아 두어야
* 창 밖에서 손을 떼도 이동이 풀리지 않는다.
*/
let dragId: number | null = null;
let dragX = 0;
let dragY = 0;
let dragLeft = 0;
let dragTop = 0;
let dragMoved = false;
function endDrag(): void {
if (dragId === null) return;
if (scroller.hasPointerCapture(dragId)) scroller.releasePointerCapture(dragId);
dragId = null;
scroller.style.cursor = "";
scroller.style.userSelect = "";
// 끈 뒤의 click 한 번만 삼킨다(캡처 단계라 아래 리스너보다 먼저 잡는다).
if (dragMoved) {
const swallow = (event: MouseEvent): void => {
event.stopPropagation();
event.preventDefault();
};
scroller.addEventListener("click", swallow, { capture: true, once: true });
// 클릭이 안 오는 경우(휠 버튼 등)를 대비해 다음 프레임에 걷어낸다.
requestAnimationFrame(() =>
scroller.removeEventListener("click", swallow, { capture: true }),
);
}
dragMoved = false;
}
scroller.addEventListener("pointerdown", (event) => {
if (dragId !== null || (event.button !== 0 && event.button !== 1)) return;
if ((event.target as HTMLElement | null)?.closest(DRAG_BLOCK_SELECTOR)) return;
// 끌기가 시작되면 휠 관성 이동은 접는다 — 둘이 같은 scrollLeft를 다투면 튄다.
stop();
dragId = event.pointerId;
dragX = event.clientX;
dragY = event.clientY;
dragLeft = scroller.scrollLeft;
dragTop = scroller.scrollTop;
dragMoved = false;
// 여기서는 포인터를 잡지 않는다 — 위 주석 참고(클릭이 측점에 닿아야 한다).
// 휠 버튼은 브라우저 자동 스크롤이 뜨므로 막는다.
if (event.button === 1) event.preventDefault();
});
scroller.addEventListener("pointermove", (event) => {
if (dragId !== event.pointerId) return;
const dx = event.clientX - dragX;
const dy = event.clientY - dragY;
if (!dragMoved && Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX) return;
if (!dragMoved) {
dragMoved = true;
scroller.style.cursor = "grabbing";
// 이제부터 진짜 끌기다 — 창 밖으로 나가도 이어지도록 포인터를 잡는다.
scroller.setPointerCapture(event.pointerId);
// 끄는 동안 글자가 파랗게 잡히지 않게 한다. 포인터 잡기를 미룬 뒤로 브라우저가
// 기본 글자 선택을 시작했다(2026-09-06 사용자 지적). 이미 잡힌 것도 푼다.
scroller.style.userSelect = "none";
window.getSelection()?.removeAllRanges();
}
scroller.scrollLeft = dragLeft - dx;
scroller.scrollTop = dragTop - dy;
event.preventDefault();
});
scroller.addEventListener("pointerup", endDrag);
scroller.addEventListener("pointercancel", endDrag);
// 잡아 둔 포인터를 잃으면(창 전환 등) 끌기도 함께 끝낸다.
scroller.addEventListener("lostpointercapture", endDrag);
}