From 69aed9d26c9c53e8fd461d216e38c0e1d196a49f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 5 Aug 2026 19:44:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=ED=8F=BC=20=EB=B9=88=EC=B9=B8=20=EC=83=81=ED=83=9C=C2=B7?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=20=EC=95=A1=EC=85=98=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=C2=B7=ED=95=98=EB=8B=A8=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EC=BA=90=EC=8A=A4=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구조물 종류 드롭다운에 빈칸 항목 — 초기·리셋 상태는 빈칸이고 하위 옵션 전체 숨김, 미선택이면 추가 불가(2026-08-05 사용자 지시) - 사이드 패널 최하단 고정 액션 줄 공용 클래스(ui-sidebar-actions): 계산·확정 버튼이 스크롤에서 제외되고 불투명 배경이 지나가는 내용을 가림. B04(확정·초기화)/B05(계산·확정)/B06(재계산·저장·확정) 적용, B07은 기존 자체 sticky 유지 - 하단 패널 높이 캐스케이드: 아래로 끌면 종단 먼저 최소(100px)까지 → 테이블·유토곡선이 같이 비례 축소(각자 최소 140/160px, 인라인 임시 축소라 다시 키우면 원래 높이 복귀) → 셋 다 최소면 리사이저 동적 하한이 차단. 공용 리사이저 min을 함수 지원으로 확장. 반대로 서브패널 펼침으로 부족하면 메인 패널을 키움(기존 로직 유지) Co-Authored-By: Claude Fable 5 --- B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 14 ++--- .../B05_wf2_Route_UI_IrregularStations.ts | 22 ++++--- B05_wf2_Route/B05_wf2_Route_UI_Panel.ts | 3 +- .../B05_wf2_Route_UI_Profile_MassHaul.ts | 2 + .../B05_wf2_Route_UI_Profile_Panel.ts | 58 ++++++++++++++++--- .../B05_wf2_Route_UI_Profile_TableOverlay.ts | 2 + .../B06_wf3_ProfileCross_UI_Page.ts | 3 +- .../B07_wf4_DesignDetail_UI_Page.ts | 2 + ui_template/ui_template_overlay.css | 19 ++++++ ui_template/ui_template_resizer.ts | 8 ++- 10 files changed, 104 insertions(+), 29 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index b0b47aa5..ca84f234 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -192,14 +192,12 @@ export async function renderB04Surface(root: HTMLElement): Promise { 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"; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts index 51434aca..6647bcb5 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts @@ -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 = { structureType }; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts index e4cf789d..2cf7d21e 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts @@ -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 = [ 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 455b35fa..333433a0 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts @@ -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; 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 aab5d9f4..61a1f32c 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -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) { 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 8dd8c06f..6725aa3d 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_TableOverlay.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_TableOverlay.ts @@ -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 { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 3df7c464..ecd1aabb 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -113,7 +113,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { }); 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"); diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts index 9f413cd8..e6c6640a 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts @@ -509,6 +509,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { ); 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); diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index d81a1c42..5f77ce32 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -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; +} diff --git a/ui_template/ui_template_resizer.ts b/ui_template/ui_template_resizer.ts index e216ff5d..cd788b09 100644 --- a/ui_template/ui_template_resizer.ts +++ b/ui_template/ui_template_resizer.ts @@ -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)); };