fix(B05): 하단 패널 리사이즈 튐·자동 확대 근절 — 캐스케이드 멱등화

원인 4개(2026-08-06 분석) 일괄 수정:
1. applyHeightCascade가 임시 축소 반영된 offsetHeight로 판정해 호출마다
   줄임↔풀림 진동 + 풀린 호출의 deficit가 메인 패널을 멋대로 확대.
   → 판정 기준을 저장 높이(desiredHeight 신설: 리사이저 CSS 변수/기본값)로
   전환해 멱등화. 축소 비율이 budget에 따라 연속 변화해 스냅 없음
2. grow 상한(window 92%)이 리사이저 max(부모 90%)보다 높아 자동 확대 후
   손잡이를 잡는 순간 clamp 급락 → 상한을 같은 식으로 통일
3. 서브패널 드래그가 매 프레임 전체 draw(차트·테이블 재생성)로 덜컹
   → 메인 드래그와 같은 경량 동기화(subPanelChanged 분기), 손 뗀 뒤
   전체 재구성 1회(clearDragFlags 120ms 예약)
4. pointerup 직후 지연 draw가 드래그 보호 해제 상태로 grow 발동
   → mainDragCooldown: 손 뗀 뒤 첫 전체 draw까지 grow 금지

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 16:47:57 +09:00
co-authored by Claude Fable 5
parent 6273abc1ae
commit d8723f4208
3 changed files with 86 additions and 32 deletions
@@ -63,6 +63,8 @@ export const MASSHAUL_MIN_HEIGHT = OVERLAY_MIN_HEIGHT;
const OVERLAY_DEFAULT_HEIGHT = 280;
/** 오버레이가 패널 본문을 다 덮지 않게 남기는 상한 비율. */
const OVERLAY_MAX_RATIO = 0.8;
/** 리사이저가 높이를 담는 CSS 변수 — CSS 기본값(280px)은 OVERLAY_DEFAULT_HEIGHT와 같아야 한다. */
const HEIGHT_VAR = "--b05-masshaul-height";
/**
* 표시 곡선·레이어 키 — **B06 유토곡선과 같은 키**를 쓴다. 두 화면은 같은 그림의 두 창이라
* 표시 상태가 갈리면 "같은 곡선인데 왜 다르게 보이나"가 된다(2026-08-03 사용자 확정).
@@ -130,6 +132,9 @@ export interface RouteMassHaulPanel {
/** 오버레이 서브패널 — 호출한 쪽이 `position: relative`인 패널 본문 래퍼에 붙인다. */
overlay: HTMLElement;
isOpen(): boolean;
/** 저장된 오버레이 높이(px) — 리사이저 변수(세션)나 기본값. 임시 축소(인라인)는 무시한다.
* Panel의 높이 캐스케이드가 호출마다 같은 판정을 내리는 기준(2026-08-06 진동 수정). */
desiredHeight(): number;
setContext(next: RouteMassHaulContext | null): void;
/** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */
attachScrollSync(main: HTMLElement): void;
@@ -184,7 +189,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
const resizer = createPanelResizer({
axis: "vertical",
target: overlay,
cssVar: "--b05-masshaul-height",
cssVar: HEIGHT_VAR,
direction: -1,
min: OVERLAY_MIN_HEIGHT,
max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO,
@@ -369,6 +374,10 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
handle,
overlay,
isOpen: () => open,
desiredHeight() {
const raw = parseFloat(overlay.style.getPropertyValue(HEIGHT_VAR));
return Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT;
},
setContext(next) {
context = next;
},
+64 -30
View File
@@ -258,12 +258,19 @@ export function createRouteProfilePanel(
bodyWrap.append(balanceBar, body, progress.root);
// 유토곡선 — 패널 바닥에 붙는 오버레이 서브패널(2026-08-04 사용자 확정).
// 종단도·테이블 배치는 건드리지 않고 그 위를 덮으며, 위 경계 리사이저로 높이를 조절한다.
const massHaul = createRouteMassHaulPanel(() => draw());
// 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고
// (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은
// 손을 뗀 뒤(clearDragFlags) 1회만 한다. 펼침·범례 토글 등 드래그 밖 알림은 전체 draw.
const subPanelChanged = (): void => {
if (subPanelDragging) scheduleLightSync();
else draw();
};
const massHaul = createRouteMassHaulPanel(subPanelChanged);
bodyWrap.append(massHaul.overlay, massHaul.handle);
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
massHaul.attachScrollSync(body);
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
const tableOverlay = createProfileTableOverlay(() => draw());
const tableOverlay = createProfileTableOverlay(subPanelChanged);
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
tableOverlay.attachScrollSync(body);
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
@@ -309,6 +316,10 @@ export function createRouteProfilePanel(
* 중엔 캐스케이드의 임시 축소(인라인)가 드래그 변수 값을 덮으면 안 된다. */
let mainPanelDragging = false;
let subPanelDragging = false;
/** 메인 드래그 손을 뗀 뒤 **첫 전체 재구성(draw)까지** grow 금지 — 120ms 지연 draw는
* 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해
* 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */
let mainDragCooldown = false;
heightResizer.root.addEventListener("pointerdown", () => {
mainPanelDragging = true;
});
@@ -318,6 +329,12 @@ export function createRouteProfilePanel(
}),
);
const clearDragFlags = (): void => {
if (mainPanelDragging) mainDragCooldown = true;
// 드래그 중엔 경량 동기화만 하므로, 손을 뗀 뒤 전체 재구성 1회를 예약한다.
if (mainPanelDragging || subPanelDragging) {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(draw, 120);
}
mainPanelDragging = false;
subPanelDragging = false;
};
@@ -500,47 +517,57 @@ export function createRouteProfilePanel(
const available = Math.max(120, body.clientHeight);
const massOpen = !massHaul.overlay.hidden;
const tableOpen = tableOverlay.isOpen();
// 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)로
// 덮어쓰면 드래그 값이 안 먹다가 나중에 한 번에 튄다("이전 배치로 복귀" 증상의 원인).
// 이때는 인라인을 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면
// 아래 deficit 처리로 메인 패널을 키운다.
// 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면
// 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이
// 호출마다 번갈아 도는 진동이 되고, 풀리는 호출의 deficit 처리가 메인 패널을
// 멋대로 키운다(2026-08-06 분석: 튐·자동 확대의 핵심 원인). 저장 높이 기준이면
// 몇 번을 호출해도 같은 답이 나온다(멱등).
const massDesired = massOpen ? massHaul.desiredHeight() : 0;
const tableDesired = tableOpen ? tableOverlay.desiredHeight() : 0;
let massHeight = massDesired;
let tableOverlayHeight = tableDesired;
const budget = Math.max(0, available - MIN_CHART_HEIGHT - MASSHAUL_HANDLE_GUTTER_PX);
if (subPanelDragging) {
// 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)를
// 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면 아래 deficit 처리로
// 메인 패널을 키운다. 변수는 드래그마다 갱신되므로 저장 높이 = 드래그 값이다.
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 (!subPanelDragging && massHeight + tableOverlayHeight > budget) {
} else if (massDesired + tableDesired > budget) {
// ② 같이 줄이기 — 줄일 수 있는 여유분에 비례해 축소, 각자 최소 높이 하한.
// 자리가 다시 늘면 ratio가 매끄럽게 0으로 줄어 저장 높이로 연속 복귀한다.
const massMin = massOpen ? MASSHAUL_MIN_HEIGHT : 0;
const tableMin = tableOpen ? TABLE_OVERLAY_MIN_HEIGHT : 0;
const shrinkable = massHeight - massMin + (tableOverlayHeight - tableMin);
const over = massHeight + tableOverlayHeight - budget;
const shrinkable = massDesired - massMin + (tableDesired - tableMin);
const over = massDesired + tableDesired - budget;
const ratio = shrinkable > 0 ? Math.min(1, over / shrinkable) : 1;
massHeight = Math.round(massHeight - (massHeight - massMin) * ratio);
tableOverlayHeight = Math.round(tableOverlayHeight - (tableOverlayHeight - tableMin) * ratio);
massHeight = Math.round(massDesired - (massDesired - massMin) * ratio);
tableOverlayHeight = Math.round(tableDesired - (tableDesired - tableMin) * ratio);
// 임시 축소는 인라인 높이로만 — 리사이저 저장값(--변수·세션)은 건드리지 않아
// 패널을 다시 키우면 원래 높이로 돌아온다.
if (massOpen) massHaul.overlay.style.height = `${massHeight}px`;
if (tableOpen) tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
} else if (!subPanelDragging && massHeight + tableOverlayHeight <= budget) {
// 공간 충분 — 임시 축소 해제(저장된 변수 높이로 복귀).
} else {
// 공간 충분 — 임시 축소 해제(저장 높이로 복귀).
massHaul.overlay.style.height = "";
tableOverlay.overlay.style.height = "";
massHeight = massOpen ? massHaul.overlay.offsetHeight : 0;
tableOverlayHeight = tableOpen ? tableOverlay.overlay.offsetHeight : 0;
}
// 접힌 유토곡선 손잡이는 바닥 고정(순서상 테이블 아래에서 나옴), 접힌 테이블 손잡이는
// 열린 유토곡선 위 경계(TableOverlay.syncHandlePosition).
tableOverlay.setBottomOffset(massHeight);
// ③' 최소까지 줄여도 모자라면(서브패널 펼침·서브패널 드래그로 자리가 부족한 경우)
// 메인 패널을 키운다. 단 **메인 패널을 끄는 중에는 절대 안 된다** — 포인터가 정한
// 높이를 되돌려 서로 밀고 당기는 진동("반복적으로 돌아가려는" 증상)이 생긴다.
// 메인 패널을 키운다. 단 **메인 패널을 끄는 중과 손 뗀 직후 첫 재구성까지**는 절대
// 안 된다 — 포인터가 정한 높이를 되돌려 서로 밀고 당기는 진동이 생긴다.
const deficit =
MIN_CHART_HEIGHT + massHeight + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available;
if (deficit > 1 && allowGrow && !mainPanelDragging) {
const grown = Math.min(root.offsetHeight + deficit, Math.round(window.innerHeight * 0.92));
if (deficit > 1 && allowGrow && !mainPanelDragging && !mainDragCooldown) {
// 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은
// 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은
// 상한으로 clamp되며 패널이 뚝 떨어졌다(2026-08-06 분석).
const growCap = Math.round(
(root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
);
const grown = Math.min(root.offsetHeight + deficit, growCap);
if (grown > root.offsetHeight + 1) {
root.style.setProperty("--b05-profile-height", `${grown}px`);
// ResizeObserver가 새 높이로 draw를 다시 부른다 — 이번 프레임은 그대로 마저 그린다.
@@ -554,6 +581,17 @@ export function createRouteProfilePanel(
};
}
/** 경량 동기화를 다음 프레임에 1회 예약 — 메인 리사이즈(ResizeObserver)와
* 서브패널 드래그(onResize 알림)가 같은 스로틀을 공유한다. */
function scheduleLightSync(): void {
if (lightSyncPending) return;
lightSyncPending = true;
requestAnimationFrame(() => {
lightSyncPending = false;
syncHeightsLight();
});
}
/** 리사이즈 중 프레임당 경량 동기화 — 차트·테이블 재구성 없이 높이만 맞춰 끊김을 없앤다.
* SVG는 잠깐 세로 스케일되지만, 손을 떼면(디바운스) 전체 재그리기가 정확히 다시 그린다. */
function syncHeightsLight(): void {
@@ -604,6 +642,8 @@ export function createRouteProfilePanel(
// 그래프 40% : 테이블 60% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다).
// 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다.
const { chartHeight } = applyHeightCascade(true);
// 전체 재구성이 한 번 돌면 배치가 확정된 것 — 다음 캐스케이드부터 grow를 다시 허용한다.
mainDragCooldown = false;
const tableHeight = tableOverlay.contentHeight();
const table =
@@ -793,13 +833,7 @@ export function createRouteProfilePanel(
return;
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
if (!lightSyncPending) {
lightSyncPending = true;
requestAnimationFrame(() => {
lightSyncPending = false;
syncHeightsLight();
});
}
scheduleLightSync();
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(draw, 120);
});
@@ -19,6 +19,10 @@ const OVERLAY_MIN_HEIGHT = 140;
/** 테이블 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */
export const TABLE_OVERLAY_MIN_HEIGHT = OVERLAY_MIN_HEIGHT;
const OVERLAY_MAX_RATIO = 0.75;
/** CSS 기본 높이 — `var(--b05-table-height, 300px)`(_Style_MassHaul.css)와 같아야 한다. */
const OVERLAY_DEFAULT_HEIGHT = 300;
/** 리사이저가 높이를 담는 CSS 변수. */
const HEIGHT_VAR = "--b05-table-height";
export interface RouteProfileTableOverlay {
/** 접힘 손잡이(패널 바닥 띠에 얹는다). */
@@ -26,6 +30,9 @@ export interface RouteProfileTableOverlay {
/** 테이블을 담는 바닥 고정 오버레이. */
overlay: HTMLElement;
isOpen: () => boolean;
/** 저장된 오버레이 높이(px) — 리사이저 변수(세션)나 기본값. 임시 축소(인라인)는 무시한다.
* Panel의 높이 캐스케이드가 호출마다 같은 판정을 내리는 기준(2026-08-06 진동 수정). */
desiredHeight: () => number;
/** 오버레이 내용 높이(px) — draw()가 테이블 행 높이를 정하는 기준. */
contentHeight: () => number;
/** draw()가 만든 테이블 요소를 넣는다(null이면 비움). */
@@ -56,7 +63,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
const resizer = createPanelResizer({
axis: "vertical",
target: overlay,
cssVar: "--b05-table-height",
cssVar: HEIGHT_VAR,
direction: -1,
min: OVERLAY_MIN_HEIGHT,
max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO,
@@ -131,6 +138,10 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
handle,
overlay,
isOpen: () => open,
desiredHeight: () => {
const raw = parseFloat(overlay.style.getPropertyValue(HEIGHT_VAR));
return Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT;
},
contentHeight: () => Math.max(0, overlay.offsetHeight - 6),
setTable(table) {
const keepScroll = scroll.scrollLeft;