fix(B05): 하단 패널 리사이즈 진동·서브패널 드래그 값 복귀 버그

원인 두 가지(2026-08-05 사용자 보고 분석):
1. 캐스케이드의 임시 축소(인라인 height)가 서브패널 자체 리사이저의 변수 값을
   덮어씀 — 드래그 값이 화면에 안 먹다가 인라인이 걷힐 때 한 번에 튐
   ('이전 배치로 복귀' 증상). → 서브패널 드래그 중엔 인라인을 걷고 임시 축소를
   중단, 자리가 모자라면 메인 패널을 즉시 키워 밀어올림
2. 자동 확장(grow)이 메인 패널 드래그 중에도 개입해 포인터가 정한 높이를
   되돌림 — 서로 밀고 당기는 진동('반복적으로 돌아가려는' 증상).
   → 드래그 출처 추적(pointerdown/up 플래그): 메인 드래그 중 grow 금지,
   경량 동기화는 서브패널 드래그일 때만 grow 허용

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 19:57:06 +09:00
co-authored by Claude Fable 5
parent 81825413ef
commit 6273abc1ae
@@ -304,6 +304,26 @@ export function createRouteProfilePanel(
root.append(panelHandle.root, heightResizer.root, content);
drainagePanel.load(projectId);
/* ── 리사이저 드래그 출처 추적(2026-08-05 진동·복귀 버그 수정) ──────────────
* 메인 패널을 끄는 중엔 grow(자동 확장)가 포인터와 싸우면 안 되고, 서브패널을 끄는
* 중엔 캐스케이드의 임시 축소(인라인)가 드래그 변수 값을 덮으면 안 된다. */
let mainPanelDragging = false;
let subPanelDragging = false;
heightResizer.root.addEventListener("pointerdown", () => {
mainPanelDragging = true;
});
[massHaul.overlay, tableOverlay.overlay].forEach((overlay) =>
overlay.querySelector(".ui-resizer")?.addEventListener("pointerdown", () => {
subPanelDragging = true;
}),
);
const clearDragFlags = (): void => {
mainPanelDragging = false;
subPanelDragging = false;
};
window.addEventListener("pointerup", clearDragFlags);
window.addEventListener("pointercancel", clearDragFlags);
let detail: SectionDetailResponse | null = null;
let selectedStationId: string | null = null;
let stationInterval: number | undefined;
@@ -476,14 +496,22 @@ export function createRouteProfilePanel(
* draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는
* 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다.
*/
function applyHeightCascade(): { chartHeight: number } {
function applyHeightCascade(allowGrow: boolean): { chartHeight: number } {
const available = Math.max(120, body.clientHeight);
const massOpen = !massHaul.overlay.hidden;
const tableOpen = tableOverlay.isOpen();
// 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)로
// 덮어쓰면 드래그 값이 안 먹다가 나중에 한 번에 튄다("이전 배치로 복귀" 증상의 원인).
// 이때는 인라인을 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면
// 아래 deficit 처리로 메인 패널을 키운다.
if (subPanelDragging) {
massHaul.overlay.style.height = "";
tableOverlay.overlay.style.height = "";
}
let massHeight = massOpen ? massHaul.overlay.offsetHeight : 0;
let tableOverlayHeight = tableOpen ? tableOverlay.overlay.offsetHeight : 0;
const budget = Math.max(0, available - MIN_CHART_HEIGHT - MASSHAUL_HANDLE_GUTTER_PX);
if (massHeight + tableOverlayHeight > budget) {
if (!subPanelDragging && massHeight + tableOverlayHeight > budget) {
// ② 같이 줄이기 — 줄일 수 있는 여유분에 비례해 축소, 각자 최소 높이 하한.
const massMin = massOpen ? MASSHAUL_MIN_HEIGHT : 0;
const tableMin = tableOpen ? TABLE_OVERLAY_MIN_HEIGHT : 0;
@@ -496,7 +524,7 @@ export function createRouteProfilePanel(
// 패널을 다시 키우면 원래 높이로 돌아온다.
if (massOpen) massHaul.overlay.style.height = `${massHeight}px`;
if (tableOpen) tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
} else {
} else if (!subPanelDragging && massHeight + tableOverlayHeight <= budget) {
// 공간 충분 — 임시 축소 해제(저장된 변수 높이로 복귀).
massHaul.overlay.style.height = "";
tableOverlay.overlay.style.height = "";
@@ -506,10 +534,12 @@ export function createRouteProfilePanel(
// 접힌 유토곡선 손잡이는 바닥 고정(순서상 테이블 아래에서 나옴), 접힌 테이블 손잡이는
// 열린 유토곡선 위 경계(TableOverlay.syncHandlePosition).
tableOverlay.setBottomOffset(massHeight);
// ③' 최소까지 줄여도 모자라면(서브패널이 갑자기 펼쳐진 경우) 메인 패널을 키운다.
// ③' 최소까지 줄여도 모자라면(서브패널 펼침·서브패널 드래그로 자리가 부족한 경우)
// 메인 패널을 키운다. 단 **메인 패널을 끄는 중에는 절대 안 된다** — 포인터가 정한
// 높이를 되돌려 서로 밀고 당기는 진동("반복적으로 돌아가려는" 증상)이 생긴다.
const deficit =
MIN_CHART_HEIGHT + massHeight + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available;
if (deficit > 1) {
if (deficit > 1 && allowGrow && !mainPanelDragging) {
const grown = Math.min(root.offsetHeight + deficit, Math.round(window.innerHeight * 0.92));
if (grown > root.offsetHeight + 1) {
root.style.setProperty("--b05-profile-height", `${grown}px`);
@@ -528,7 +558,8 @@ export function createRouteProfilePanel(
* SVG는 잠깐 세로 스케일되지만, 손을 떼면(디바운스) 전체 재그리기가 정확히 다시 그린다. */
function syncHeightsLight(): void {
if (!detail) return;
const { chartHeight } = applyHeightCascade();
// 서브패널을 끄는 중엔 즉시 메인 패널을 밀어 올려야(grow) 드래그가 자연스럽다.
const { chartHeight } = applyHeightCascade(subPanelDragging);
const canvas = body.firstElementChild as HTMLElement | null;
if (!canvas || !canvas.classList.contains("b05-profile__canvas")) return;
canvas.style.height = `${chartHeight}px`;
@@ -572,7 +603,7 @@ export function createRouteProfilePanel(
const x = chainageMapper(longitudinal, width, originOffset);
// 그래프 40% : 테이블 60% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다).
// 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다.
const { chartHeight } = applyHeightCascade();
const { chartHeight } = applyHeightCascade(true);
const tableHeight = tableOverlay.contentHeight();
const table =
@@ -884,6 +915,8 @@ export function createRouteProfilePanel(
window.clearTimeout(crossPreviewTimer);
resizeObserver.disconnect();
heightResizer.dispose();
window.removeEventListener("pointerup", clearDragFlags);
window.removeEventListener("pointercancel", clearDragFlags);
},
};
}