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