feat(ui): 구조물 폼 빈칸 상태·사이드 액션 고정·하단 패널 높이 캐스케이드

- 구조물 종류 드롭다운에 빈칸 항목 — 초기·리셋 상태는 빈칸이고 하위 옵션 전체
  숨김, 미선택이면 추가 불가(2026-08-05 사용자 지시)
- 사이드 패널 최하단 고정 액션 줄 공용 클래스(ui-sidebar-actions): 계산·확정
  버튼이 스크롤에서 제외되고 불투명 배경이 지나가는 내용을 가림.
  B04(확정·초기화)/B05(계산·확정)/B06(재계산·저장·확정) 적용, B07은 기존
  자체 sticky 유지
- 하단 패널 높이 캐스케이드: 아래로 끌면 종단 먼저 최소(100px)까지 →
  테이블·유토곡선이 같이 비례 축소(각자 최소 140/160px, 인라인 임시 축소라
  다시 키우면 원래 높이 복귀) → 셋 다 최소면 리사이저 동적 하한이 차단.
  공용 리사이저 min을 함수 지원으로 확장. 반대로 서브패널 펼침으로 부족하면
  메인 패널을 키움(기존 로직 유지)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 19:44:16 +09:00
co-authored by Claude Fable 5
parent 0025538b18
commit 69aed9d26c
10 changed files with 104 additions and 29 deletions
+6 -8
View File
@@ -192,14 +192,12 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
const panel = document.createElement("div");
panel.className = "b04-surface__form";
panel.append(
inputGroup,
analysisGroup,
displayGroup,
viewer.controlsGroup,
confirmButton,
resetButton,
);
// 확정·초기화 버튼은 사이드 최하단 고정(공용 ui-sidebar-actions) — 스크롤 제외
// (2026-08-05 사용자 지시, B04·B05·B06·B07 공통).
const actionRow = document.createElement("div");
actionRow.className = "ui-sidebar-actions";
actionRow.append(confirmButton, resetButton);
panel.append(inputGroup, analysisGroup, displayGroup, viewer.controlsGroup, actionRow);
const viewers = document.createElement("div");
viewers.className = "b04-surface__viewers";
@@ -17,7 +17,6 @@ import {
PIPE_DEFAULT_TYPE,
PIPE_DIAMETERS_MM,
PIPE_TYPES,
STRUCTURE_DEFAULT_TYPE,
STRUCTURE_ETC_DEFAULT_NAME,
STRUCTURE_TYPES,
pickPipeDiameter,
@@ -171,7 +170,8 @@ export function createIrregularStationsSection(
remainderField.placeholder = "잔여거리";
// 구조물 종류 + 종류별 하위 옵션 (2026-08-05 사용자 확정. 목록은 config_frontend가 정본).
const typeSelect = selectInput(STRUCTURE_TYPES);
// 첫 항목은 빈칸 — 리셋 시 "선택 안 됨" 상태를 표현한다(빈칸이면 하위 옵션 전체 숨김).
const typeSelect = selectInput(["", ...STRUCTURE_TYPES]);
const pipeTypeSelect = selectInput(PIPE_TYPES);
const diameterSelect = document.createElement("select");
const widthSelect = selectInput(ESCAPE_ROUTE_WIDTHS_M.map((width) => width.toFixed(1)));
@@ -198,13 +198,15 @@ export function createIrregularStationsSection(
diameterSelect.value = String(pickPipeDiameter(pipeTypeSelect.value, wanted));
}
/** 종류에 따라 보이는 하위 옵션을 바꾼다 — 배관: 관종+직경 / 대피로: 폭 / 기타: 이름. */
/** 종류에 따라 보이는 하위 옵션을 바꾼다 — 배관: 관종+직경 / 대피로: 폭 / 기타: 이름.
* 빈칸(선택 안 됨)이면 하위 옵션 전체를 숨긴다(리셋 직후 상태). */
function syncSubOptions(): void {
const type = typeSelect.value as StructureType;
const type = typeSelect.value as StructureType | "";
pipeTypeField.hidden = diameterField.hidden = type !== "배관";
widthField.hidden = type !== "대피로";
nameFieldWrap.hidden = type !== "기타";
subRow.hidden = type === "기성막이"; // 기성막이 하위 옵션 미정(추가반영 예정)
// 기성막이 하위 옵션 미정(추가반영 예정), 빈칸은 아무것도 안 보인다.
subRow.hidden = type === "기성막이" || type === "";
}
typeSelect.addEventListener("change", () => {
syncSubOptions();
@@ -218,7 +220,7 @@ export function createIrregularStationsSection(
}
});
pipeTypeSelect.addEventListener("change", () => syncDiameterOptions());
typeSelect.value = STRUCTURE_DEFAULT_TYPE;
typeSelect.value = ""; // 초기·리셋 상태는 빈칸(2026-08-05 사용자 지시)
pipeTypeSelect.value = PIPE_DEFAULT_TYPE;
widthSelect.value = ESCAPE_ROUTE_DEFAULT_WIDTH_M.toFixed(1);
syncDiameterOptions(PIPE_DEFAULT_DIAMETER_MM);
@@ -282,7 +284,8 @@ export function createIrregularStationsSection(
stationField.value = target ? String(target.station) : "";
remainderField.value = target ? String(target.remainder) : "";
// 구버전 항목(structureType 없음)은 "기타"로 열고 이름칸에 기존 텍스트를 옮긴다.
const type = target ? (target.structureType ?? "기타") : STRUCTURE_DEFAULT_TYPE;
// 선택 해제·리셋이면 종류도 빈칸으로 — 하위 옵션이 전부 숨는다(2026-08-05 사용자 지시).
const type = target ? (target.structureType ?? "기타") : "";
typeSelect.value = type;
pipeTypeSelect.value = target?.pipeType ?? PIPE_DEFAULT_TYPE;
syncDiameterOptions(target?.diameterMm ?? PIPE_DEFAULT_DIAMETER_MM);
@@ -346,6 +349,11 @@ export function createIrregularStationsSection(
const safeRemainder = Number.isFinite(remainder)
? Math.min(Math.max(remainder, 0), intervalMax())
: 0;
// 종류 미선택(빈칸)이면 추가하지 않는다 — 리셋 직후 상태.
if (!typeSelect.value) {
typeSelect.focus();
return;
}
// 드롭다운 선택값 → 옵션 필드 + 표시 라벨. 종류에 안 쓰는 필드는 넣지 않는다.
const structureType = typeSelect.value as StructureType;
const options: Partial<IrregularStation> = { structureType };
+2 -1
View File
@@ -360,7 +360,8 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
const confirmButton = button("경로 확정", callbacks.onConfirm);
confirmButton.disabled = true;
const actionRow = document.createElement("div");
actionRow.className = "b05-route__actions";
// 사이드 최하단 고정(공용 ui-sidebar-actions) — 스크롤에서 제외(2026-08-05 사용자 지시).
actionRow.className = "b05-route__actions ui-sidebar-actions";
actionRow.append(solveButton, confirmButton);
const inputElements = [
@@ -58,6 +58,8 @@ const OPEN_KEY = "b05-route-profile-masshaul-open";
const OVERLAY_HEIGHT_KEY = "b05-route-profile-masshaul-height";
/** 오버레이 높이 하한/기본값(px). 곡선 + 요약 막대가 읽히는 최소 크기다. */
const OVERLAY_MIN_HEIGHT = 160;
/** 유토곡선 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */
export const MASSHAUL_MIN_HEIGHT = OVERLAY_MIN_HEIGHT;
const OVERLAY_DEFAULT_HEIGHT = 280;
/** 오버레이가 패널 본문을 다 덮지 않게 남기는 상한 비율. */
const OVERLAY_MAX_RATIO = 0.8;
@@ -23,7 +23,10 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel";
import { mountStructureMenu } from "./B05_wf2_Route_UI_Profile_Structures";
import { createProfileTableOverlay } from "./B05_wf2_Route_UI_Profile_TableOverlay";
import {
createProfileTableOverlay,
TABLE_OVERLAY_MIN_HEIGHT,
} from "./B05_wf2_Route_UI_Profile_TableOverlay";
import {
hasLegacyAlignment,
normalizedLongitudinal,
@@ -53,6 +56,7 @@ import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_vie
import {
buildStickyYAxis,
createRouteMassHaulPanel,
MASSHAUL_MIN_HEIGHT,
type RouteMassHaulContext,
} from "./B05_wf2_Route_UI_Profile_MassHaul";
import {
@@ -282,7 +286,18 @@ export function createRouteProfilePanel(
target: root,
cssVar: "--b05-profile-height",
direction: -1,
min: MIN_PANEL_HEIGHT,
// 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정).
// 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) →
// 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단).
min: () => {
const chrome = Math.max(0, root.offsetHeight - body.clientHeight);
const massMin = massHaul.overlay.hidden ? 0 : MASSHAUL_MIN_HEIGHT;
const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0;
return Math.max(
MIN_PANEL_HEIGHT,
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin,
);
},
max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
storageKey: HEIGHT_KEY,
});
@@ -489,14 +504,39 @@ export function createRouteProfilePanel(
// · 하나만 펼침: 그 패널 높이만큼 종단도가 줄어든다. 테이블 높이는 고정.
// · 둘 다 펼침: 위→아래 = 종단도 → 테이블 → 유토곡선. 유토곡선 높이를 끌어 늘리면
// 테이블은 그대로 두고 종단도가 그만큼 줄어든다(리사이저 onResize → draw 재계산).
const massHeight = massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight;
const tableOverlayHeight = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0;
// 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
// 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면
// 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기
// 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(아래 deficit 처리).
const massOpen = !massHaul.overlay.hidden;
const tableOpen = tableOverlay.isOpen();
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) {
// ② 같이 줄이기 — 줄일 수 있는 여유분에 비례해 축소, 각자 최소 높이 하한.
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 ratio = shrinkable > 0 ? Math.min(1, over / shrinkable) : 1;
massHeight = Math.round(massHeight - (massHeight - massMin) * ratio);
tableOverlayHeight = Math.round(tableOverlayHeight - (tableOverlayHeight - tableMin) * ratio);
// 임시 축소는 인라인 높이로만 — 리사이저 저장값(--변수·세션)은 건드리지 않아
// 패널을 다시 키우면 원래 높이로 돌아온다.
if (massOpen) massHaul.overlay.style.height = `${massHeight}px`;
if (tableOpen) tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
} 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);
// 접힌 유토곡선 손잡이는 **하단(바닥)** 그대로 둔다 — 순서가 종단>테이블>유토곡선이라
// 유토곡선은 테이블 아래에서 나오기 때문(2026-08-05 사용자 확정). 반대로 접힌 테이블
// 손잡이는 열린 유토곡선 위 경계에 나란히 앉는다(TableOverlay.syncHandlePosition).
// 종단 그래프 최소 높이는 반드시 확보한다 — 서브패널이 갑자기 펼쳐져 자리가 모자라면
// 종단이 뒤로 넘어가는 대신 **메인 하단 패널 높이를 키운다**(2026-08-05 사용자 지시).
// ③' 최소까지 줄여도 모자라면(서브패널이 갑자기 펼쳐진 경우) 메인 패널을 키운다.
const deficit =
MIN_CHART_HEIGHT + massHeight + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available;
if (deficit > 1) {
@@ -16,6 +16,8 @@ import { createPanelResizer } from "@ui/ui_template_resizer";
const OPEN_KEY = "b05:profile:table:open";
const HEIGHT_KEY = "b05:profile:table:height";
const OVERLAY_MIN_HEIGHT = 140;
/** 테이블 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */
export const TABLE_OVERLAY_MIN_HEIGHT = OVERLAY_MIN_HEIGHT;
const OVERLAY_MAX_RATIO = 0.75;
export interface RouteProfileTableOverlay {
@@ -113,7 +113,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
});
confirmButton.disabled = true;
const actionRow = document.createElement("div");
actionRow.className = "b06-profile__actions";
// 사이드 최하단 고정(공용 ui-sidebar-actions) — 스크롤에서 제외(2026-08-05 사용자 지시).
actionRow.className = "b06-profile__actions ui-sidebar-actions";
actionRow.append(recalcButton, saveButton, confirmButton);
const leftForm = document.createElement("div");
@@ -509,6 +509,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
);
drawingListEl = drawingPanel;
const confirmActions = document.createElement("div");
// b07-drawing-actions는 자체 sticky 하단 고정(B07 Style.css) — 공용 ui-sidebar-actions와
// 같은 동작이라 중복 적용하지 않는다(2026-08-05 확인).
confirmActions.className = "b07-drawing-actions";
confirmActions.append(confirmButton);
+19
View File
@@ -220,3 +220,22 @@
max-height: calc(100vh - var(--spacing-64) - var(--spacing-16));
}
}
/* 사이드 패널 최하단 고정 액션 줄(계산·확정 버튼) — 스크롤에서 제외된다.
B04·B05·B06·B07 공통(2026-08-05 사용자 지시). 스크롤로 지나가는 다른 버튼·내용이
틈으로 노출되지 않게 불투명 배경 + 사이드 body의 하단 패딩(16px)까지 덮는다. */
.ui-sidebar-actions {
position: sticky;
bottom: calc(-1 * var(--spacing-16));
z-index: 5;
display: flex;
gap: var(--spacing-8);
padding: var(--spacing-8) 0 var(--spacing-16);
background: var(--color-surface-raised);
border-top: 1px solid var(--color-border);
}
.ui-sidebar-actions > * {
flex: 1 1 0;
min-width: 0;
}
+5 -3
View File
@@ -22,8 +22,8 @@ export interface PanelResizerOptions {
cssVar: string;
/** 손잡이가 패널의 위/왼쪽에 있으면 -1 — 끌어올리거나 왼쪽으로 끌면 커진다. */
direction: 1 | -1;
/** 최소 크기(px) */
min: number;
/** 최소 크기(px). 함수면 끌 때마다 다시 계산한다(열린 서브패널 최소높이 합 같은 동적 하한). */
min: number | (() => number);
/** 최대 크기(px). 함수면 끌 때마다 다시 계산한다(부모의 70% 같은 상대 상한). */
max: number | (() => number);
/** 세션 저장 키. 생략하면 저장하지 않는다. */
@@ -43,15 +43,17 @@ export interface PanelResizer {
}
export function createPanelResizer(options: PanelResizerOptions): PanelResizer {
const { axis, target, cssVar, direction, min, storageKey, onResize } = options;
const { axis, target, cssVar, direction, storageKey, onResize } = options;
const root = document.createElement("div");
root.className = `ui-resizer ui-resizer--${axis}`;
root.setAttribute("role", "separator");
root.setAttribute("aria-orientation", axis === "vertical" ? "horizontal" : "vertical");
const minOf = (): number => (typeof options.min === "function" ? options.min() : options.min);
const maxOf = (): number => (typeof options.max === "function" ? options.max() : options.max);
const clamp = (size: number): number => {
const min = minOf();
const max = Math.max(min, maxOf());
return Math.min(max, Math.max(min, size));
};