fix(B05): 종단·유토곡선 휠 이동 부드럽게 + 재계산 표시 오버레이화

- 세 곳(종단 그래프·유토곡선·측점 테이블)에 복사돼 있던 휠 처리를
  `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 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 21:07:55 +09:00
co-authored by Claude Opus 5
parent 973ac7138f
commit c2f681f9d3
5 changed files with 107 additions and 58 deletions
+13 -26
View File
@@ -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,
+6 -15
View File
@@ -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 (
@@ -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 사용자 지시). */
@@ -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 },
);
}
+13 -3
View File
@@ -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 {