feat(B05): 메인 패널 리사이즈 시 종단·테이블·유토곡선 비례 연동
메인 패널을 끌면 3영역이 드래그 시작 시점 비율대로 같이 늘고 준다
(2026-08-06 사용자 확정). 롤백 기준: 직전 안정판 d8723f4.
- Panel: pointerdown에서 3영역 실측 스냅샷(mainDragRef), 캐스케이드에
메인 드래그 분기 — 비례 배분 + 최소(100/160/140) 고정 후 남은 영역끼리
재배분(최대 3회 수렴). 서브패널 상한(75%/80%)은 안전상 유지
- 손 떼면 결과 높이를 서브패널 저장값으로 확정(commitHeight: 리사이저와
같은 CSS 변수+세션 기록) — 이후 redraw 판정 기준이 되어 멱등성 유지
- MassHaul syncHandle() 신설 — 드래그 중 손잡이가 인라인 높이 위 경계 추종
실측 테스트: 확대 시 3영역 동일 배율(×1.41), 축소 시 최소 걸린 영역부터
정지, 손 뗀 뒤·redraw 후 고정, 서브패널 개별 드래그·밀어올림 보존,
콘솔 에러 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -135,6 +135,11 @@ export interface RouteMassHaulPanel {
|
||||
/** 저장된 오버레이 높이(px) — 리사이저 변수(세션)나 기본값. 임시 축소(인라인)는 무시한다.
|
||||
* Panel의 높이 캐스케이드가 호출마다 같은 판정을 내리는 기준(2026-08-06 진동 수정). */
|
||||
desiredHeight(): number;
|
||||
/** 저장 높이를 확정한다(리사이저와 같은 변수+세션 기록) — 메인 패널 비례 연동이
|
||||
* 드래그를 마친 높이를 앞으로의 기준으로 못 박는 데 쓴다(2026-08-06 사용자 확정). */
|
||||
commitHeight(px: number): void;
|
||||
/** 손잡이를 오버레이 위 경계로 다시 맞춘다 — 캐스케이드가 인라인 높이를 바꾼 직후 호출. */
|
||||
syncHandle(): void;
|
||||
setContext(next: RouteMassHaulContext | null): void;
|
||||
/** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */
|
||||
attachScrollSync(main: HTMLElement): void;
|
||||
@@ -378,6 +383,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
const raw = parseFloat(overlay.style.getPropertyValue(HEIGHT_VAR));
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT;
|
||||
},
|
||||
commitHeight(px) {
|
||||
const next = Math.round(px);
|
||||
overlay.style.setProperty(HEIGHT_VAR, `${next}px`);
|
||||
sessionStorage.setItem(OVERLAY_HEIGHT_KEY, String(next));
|
||||
syncHandlePosition();
|
||||
},
|
||||
syncHandle: () => syncHandlePosition(),
|
||||
setContext(next) {
|
||||
context = next;
|
||||
},
|
||||
|
||||
@@ -320,8 +320,21 @@ export function createRouteProfilePanel(
|
||||
* 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해
|
||||
* 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */
|
||||
let mainDragCooldown = false;
|
||||
/** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준.
|
||||
* 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */
|
||||
let mainDragRef: { chart: number; mass: number; table: number } | null = null;
|
||||
heightResizer.root.addEventListener("pointerdown", () => {
|
||||
mainPanelDragging = true;
|
||||
const massH = massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight;
|
||||
const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0;
|
||||
mainDragRef = {
|
||||
chart: Math.max(
|
||||
MIN_CHART_HEIGHT,
|
||||
body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
),
|
||||
mass: massH,
|
||||
table: tableH,
|
||||
};
|
||||
});
|
||||
[massHaul.overlay, tableOverlay.overlay].forEach((overlay) =>
|
||||
overlay.querySelector(".ui-resizer")?.addEventListener("pointerdown", () => {
|
||||
@@ -330,6 +343,16 @@ export function createRouteProfilePanel(
|
||||
);
|
||||
const clearDragFlags = (): void => {
|
||||
if (mainPanelDragging) mainDragCooldown = true;
|
||||
if (mainPanelDragging && mainDragRef) {
|
||||
// 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정
|
||||
// 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전
|
||||
// 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다).
|
||||
if (!massHaul.overlay.hidden) massHaul.commitHeight(massHaul.overlay.offsetHeight);
|
||||
if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight);
|
||||
massHaul.overlay.style.height = "";
|
||||
tableOverlay.overlay.style.height = "";
|
||||
}
|
||||
mainDragRef = null;
|
||||
// 드래그 중엔 경량 동기화만 하므로, 손을 뗀 뒤 전체 재구성 1회를 예약한다.
|
||||
if (mainPanelDragging || subPanelDragging) {
|
||||
window.clearTimeout(resizeTimer);
|
||||
@@ -527,6 +550,52 @@ export function createRouteProfilePanel(
|
||||
let massHeight = massDesired;
|
||||
let tableOverlayHeight = tableDesired;
|
||||
const budget = Math.max(0, available - MIN_CHART_HEIGHT - MASSHAUL_HANDLE_GUTTER_PX);
|
||||
// ── 메인 드래그 중: 3영역 비례 연동(2026-08-06 사용자 확정) ─────────────
|
||||
// 드래그 시작 시점 비율(mainDragRef)대로 종단·유토곡선·테이블을 같이 늘리고
|
||||
// 줄인다. 최소에 닿은 영역은 거기서 멈추고 남은 영역끼리 다시 비례 배분한다.
|
||||
// 손을 떼면 clearDragFlags가 이 결과를 저장 높이로 확정한다.
|
||||
if (mainPanelDragging && mainDragRef) {
|
||||
const content = Math.max(0, available - MASSHAUL_HANDLE_GUTTER_PX);
|
||||
const items = [
|
||||
{ key: "chart", ref: Math.max(1, mainDragRef.chart), min: MIN_CHART_HEIGHT },
|
||||
...(massOpen
|
||||
? [{ key: "mass", ref: Math.max(1, mainDragRef.mass), min: MASSHAUL_MIN_HEIGHT }]
|
||||
: []),
|
||||
...(tableOpen
|
||||
? [{ key: "table", ref: Math.max(1, mainDragRef.table), min: TABLE_OVERLAY_MIN_HEIGHT }]
|
||||
: []),
|
||||
];
|
||||
// 고정 → 재배분 반복: 축소 배율로 최소를 뚫는 항목을 최소에 고정하고, 남은
|
||||
// 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 3개라 최대 3회에 끝난다.
|
||||
const out: Record<string, number> = {};
|
||||
let pool = content;
|
||||
let active = items;
|
||||
while (active.length) {
|
||||
const refSum = active.reduce((sum, item) => sum + item.ref, 0);
|
||||
const scale = pool / refSum;
|
||||
const pinned = active.filter((item) => item.ref * scale < item.min);
|
||||
if (!pinned.length) {
|
||||
active.forEach((item) => (out[item.key] = Math.round(item.ref * scale)));
|
||||
break;
|
||||
}
|
||||
pinned.forEach((item) => {
|
||||
out[item.key] = item.min;
|
||||
pool -= item.min;
|
||||
});
|
||||
active = active.filter((item) => !pinned.includes(item));
|
||||
}
|
||||
// 서브패널 상한(개별 리사이저 max와 같은 비율)은 안전상 유지 — 넘치면 종단이 흡수.
|
||||
massHeight = massOpen ? Math.min(out.mass ?? 0, Math.round(available * 0.8)) : 0;
|
||||
tableOverlayHeight = tableOpen ? Math.min(out.table ?? 0, Math.round(available * 0.75)) : 0;
|
||||
if (massOpen) massHaul.overlay.style.height = `${massHeight}px`;
|
||||
if (tableOpen) tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
|
||||
// 손잡이는 바뀐 인라인 높이의 위 경계를 즉시 따라간다.
|
||||
massHaul.syncHandle();
|
||||
tableOverlay.setBottomOffset(massHeight);
|
||||
return {
|
||||
chartHeight: Math.max(MIN_CHART_HEIGHT, content - massHeight - tableOverlayHeight),
|
||||
};
|
||||
}
|
||||
if (subPanelDragging) {
|
||||
// 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)를
|
||||
// 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면 아래 deficit 처리로
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface RouteProfileTableOverlay {
|
||||
/** 저장된 오버레이 높이(px) — 리사이저 변수(세션)나 기본값. 임시 축소(인라인)는 무시한다.
|
||||
* Panel의 높이 캐스케이드가 호출마다 같은 판정을 내리는 기준(2026-08-06 진동 수정). */
|
||||
desiredHeight: () => number;
|
||||
/** 저장 높이를 확정한다(리사이저와 같은 변수+세션 기록) — 메인 패널 비례 연동이
|
||||
* 드래그를 마친 높이를 앞으로의 기준으로 못 박는 데 쓴다(2026-08-06 사용자 확정). */
|
||||
commitHeight: (px: number) => void;
|
||||
/** 오버레이 내용 높이(px) — draw()가 테이블 행 높이를 정하는 기준. */
|
||||
contentHeight: () => number;
|
||||
/** draw()가 만든 테이블 요소를 넣는다(null이면 비움). */
|
||||
@@ -142,6 +145,12 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
const raw = parseFloat(overlay.style.getPropertyValue(HEIGHT_VAR));
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT;
|
||||
},
|
||||
commitHeight: (px) => {
|
||||
const next = Math.round(px);
|
||||
overlay.style.setProperty(HEIGHT_VAR, `${next}px`);
|
||||
sessionStorage.setItem(HEIGHT_KEY, String(next));
|
||||
syncHandlePosition();
|
||||
},
|
||||
contentHeight: () => Math.max(0, overlay.offsetHeight - 6),
|
||||
setTable(table) {
|
||||
const keepScroll = scroll.scrollLeft;
|
||||
|
||||
Reference in New Issue
Block a user