From d8723f4208a3777f1385a90763623482eec58355 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 6 Aug 2026 16:47:57 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=ED=95=98=EB=8B=A8=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88=20=ED=8A=90?= =?UTF-8?q?=C2=B7=EC=9E=90=EB=8F=99=20=ED=99=95=EB=8C=80=20=EA=B7=BC?= =?UTF-8?q?=EC=A0=88=20=E2=80=94=20=EC=BA=90=EC=8A=A4=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EB=A9=B1=EB=93=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원인 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 --- .../B05_wf2_Route_UI_Profile_MassHaul.ts | 11 ++- .../B05_wf2_Route_UI_Profile_Panel.ts | 94 +++++++++++++------ .../B05_wf2_Route_UI_Profile_TableOverlay.ts | 13 ++- 3 files changed, 86 insertions(+), 32 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts index 333433a0..99d108ed 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts @@ -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; }, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index e5506528..91ef380e 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -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); }); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_TableOverlay.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_TableOverlay.ts index 6725aa3d..2e53dc19 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_TableOverlay.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_TableOverlay.ts @@ -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;