From c2f681f9d39445712aced4627e98b0f20f4e6b0f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 21:07:55 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=EC=A2=85=EB=8B=A8=C2=B7=EC=9C=A0?= =?UTF-8?q?=ED=86=A0=EA=B3=A1=EC=84=A0=20=ED=9C=A0=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EB=B6=80=EB=93=9C=EB=9F=BD=EA=B2=8C=20+=20=EC=9E=AC=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=ED=91=9C=EC=8B=9C=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세 곳(종단 그래프·유토곡선·측점 테이블)에 복사돼 있던 휠 처리를 `B05_Profile_UI_Profile_Wheel.ts` 한 벌로 통합. - 이동은 rAF 로 목표를 쌓아 프레임마다 좁힘 — `scrollLeft` 직접 대입의 120px 점프 제거. 브라우저 `scrollTo({behavior:"smooth"})` 는 휠마다 애니메이션이 처음부터 다시 시작돼 1.6초에 17px 만 움직여 쓰지 않음(실측). - 세로 자동 맞춤 디바운스 160 → 80ms. - 「다시 계산 중…」 을 요약 막대에서 떼어 곡선 영역 우측 상단 오버레이로 이동 — 막대 줄바꿈으로 곡선이 밀리던 창밀림 제거. 자체검증(공용 브라우저 5173, 임시 탭): 휠 10칸 1,200px 중 1,199px 이동·궤적 연속, 마지막 휠 500ms → 재구성 813ms. 칩 표시/숨김에서 요약 막대 높이 53px·곡선 자리 (top 1078·height 226) 동일 — 창밀림 0. 칩은 곡선 영역 안 우측(범례 아래 4px). Co-Authored-By: Claude Opus 5 --- .../B05_Profile_UI_Profile_MassHaul.ts | 39 ++++------ B05_Profile/B05_Profile_UI_Profile_Panel.ts | 21 ++---- .../B05_Profile_UI_Profile_TableOverlay.ts | 16 +--- B05_Profile/B05_Profile_UI_Profile_Wheel.ts | 73 +++++++++++++++++++ B05_Profile/B05_Profile_UI_Style_MassHaul.css | 16 +++- 5 files changed, 107 insertions(+), 58 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Profile_Wheel.ts diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 42c0cdfc..f702240f 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -51,6 +51,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; import "./B05_Profile_UI_Style_MassHaul.css"; +import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ const OPEN_KEY = "b05-route-profile-masshaul-open"; @@ -216,7 +217,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa }); }, }); - overlay.append(resizer.root, bar, scroll, legendLayer); + // 재계산 대기 표시 — 곡선 영역 우측 상단에 **떠 있는** 알림이다. 예전에는 요약 막대에 + // 붙였는데 막대가 줄바꿈되며 곡선을 아래로 밀어냈다(2026-09-04 사용자 보고: 「창 밀림」). + const pendingChip = document.createElement("span"); + pendingChip.className = "b05-profile__masshaul-pending"; + pendingChip.textContent = "다시 계산 중…"; + pendingChip.hidden = true; + overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip); // 측점선이 아닌 빈 곳 클릭 = 선택 해제 — 종단 그래프와 같은 규칙(2026-08-04 사용자 지시). let clearSelection: (() => void) | undefined; @@ -227,20 +234,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa // 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시). // scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로. - scroll.addEventListener( - "wheel", - (event) => { - if (event.shiftKey || event.deltaY === 0) return; - const limit = scroll.scrollWidth - scroll.clientWidth; - if (limit <= 0) return; - const delta = event.deltaY; - if ((delta < 0 && scroll.scrollLeft <= 0) || (delta > 0 && scroll.scrollLeft >= limit)) - return; - scroll.scrollLeft += delta; - event.preventDefault(); - }, - { passive: false }, - ); + attachWheelHorizontalScroll(scroll); /** * 손잡이를 오버레이 **위 경계**에 태운다(2026-08-04 사용자 지시 — 펼치면 같이 올라와 @@ -298,6 +292,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa function note(message: string): void { drawnOnce = false; + pendingChip.hidden = true; bar.replaceChildren(); scroll.replaceChildren(); legendLayer.replaceChildren(); @@ -324,19 +319,11 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa // 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만 // 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다. if (params.pendingRecalc) { - if (drawnOnce) { - const pending = bar.querySelector(".b05-profile__masshaul-pending"); - if (!pending) { - const chip = document.createElement("span"); - chip.className = "b05-profile__masshaul-pending"; - chip.textContent = "다시 계산 중…"; - bar.append(chip); - } - } else { - note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다."); - } + if (drawnOnce) pendingChip.hidden = false; + else note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다."); return; } + pendingChip.hidden = true; // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. const series: MassHaulSeries[] = computeMassHaulSeries( params.longitudinal, diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index a2580a54..45fec08d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -97,6 +97,7 @@ const CROSS_PREVIEW_DEBOUNCE_MS = 60; /** 콜백 타입은 700줄 제한으로 `_Panel_Types` 로 옮겼다 — 옛 임포트 경로가 그대로 * 동작하도록 여기서 다시 내보낸다(2026-09-04). */ import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; +import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; export type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; export function createRouteProfilePanel( @@ -508,8 +509,10 @@ export function createRouteProfilePanel( }); } - /** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. */ - const SCROLL_SETTLE_MS = 160; + /** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. + * 재구성이 실측 50ms 미만이라 160 → 80ms 로 줄였다 — 멈춘 뒤 따라오는 시간을 반으로 + * (2026-09-04 사용자 지시: 「응답이 늦게 쫓아온다」). */ + const SCROLL_SETTLE_MS = 80; /** 마지막으로 세로를 맞춘 가로 위치 — 같은 자리면 다시 그리지 않는다(재구성 되먹임 차단). */ let settledScrollLeft = 0; let scrollSettleTimer = 0; @@ -525,19 +528,7 @@ export function createRouteProfilePanel( // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 // 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다). - body.addEventListener( - "wheel", - (event) => { - if (event.shiftKey || event.deltaY === 0) return; - const delta = event.deltaY; - const limit = body.scrollWidth - body.clientWidth; - if (limit <= 0) return; - if ((delta < 0 && body.scrollLeft <= 0) || (delta > 0 && body.scrollLeft >= limit)) return; - body.scrollLeft += delta; - event.preventDefault(); - }, - { passive: false }, - ); + attachWheelHorizontalScroll(body); const resizeObserver = new ResizeObserver(() => { if ( diff --git a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts index cfea791e..2738f154 100644 --- a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts +++ b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts @@ -12,6 +12,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; +import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; const OPEN_KEY = "b05:profile:table:open"; const HEIGHT_KEY = "b05:profile:table:height"; @@ -87,20 +88,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa scroll.addEventListener("contextmenu", (event) => event.preventDefault()); // 유토곡선 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(종단 스크롤도 함께 움직인다). - scroll.addEventListener( - "wheel", - (event) => { - if (event.shiftKey || event.deltaY === 0) return; - const limit = scroll.scrollWidth - scroll.clientWidth; - if (limit <= 0) return; - const delta = event.deltaY; - if ((delta < 0 && scroll.scrollLeft <= 0) || (delta > 0 && scroll.scrollLeft >= limit)) - return; - scroll.scrollLeft += delta; - event.preventDefault(); - }, - { passive: false }, - ); + attachWheelHorizontalScroll(scroll); /** 손잡이는 오버레이 위 경계를, 접히면 열린 유토곡선의 위 경계(bottomOffset)를 따라간다 * — 접힌 버튼이 펼쳐진 패널 수평선에 놓인다(2026-08-05 사용자 지시). */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts new file mode 100644 index 00000000..c2e0454e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -0,0 +1,73 @@ +/* ============================================================================= + * 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; + +export function attachWheelHorizontalScroll(scroller: HTMLElement): void { + let target = -1; + /** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */ + let applied = -1; + let frame = 0; + + function stop(): void { + if (frame) cancelAnimationFrame(frame); + frame = 0; + target = -1; + applied = -1; + } + + function step(): 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(); + } + const next = scroller.scrollLeft + rest * EASE; + scroller.scrollLeft = next; + 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 }, + ); +} diff --git a/B05_Profile/B05_Profile_UI_Style_MassHaul.css b/B05_Profile/B05_Profile_UI_Style_MassHaul.css index c482c4ca..85c65516 100644 --- a/B05_Profile/B05_Profile_UI_Style_MassHaul.css +++ b/B05_Profile/B05_Profile_UI_Style_MassHaul.css @@ -112,12 +112,22 @@ padding-top: 0; } -/* 재계산 대기 표시 — 곡선을 지우지 않고 요약줄 끝에만 붙인다(2026-09-03 깜빡임 제거). */ +/* 재계산 대기 표시 — 곡선을 지우지 않고(2026-09-03 깜빡임 제거) 곡선 영역 우측 상단에 + **떠 있는** 알림으로 둔다. 요약 막대에 붙이면 막대가 줄바꿈되며 곡선을 밀어냈다 + (2026-09-04 사용자 보고). 범례(top: 34px) 아래 줄에 앉혀 서로 가리지 않는다. */ .b05-profile__masshaul-pending { - margin-left: auto; + position: absolute; + top: 62px; + right: var(--spacing-8); + z-index: 4; + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); color: var(--color-text-secondary); font-size: var(--text-caption); - opacity: 0.8; + line-height: 1.2; + pointer-events: none; } .b05-profile__masshaul-empty {