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:
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user