diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 0fb5f934..9bc20070 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -259,33 +259,66 @@ export function createFacilityOptionsForm( const fordCount = numberInput("1", "1", "련"); const fordRow = grid(labeled("수량 (련)", fordCount)); - // ── 물넘이·세월교 개략 단면 — 지정할 옵션이 거의 없으니 계산값이라도 보여 준다 - // (2026-08-17 사용자 지시). 폭만 받고 설계유량으로 수심·통수능을 되짚는다. + // ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시). + // 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음). + // 높이는 설계유량·폭으로 되짚은 필요 수심을 자동으로 채우고, 사용자가 키울 수는 + // 있으나 계산값(필요 최소 수심) 미만은 되돌린다. const fordWidth = numberInput("0.1"); - const fordWidthRow = grid(labeled("월류 폭 (m)", fordWidth)); + const fordHeight = numberInput("0.01"); + const fordWidthRow = grid( + labeled("월류 폭 (m)", fordWidth), + labeled("월류 높이 (m)", fordHeight), + ); const fordSummary = document.createElement("p"); fordSummary.className = "b05-drainage__facility-note"; /** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */ let designFlowM3s: number | null = null; + /** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */ + let fordMinDepthM: number | null = null; function syncFordSummary(): void { + const section = + designFlowM3s !== null + ? fordSection(designFlowM3s, Number.parseFloat(fordWidth.value)) + : null; + fordMinDepthM = section ? section.depthM : null; + if (section) { + // 표시 정밀도(0.01m)로 맞춘 최소값 — 비었거나 그보다 작으면 계산값으로 채운다. + const min = Number(section.depthM.toFixed(2)); + const current = Number.parseFloat(fordHeight.value); + if (!Number.isFinite(current) || current < min) fordHeight.value = min.toFixed(2); + } if (designFlowM3s === null) { fordSummary.textContent = "담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다."; return; } - const section = fordSection(designFlowM3s, Number.parseFloat(fordWidth.value)); const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`; if (!section) { fordSummary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`; return; } - // 필요 최소 수심이다 — 실제 설계 수심은 여기에 여유를 더해 정한다. + // 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다. fordSummary.textContent = `${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` + - `필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s`; + `필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` + + `월류 높이는 필요 수심 이상만 입력됩니다.`; } + // 계산값 미만은 입력을 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다. + fordHeight.addEventListener("change", () => { + if (fordMinDepthM !== null) { + const min = Number(fordMinDepthM.toFixed(2)); + const value = Number.parseFloat(fordHeight.value); + if (!Number.isFinite(value) || value < min) { + fordHeight.value = min.toFixed(2); + fordHeight.classList.add("is-invalid"); + window.setTimeout(() => fordHeight.classList.remove("is-invalid"), 900); + } + } + emit(); + }); + root.append( pipeRow, inletGroup.root, @@ -337,6 +370,12 @@ export function createFacilityOptionsForm( } } + /** 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림). */ + function putFordHeight(options: Record): void { + const value = Number.parseFloat(fordHeight.value); + if (Number.isFinite(value) && value > 0) options.ford_height_m = Number(value.toFixed(2)); + } + function emit(): void { syncVisibility(); callbacks.onChange?.(); @@ -404,6 +443,11 @@ export function createFacilityOptionsForm( wingOutFields.write(options); fordCount.value = isFord ? text("pipe_count") : ""; fordWidth.value = text("ford_width_m"); + fordHeight.value = text("ford_height_m"); + // 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정). + if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) { + fordWidth.value = facility === "ford_bridge" ? "10" : "5"; + } syncVisibility(); }, readOptions() { @@ -430,12 +474,14 @@ export function createFacilityOptionsForm( wingOutFields.read(options); } else if (current === "ford_pavement") { putNumber(options, "ford_width_m", fordWidth.value); + putFordHeight(options); } else if (current === "ford_bridge") { options.pipe_kind = pipeMaterial.value; options.pipe_diameter_mm = Number(pipeDiameter.value); const count = Number.parseInt(fordCount.value, 10); if (Number.isFinite(count) && count > 0) options.pipe_count = count; putNumber(options, "ford_width_m", fordWidth.value); + putFordHeight(options); } return options; }, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index bb9fb791..a8a41a1b 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -118,6 +118,9 @@ export interface DrainagePanelCallbacks { onPipeSelected?: (chainageM: number | null) => void; /** 배수유역도 우클릭으로 배관 외 구조물을 넣을 때(누가거리는 계획선 투영값). */ onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void; + /** 배수유역도 우클릭 [배관 추가] — 즉시 추가 대신 사이드 폼에 측점을 실어 준다 + * (2026-08-18 사용자 지시). */ + onPipeAddRequest?: (chainageM: number) => void; } export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel { @@ -530,8 +533,13 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra } } - bindPipeContextMenu(viewport, contextMenu, pipeEditor, currentView, (chainage, type) => - callbacks.onStructureAdd?.(chainage, type), + bindPipeContextMenu( + viewport, + contextMenu, + pipeEditor, + currentView, + (chainage, type) => callbacks.onStructureAdd?.(chainage, type), + callbacks.onPipeAddRequest ? (chainage) => callbacks.onPipeAddRequest?.(chainage) : undefined, ); bindDrainageInteractions({ diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 63cc86be..0d1d7dd5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -200,6 +200,9 @@ export function bindPipeContextMenu( editor: PipeEditor, viewOf: () => ViewState, onAddStructure?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void, + /** [배관 추가] 위임 — 즉시 추가 대신 사이드 폼에 측점을 실어 준다(2026-08-18). + * 없으면 옛 동작(즉시 추가)으로 돌아간다. */ + onAddPipe?: (chainageM: number) => void, ): void { viewport.addEventListener("contextmenu", (event) => { if (menu.contains(event.target)) { @@ -230,7 +233,14 @@ export function bindPipeContextMenu( // 배관 + 배관 외 구조물(기성막이/대피로/기타) 추가(2026-08-05 사용자 지시). const chainage = editor.chainageAt(view, x, y); menu.open(x, y, [ - [L("B05_Drainage_Menu_Add"), () => void editor.addAt(view, x, y)], + [ + L("B05_Drainage_Menu_Add"), + () => { + // 즉시 추가 폐지(2026-08-18) — 사이드 폼 경유. 위임이 없을 때만 옛 동작. + if (onAddPipe && chainage !== null) onAddPipe(chainage); + else void editor.addAt(view, x, y); + }, + ], ...(onAddStructure && chainage !== null ? (["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [ `${type === "기타" ? "기타 구조물" : type} 추가`, diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 601fd1bf..bccc6121 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -111,7 +111,9 @@ export async function renderB05Route(root: HTMLElement): Promise { onStructureRemove: (station) => { if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m); }, - onPipeAdd: (chainage) => profilePanel.drainage.addPipe(chainage), + // 우클릭 [배관 추가] — 즉시 추가 대신 사이드 폼에 종류·측점을 실어 주고, + // A군 임시 배치(세부유역 미리보기)로 이어진다. [추가]로 확정(2026-08-18). + onPipeAdd: (chainage) => panel.structures.addAt(chainage, "pipe"), // 우클릭 메뉴의 구 항목(기성막이·대피로·기타) — 구조물 정본 타입으로 넣는다. onStructureAdd: (chainage, type) => panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), @@ -252,9 +254,13 @@ export async function renderB05Route(root: HTMLElement): Promise { if (selectionSyncing) return; selectionSyncing = true; try { - const station = bridge - .irregularStations() - .find((entry) => Math.abs(entry.chainage_m - chainage) < 0.05); + // null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18). + const station = + chainage === null + ? undefined + : bridge + .irregularStations() + .find((entry) => Math.abs(entry.chainage_m - chainage) < 0.05); const id = station ? irregularStationId(station.id) : null; viewer.markers.selectStation(id); profilePanel.setSelectedStation(id); diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index e0c9b812..3a35226a 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -33,6 +33,9 @@ export interface RoutePanelValues { * 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다. * 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다. */ +/** 측점 간격(m) — 20m 고정(2026-08-18 사용자 확정, 입력 UI 삭제). */ +const STATION_INTERVAL_M = 20; + const PROFILE_CRITERIA: Record< RoutePanelValues["gradeClass"], { speed: number; grade: Record; radius: number } @@ -77,7 +80,8 @@ interface PanelCallbacks { attributes: FacilityAttributes, ) => void; onPipeFacilityRemove: (chainageM: number) => void; - onPipeFacilitySelect: (chainageM: number) => void; + /** null = 사이드 목록 재클릭 해제 — 전 화면 선택도 함께 푼다(2026-08-18). */ + onPipeFacilitySelect: (chainageM: number | null) => void; /** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */ onStationDisplayChange: (offset: { station: number; cumulative: number }) => void; } @@ -192,8 +196,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { button("뷰 초기화", callbacks.onResetView, "glass"), ); - const contour = section("등고선 간격"); - contour.root.classList.add("is-collapsed"); + // 등고선 간격 행 — 「페이지 설정」 섹션에 들어간다(2026-08-18 컨테이너 병합). const contourInterval = numberField("간격 (m), 최소 0.5m", "1"); const contourRow = document.createElement("div"); contourRow.className = "b05-route__contour-row"; @@ -201,7 +204,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) { contourInterval.wrapper, button("재적용", () => callbacks.onContourApply(Number(contourInterval.value) || 1)), ); - contour.body.append(contourRow); // 포인트 팔레트 + 임도 기준·옵션을 한 컨테이너로 병합하고 [최적 경로 계산]도 이 안에 // 둔다 — 경로 재탐색은 B04~B06 재계산을 부르는 무거운 작업이라 가끔만 쓴다 @@ -286,20 +288,13 @@ export function createRoutePanel(callbacks: PanelCallbacks) { solveButton, ); - const sectionOptions = section(L("B05_Route_Group_SectionOptions")); + // 「페이지 설정」 — 등고선 간격·시작 측점 및 샘플링·종단 설계 기준 세 섹션 병합 + // (2026-08-18 사용자 확정). 측점 간격 입력은 삭제 — 20m 고정. + const sectionOptions = section("페이지 설정"); sectionOptions.root.classList.add("is-collapsed"); - const stationInterval = numberField(L("B05_Route_Field_StationInterval")); const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample")); const longSampleInterval = numberField(L("B05_Route_Field_LongSample")); - sectionOptions.body.append( - stationInterval.wrapper, - crossSampleInterval.wrapper, - longSampleInterval.wrapper, - ); - - // 종단 설계 기준은 한 번 정하면 자주 손대지 않는다 — 기본은 접힌 상태 - // (2026-08-17 사용자 지시). - const gradeLine = section("종단 설계 기준", true); + sectionOptions.body.append(crossSampleInterval.wrapper, longSampleInterval.wrapper); const terrainField = createSelectField({ label: "지형 구분", options: [ @@ -328,7 +323,18 @@ export function createRoutePanel(callbacks: PanelCallbacks) { startElevationOffset.wrapper, endElevationOffset.wrapper, ); - gradeLine.body.append(terrainField.root, criteriaNote, gradeAdvanced); + // 병합 구획 구분선 — 샘플링 / 등고선 / 종단 설계 기준. + const settingsDivider1 = document.createElement("hr"); + settingsDivider1.className = "b05-structure__divider"; + const settingsDivider2 = settingsDivider1.cloneNode() as HTMLHRElement; + sectionOptions.body.append( + settingsDivider1, + contourRow, + settingsDivider2, + terrainField.root, + criteriaNote, + gradeAdvanced, + ); // 공사 시작 기준 — 이전 공사에 이어 시공할 때 0측점을 임의 측점/누가거리로 시작 표기한다. // (내부 chainage는 0기준 유지, 측점 라벨·누가거리 "표시"만 이 값만큼 이동.) 기본값 0/0. @@ -359,7 +365,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { const structures = createStructuresSection({ onChange: callbacks.onStructuresChange, onSelect: callbacks.onStructureSelect, - getInterval: () => Number(stationInterval.value) || 20, + getInterval: () => STATION_INTERVAL_M, onPipeAdd: callbacks.onPipeFacilityAdd, onPipeUpdate: callbacks.onPipeFacilityUpdate, onPipeRemove: callbacks.onPipeFacilityRemove, @@ -409,7 +415,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) { maxDownhillGrade, minUphillGrade, minDownhillGrade, - stationInterval, crossSampleInterval, longSampleInterval, terrainType, @@ -420,15 +425,16 @@ export function createRoutePanel(callbacks: PanelCallbacks) { endElevationOffset, ]; inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange)); - root.append( - gradeLine.root, - contour.root, - sectionOptions.root, - structures.root, - routeCalc.root, - selected.root, - actionDock, - ); + + // 경로 계산 설정 — 사용여부 미결정으로 조작 불가(숨기지 않는다, 2026-08-18 사용자 + // 지시). inert가 본문 전체 입력·드래그를 막고, is-disabled가 흐림을 그린다. + // 제목 클릭(접기/펼치기)은 본문 밖이라 그대로 산다. + routeCalc.root.classList.add("is-disabled"); + routeCalc.body.inert = true; + + // 배치 순서: 구조물 배치 > 페이지 설정 > 경로 계산 설정(비활성) > 선택 포인트 + // (평소 숨김) > 하단 고정 dock (2026-08-18 사용자 확정). + root.append(structures.root, sectionOptions.root, routeCalc.root, selected.root, actionDock); // 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도 // 접힘 항목은 손대지 않는다. @@ -453,7 +459,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { minUphillGrade: parseOptional(minUphillGrade), minDownhillGrade: parseOptional(minDownhillGrade), allowAvoidPassThrough: avoidPass.checked, - stationInterval: parseOptional(stationInterval), + stationInterval: STATION_INTERVAL_M, crossSampleInterval: parseOptional(crossSampleInterval), longSampleInterval: parseOptional(longSampleInterval), terrainType: terrainType.value as RoutePanelValues["terrainType"], @@ -475,7 +481,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { if (values.minUphillGrade != null) minUphillGrade.value = String(values.minUphillGrade); if (values.minDownhillGrade != null) minDownhillGrade.value = String(values.minDownhillGrade); if (values.allowAvoidPassThrough != null) avoidPass.checked = values.allowAvoidPassThrough; - if (values.stationInterval != null) stationInterval.value = String(values.stationInterval); + // stationInterval은 복원하지 않는다 — 20m 고정(2026-08-18). if (values.crossSampleInterval != null) crossSampleInterval.value = String(values.crossSampleInterval); if (values.longSampleInterval != null) diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index eb89a691..bd17c99b 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -182,6 +182,8 @@ export function createRouteProfilePanel( onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM), onPipeSelected: (chainageM) => callbacks?.onPipeSelected?.(chainageM), onStructureAdd: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type), + // 배수유역도 [배관 추가]도 사이드 폼 경유(2026-08-18) — 테이블 우클릭과 같은 경로. + onPipeAddRequest: (chainage) => callbacks?.onPipeAdd?.(chainage), }); content.append(bodyWrap, drainagePanel.root); // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 2eba70cf..3437fff4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -99,8 +99,9 @@ interface StructuresCallbacks { ) => void; /** 계곡 통과 시설 삭제. */ onPipeRemove: (chainageM: number) => void; - /** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫). */ - onPipeSelect: (chainageM: number) => void; + /** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫). + * null = 재클릭 해제 — 전 화면(그래프·3D·배수유역도) 선택도 함께 푼다(2026-08-18). */ + onPipeSelect: (chainageM: number | null) => void; } export function createStructuresSection(callbacks: StructuresCallbacks): StructuresSection { @@ -187,6 +188,10 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu let editingId: string | null = null; /** 목록에서 고른 계곡 통과 시설(누가거리 키). 삭제 버튼이 이쪽으로 동작한다. */ let selectedPipeChainage: number | null = null; + /** 임시 배치 중인 A군 시설의 누가거리(2026-08-18) — 측점을 넣는 순간 관 지점에 + * 미리 넣어 세부유역·설계유량 미리보기를 얻고, [추가]로만 확정한다. 취소(삭제· + * 리셋·다른 항목 선택·종류 변경)하면 관을 물려 추가 직전 상태로 되돌린다. */ + let tempPipeChainage: number | null = null; let optionInputs: Array<{ key: string; required: boolean; @@ -232,6 +237,54 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu designFlow: number | null = null, ): void { facilityOptions.setFacility(facilityFormKind(currentType()), options, designFlow); + syncOptionLock(); + } + + /** 위치(측점)가 채워졌는지 — 상세 옵션을 여는 조건(2026-08-18 입력 순서 가이드). */ + function hasPosition(): boolean { + const type = currentType(); + if (!type) return false; + const step = interval(); + if (!type.managed_by && type.placement === "interval") { + return startFields.read(step, false) !== null && endFields.read(step, false) !== null; + } + return anchorFields.read(step, false) !== null; + } + + /** 측점 미입력 상태에서는 상세 옵션(레지스트리 옵션 칸·부속 서브폼)을 잠근다 — + * 입력 순서 가이드(2026-08-18 사용자 지시). 자동 배치 시설은 측점을 갖고 폼에 + * 올라오므로 항상 열려 있다. */ + function syncOptionLock(): void { + const locked = !hasPosition(); + optionInputs.forEach((entry) => { + entry.input.disabled = locked; + }); + optionRow.classList.toggle("is-locked", locked); + facilityOptions.root.inert = locked; + facilityOptions.root.classList.toggle("is-locked", locked); + } + + /** 측점 입력 변경 — A군이면 임시 배치를 만들거나 옮기고(세부유역 미리보기, + * 2026-08-18), 어느 군이든 옵션 잠금을 다시 판정한다. */ + function handleStationInput(): void { + const type = currentType(); + if (type?.managed_by) { + const anchor = anchorFields.read(interval(), false); + // 기존 관(임시 아님)을 수정 중일 때 기준점 이동은 [수정]으로만 반영한다. + if (anchor !== null && (selectedPipeChainage === null || tempPipeChainage !== null)) { + const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility }; + if (tempPipeChainage === null) { + tempPipeChainage = anchor; + callbacks.onPipeAdd(anchor, attributes); + } else if (Math.abs(tempPipeChainage - anchor) > 0.005) { + const from = tempPipeChainage; + tempPipeChainage = anchor; + callbacks.onPipeUpdate(from, anchor, attributes); + } + syncButtons(); + } + } + syncOptionLock(); } /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07 @@ -244,6 +297,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const visible = type && !facilityFormKind(type) ? type.options.filter(isB05Option) : []; if (!visible.length) { optionRow.hidden = true; + syncOptionLock(); return; } optionRow.hidden = false; @@ -275,6 +329,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu isEmpty: () => input.value.trim() === "", }); }); + syncOptionLock(); } /** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도 @@ -294,9 +349,38 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu syncFacilityForm(); } + /** 임시 배치를 물리고 추가 직전 상태로 되돌린다(관 제거 → 세부유역 재분할). */ + function cancelTempPipe(): void { + if (tempPipeChainage === null) return; + const chainage = tempPipeChainage; + tempPipeChainage = null; + if (selectedPipeChainage !== null && Math.abs(selectedPipeChainage - chainage) < 0.05) { + selectedPipeChainage = null; + } + callbacks.onPipeRemove(chainage); + } + + /** 폼 전체 초기화 — 리셋 버튼과 선택 해제가 같은 상태를 공유한다(2026-08-18 + * 사용자 지시: 해제 시 구조물군·종류·측점까지 리셋과 동일하게 비운다). */ + function resetForm(): void { + const hadPipe = selectedPipeChainage !== null || tempPipeChainage !== null; + cancelTempPipe(); + groupSelect.value = ""; + syncTypeOptions(); + loadForm(null); + // 시설이 골라져 있었으면 전 화면(그래프·3D·배수유역도) 선택도 함께 푼다. + if (hadPipe) callbacks.onPipeSelect(null); + } + function syncButtons(): void { - primary.textContent = editingId || selectedPipeChainage !== null ? "수정" : "추가"; - removeButton.disabled = editingId === null && selectedPipeChainage === null; + primary.textContent = + tempPipeChainage !== null + ? "추가" + : editingId || selectedPipeChainage !== null + ? "수정" + : "추가"; + removeButton.disabled = + editingId === null && selectedPipeChainage === null && tempPipeChainage === null; } function renderList(): void { @@ -308,16 +392,27 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu intervalM: interval(), editingId, selectedPipeChainage, - onSelectStructure: (structure) => - loadForm(structure.structure_id === editingId ? null : structure), + // 재선택 = 해제(2026-08-18 복구) — 해제는 리셋과 같은 전체 초기화다. + onSelectStructure: (structure) => { + if (structure.structure_id !== null && structure.structure_id === editingId) resetForm(); + else loadForm(structure); + }, onSelectPipe: (pipe) => { - loadPipeForm(pipe); - callbacks.onPipeSelect(pipe.chainage_m); + const isSame = + selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05; + if (isSame) { + resetForm(); + } else { + loadPipeForm(pipe); + callbacks.onPipeSelect(pipe.chainage_m); + } }, }); } function loadForm(target: StructureInstance | null): void { + // 다른 항목으로 넘어가면 임시 배치(A군 미리보기)는 취소된다. + cancelTempPipe(); editingId = target?.structure_id ?? null; selectedPipeChainage = null; const step = interval(); @@ -351,6 +446,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu /** 계곡 통과 시설을 폼에 올린다 — 일반 구조물과 같은 흐름(종류·측점·옵션·수정). * 정본이 관 지점이라 editingId 대신 선택 누가거리로 추적한다. */ function loadPipeForm(pipe: PipeFacilityItem): void { + // 임시 배치 중인 그 관이 재계산을 거쳐 되돌아온 경우가 아니면, 다른 항목으로 + // 넘어가는 것이므로 임시 배치를 취소한다. + const isTemp = tempPipeChainage !== null && Math.abs(pipe.chainage_m - tempPipeChainage) < 0.05; + if (isTemp) tempPipeChainage = pipe.chainage_m; + else cancelTempPipe(); const hadStructure = editingId !== null; editingId = null; selectedPipeChainage = pipe.chainage_m; @@ -407,7 +507,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility }; const options = facilityOptions.readOptions(); if (Object.keys(options).length) attributes.options = options; - if (selectedPipeChainage !== null) { + if (tempPipeChainage !== null) { + // 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영. + const from = tempPipeChainage; + tempPipeChainage = null; + callbacks.onPipeUpdate(from, anchor, attributes); + } else if (selectedPipeChainage !== null) { callbacks.onPipeUpdate(selectedPipeChainage, anchor, attributes); } else { callbacks.onPipeAdd(anchor, attributes); @@ -474,6 +579,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu } groupSelect.addEventListener("change", () => { + cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18). syncTypeOptions(); // 종류가 바뀌면 기존 항목 수정이 아니라 새 항목 추가로 넘어간다(옵션 스키마가 달라진다). if (editingId) { @@ -484,6 +590,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu } }); typeSelect.addEventListener("change", () => { + cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18). syncPlacementFields(); renderOptionFields(); // 배수관·BOX암거·세월교·독립 기슭막이는 부속 옵션 서브폼이 따라 열려야 한다 — @@ -499,6 +606,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu }); primary.addEventListener("click", commit); removeButton.addEventListener("click", () => { + // 임시 배치 중이면 취소와 같다 — 관을 물리고 추가 직전 상태로(2026-08-18). + if (tempPipeChainage !== null) { + resetForm(); + return; + } // 계곡 통과 시설이 골라져 있으면 관 지점 정본에서 지운다(배수유역 패널 경유). if (selectedPipeChainage !== null) { callbacks.onPipeRemove(selectedPipeChainage); @@ -513,12 +625,15 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu loadForm(null); emit(); }); - resetButton.addEventListener("click", () => { - // 리셋은 폼만 아니라 구조물군·종류도 빈칸으로 되돌린다(2026-08-17 사용자 지시). - groupSelect.value = ""; - syncTypeOptions(); - loadForm(null); - }); + // 리셋은 폼만 아니라 구조물군·종류도 빈칸으로 되돌린다(2026-08-17 사용자 지시). + // 임시 배치 취소도 겸한다(2026-08-18). + resetButton.addEventListener("click", resetForm); + // 측점 입력 감시 — 옵션 잠금 해제 판정 + A군 임시 배치 생성·이동(2026-08-18). + [anchorFields, startFields, endFields].forEach((fields) => + [fields.station, fields.remainder].forEach((input) => + input.addEventListener("change", handleStationInput), + ), + ); body.insertBefore(field("메모", memoField), actions); syncButtons(); @@ -548,6 +663,14 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu getStructures: () => [...structures], setPipeFacilities(pipes) { pipeFacilities = pipes.map((pipe) => ({ ...pipe })); + if ( + tempPipeChainage !== null && + !pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - tempPipeChainage!) < 0.05) + ) { + // 임시 배치 관이 재계산에서 사라졌다 — 임시 상태만 정리(관은 이미 없다). + tempPipeChainage = null; + syncButtons(); + } if ( selectedPipeChainage !== null && !pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < 0.05) @@ -561,6 +684,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu }, selectPipeByChainage(chainageM) { if (chainageM === null) { + // 임시 배치 중 외부 해제(다른 곳 클릭) = 취소 — 추가 직전 상태로(2026-08-18). + if (tempPipeChainage !== null) { + resetForm(); + return; + } if (selectedPipeChainage === null) return; selectedPipeChainage = null; facilityOptions.setFacility(null); @@ -584,58 +712,34 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu addAt(chainageM, typeId) { const type = typeMap().get(typeId); if (!type) return; - // 계곡 통과 시설은 관 지점 정본으로 바로 보낸다(옵션은 추가 후 폼에서). - // BOX암거만 예외로 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 - // 함께 싣는다 — 폼을 열지 않고 넣어도 하류가 제원을 받는다(2026-08-17 사용자 - // 확정). 배관·물넘이·세월교는 부속을 설계자가 고를 몫이라 빈 채로 둔다. - if (type.managed_by) { - const attributes: FacilityAttributes = { facility: typeId as PipeFacility }; - if (typeId === "box_culvert") attributes.options = defaultOptions(type); - callbacks.onPipeAdd(chainageM, attributes); - return; - } + // 우클릭 추가 = 즉시 추가 폐지(2026-08-18 사용자 지시 — 옵션 확대로 바로 넣을 + // 수 없는 타입이 생겼다). 폼에 종류·측점을 자동으로 실어 옵션을 활성화하고, + // [추가]로 확정한다. A군은 측점이 실리는 순간 임시 배치(세부유역 미리보기)까지 + // 자동으로 진행된다. + cancelTempPipe(); + groupSelect.value = type.group; + syncTypeOptions(typeId); + typeSelect.value = typeId; + editingId = null; + selectedPipeChainage = null; const placement = placementOf(typeId); const step = interval(); - - // B05 단계 필수 옵션이 있는 타입만 폼을 연다 — 상세(detail) 필수는 B06/B07 - // 몫이라 바로 추가해도 서버가 받는다(2026-08-17 phase 분리). - if (type.options.some((option) => option.required && isB05Option(option))) { - groupSelect.value = type.group; - syncTypeOptions(typeId); - typeSelect.value = typeId; - editingId = null; - anchorFields.write(chainageM, step); - startFields.write(placement === "interval" ? chainageM : null, step); - endFields.write( - placement === "interval" ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, - step, - ); - memoField.value = ""; - syncPlacementFields(); - renderOptionFields(); - syncButtons(); - renderList(); - root.scrollIntoView({ block: "nearest" }); - const firstRequired = optionInputs.find((entry) => entry.required); - (firstRequired?.input ?? anchorFields.station).focus(); - return; - } - - structures.push({ - structure_id: null, - type_id: typeId, - placement, - chainage_m: chainageM, - start_m: placement === "interval" ? chainageM : null, - end_m: placement === "interval" ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, - options: defaultOptions(type), - memo: "", - placement_source: "manual", - status: "draft", - revision: 0, - geometry: null, - }); - emit(); + const isInterval = !type.managed_by && placement === "interval"; + anchorFields.write(chainageM, step); + startFields.write(isInterval ? chainageM : null, step); + endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step); + memoField.value = ""; + syncPlacementFields(); + renderOptionFields(type.managed_by ? undefined : defaultOptions(type)); + // BOX암거는 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 실어 폼을 + // 연다 — [추가]만 눌러도 하류가 제원을 받는다(2026-08-17 사용자 확정 유지). + syncFacilityForm(typeId === "box_culvert" ? defaultOptions(type) : {}); + if (type.managed_by) handleStationInput(); + syncButtons(); + renderList(); + root.scrollIntoView({ block: "nearest" }); + const firstRequired = optionInputs.find((entry) => entry.required); + (firstRequired?.input ?? anchorFields.station).focus(); }, moveById(structureId, toChainageM) { const index = structures.findIndex((entry) => entry.structure_id === structureId); diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index 7ce6c58b..e34ada3b 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -233,6 +233,18 @@ } /* 제목 행 클릭 접기/펼치기는 공용 collapsible(ui_template_theme.css)에서 처리한다. */ +/* 사용여부 미결정 컨테이너(경로 계산 설정) — 숨기지 않고 "조작 안 되는 항목"임이 + 보이게 글자·배경을 가라앉힌다(2026-08-18 사용자 지시). 입력 차단은 본문 inert가 + 맡고, 여기서는 시각만 담당한다. 제목(접기)은 살아 있으므로 흐림에서 제외하지 + 않는다 — 컨테이너 전체가 한 덩어리로 비활성으로 읽혀야 한다. */ +.b05-route__panel-section.is-disabled { + background: color-mix(in srgb, var(--color-surface-raised) 60%, var(--color-canvas)); +} + +.b05-route__panel-section.is-disabled > * { + opacity: 0.45; +} + .b05-route__panel-section h3 { margin: 0; color: var(--color-text); diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 9e592eaa..72e9c2af 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -165,6 +165,13 @@ min-width: 0; } +/* 측점 미입력 상태의 상세 옵션 잠금 — 입력 순서 가이드(2026-08-18 사용자 지시). + 조작 차단은 disabled·inert가 맡고 여기서는 흐림만 그린다. */ +.b05-structure__grid.is-locked, +.b05-drainage__facility.is-locked { + opacity: 0.5; +} + /* 측점 그룹과 그 아래 옵션 나열을 가르는 선(2026-08-17 사용자 지시 2). */ .b05-structure__divider { width: 100%;