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