From 77f833bb25e462072ae9269d71c240a40eb3c00d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 17 Aug 2026 18:19:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EB=B3=B4=ED=98=B8=EA=B3=B5=20?= =?UTF-8?q?=EA=B0=9C=ED=8E=B8=C2=B7=EC=A2=85=EB=8B=A8=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=EC=A0=91=ED=9E=98=C2=B7700=EC=A4=84=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=B6=84=EB=A6=AC=203=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지시 1 — 사이드 「종단 설계 기준」 컨테이너를 기본 접힘으로. section()에 collapsed 인자를 더해 초기 is-collapsed만 붙이고 토글은 전역 collapsible이 그대로 맡는다(B06 buildGroup과 같은 패턴). 지시 4 — 배수관 유입·유출 바닥 보호를 "보호공" 한 축으로 개편: - 구 돌붙임 있음/없음 + 표면처리(찰/메) 두 축 → 돌붙임(찰)/돌붙임(메)/도수로. 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 이 선택지로 받는다(별표2 유출구~원지반 도달 의무) - "없음" 삭제 — 물이 흐르는 자리라 보호공은 필수(사용자 확정). 기본값은 유입 돌붙임(찰)·유출 돌붙임(메) - 치수는 배타 — 돌붙임이면 면적(㎡, 기본 10), 도수로면 폭(m, 기본 1.0) - 저장 키 개명 *_pitching → *_protection, *_pitching_finish 삭제, *_protection_width_m 신설. 독립 기슭막이(D4)도 같은 축 - 구 저장분은 parse_pipe_points가 읽는 순간 새 키로 옮긴다("없음"은 부위 기본값으로 승격). 폼 write()도 구 키를 폴백으로 읽어 structures.json 몫을 덮는다 - revetment.pitching_area_m2가 기본값과 required를 동시에 갖던 모순도 정리 지시 5 — 700줄 초과 파일 분리(순수 이동 + import 정리, 공개 인터페이스 불변): - Drainage_Facility 724 → 401 (+_Facility_Fields 354) - Structures_Panel 753 → 695 (+_Structures_List 113) - Drainage_Panel 822 → 693 (+_Drainage_Chrome 150, +_Drainage_Interact 159) - Page 1031 → 911 (+_Page_Helpers 144) — 아직 초과 - Profile_Panel 1152 — 미착수. 두 파일은 가변 클로저 상태를 곳곳에서 직접 읽고 써 상태 승격이 선행돼야 하며, 프론트 테스트가 없어 순수 이동으로는 떼어낼 수 없다. 분리안은 PLAN.md 3-3에 기록 검증: pytest tmp/tests 107 passed·7 skipped(신규 보호공 마이그레이션 6건 포함), npm run typecheck 무오류, npm run build 성공(374 modules). Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Structure_Types.json | 78 ++-- B05_Profile/B05_Profile_UI_Drainage_Chrome.ts | 151 ++++++++ .../B05_Profile_UI_Drainage_Facility.ts | 346 ++--------------- ...B05_Profile_UI_Drainage_Facility_Fields.ts | 354 ++++++++++++++++++ .../B05_Profile_UI_Drainage_Interact.ts | 159 ++++++++ B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 243 +++--------- B05_Profile/B05_Profile_UI_Page.ts | 140 +------ B05_Profile/B05_Profile_UI_Page_Helpers.ts | 144 +++++++ B05_Profile/B05_Profile_UI_Panel.ts | 9 +- B05_Profile/B05_Profile_UI_Structures_List.ts | 112 ++++++ .../B05_Profile_UI_Structures_Panel.ts | 90 +---- common_util/common_util_drainage_pipes.py | 33 +- 12 files changed, 1109 insertions(+), 750 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Drainage_Chrome.ts create mode 100644 B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts create mode 100644 B05_Profile/B05_Profile_UI_Drainage_Interact.ts create mode 100644 B05_Profile/B05_Profile_UI_Page_Helpers.ts create mode 100644 B05_Profile/B05_Profile_UI_Structures_List.ts diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 2c2f300d..9fcddff2 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 docs/raw/PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다.", + "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 docs/raw/PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", "types": [ { "type_id": "pipe", @@ -84,28 +84,28 @@ "phase": "detail" }, { - "key": "inlet_pitching", - "label": "유입 돌붙임", + "key": "inlet_protection", + "label": "유입 보호공", "input": "select", - "choices": ["있음", "없음"], - "default": "있음", + "choices": ["돌붙임(찰)", "돌붙임(메)", "도수로"], + "default": "돌붙임(찰)", "required": false }, { - "key": "inlet_pitching_finish", - "label": "유입 돌붙임 표면처리", - "input": "select", - "choices": ["찰", "메"], - "default": "찰", + "key": "inlet_protection_area_m2", + "label": "유입 보호공 면적", + "input": "number", + "unit": "㎡", + "default": 10, "required": false, "phase": "detail" }, { - "key": "inlet_pitching_area_m2", - "label": "유입 돌붙임 면적", + "key": "inlet_protection_width_m", + "label": "유입 도수로 폭", "input": "number", - "unit": "㎡", - "default": 10, + "unit": "m", + "default": 1, "required": false, "phase": "detail" }, @@ -144,28 +144,28 @@ "phase": "detail" }, { - "key": "outlet_pitching", - "label": "유출 돌붙임", + "key": "outlet_protection", + "label": "유출 보호공", "input": "select", - "choices": ["있음", "없음"], - "default": "있음", + "choices": ["돌붙임(찰)", "돌붙임(메)", "도수로"], + "default": "돌붙임(메)", "required": false }, { - "key": "outlet_pitching_finish", - "label": "유출 돌붙임 표면처리", - "input": "select", - "choices": ["찰", "메"], - "default": "메", + "key": "outlet_protection_area_m2", + "label": "유출 보호공 면적", + "input": "number", + "unit": "㎡", + "default": 10, "required": false, "phase": "detail" }, { - "key": "outlet_pitching_area_m2", - "label": "유출 돌붙임 면적", + "key": "outlet_protection_width_m", + "label": "유출 도수로 폭", "input": "number", - "unit": "㎡", - "default": 10, + "unit": "m", + "default": 1, "required": false, "phase": "detail" } @@ -725,29 +725,29 @@ "phase": "detail" }, { - "key": "pitching", - "label": "돌붙임", + "key": "protection", + "label": "보호공", "input": "select", - "choices": ["있음", "없음"], - "default": "있음", + "choices": ["돌붙임(찰)", "돌붙임(메)", "도수로"], + "default": "돌붙임(메)", "required": false }, { - "key": "pitching_finish", - "label": "돌붙임 표면처리", - "input": "select", - "choices": ["찰", "메"], - "default": "메", + "key": "protection_width_m", + "label": "도수로 폭", + "input": "number", + "unit": "m", + "default": 1, "required": false, "phase": "detail" }, { - "key": "pitching_area_m2", - "label": "돌붙임 면적", + "key": "protection_area_m2", + "label": "보호공 면적", "input": "number", "unit": "㎡", "default": 10, - "required": true, + "required": false, "phase": "detail" } ] diff --git a/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts b/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts new file mode 100644 index 00000000..11263f66 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts @@ -0,0 +1,151 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Chrome.ts + * 배수유역 패널의 DOM 뼈대 — 헤더·버튼·뷰포트·범례·유역 목록·폭 조절 손잡이. + * + * 패널 본체(B05_Profile_UI_Drainage_Panel)가 700줄 한계에 닿아 분리했다. + * 여기에는 상태도 이벤트 배선도 없다 — 요소를 만들어 붙이고 그대로 넘긴다. + * 배선(토글·포인터·그리기)은 전부 본체가 맡는다. + * ========================================================================== */ + +import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { createPanelResizer } from "@ui/ui_template_resizer"; +import { createProgressCircle } from "@ui/ui_template_progress"; +import { createMapContextMenu } from "@ui/ui_template_context_menu"; +import { createFlowLegend } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp"; + +export interface DrainageChrome { + root: HTMLElement; + panelHandle: ReturnType; + layerButtons: HTMLElement; + analyzeButton: HTMLButtonElement; + deleteButton: HTMLButtonElement; + autoButton: HTMLButtonElement; + viewport: HTMLElement; + backgroundImage: HTMLImageElement; + canvas: HTMLCanvasElement; + status: HTMLElement; + progress: ReturnType; + contextMenu: ReturnType; + legend: ReturnType; + summary: HTMLElement; + basinList: HTMLElement; + /** 폭 조절 손잡이 — 패널을 접을 때 본체가 dispose 한다. */ + widthResizer: ReturnType; +} + +export interface DrainageChromeLabels { + title: string; + analyze: string; + analyzeTip: string; + deleteSelected: string; + auto: string; + autoTip: string; + imageAlt: string; + statusNeedRoute: string; +} + +export interface DrainageChromeMetrics { + minWidth: number; + maxWidthRatio: number; + widthStorageKey: string; +} + +export function createDrainageChrome( + labels: DrainageChromeLabels, + metrics: DrainageChromeMetrics, +): DrainageChrome { + const root = document.createElement("aside"); + root.className = "b05-drainage"; + // 이 패널은 하단 패널의 **오른쪽**에 도킹해 오른쪽으로 접힌다(제목 카드와 반대). + // 방향을 넘기지 않으면 화살표가 정확히 거꾸로 나온다. + // 이름표 "배수유역" — 세로(90°)로 화살표 위에 얹힌다(2026-08-04 사용자 지시). + const panelHandle = createWorkflowPanelHandle("side", "right", "배수유역"); + + const header = document.createElement("div"); + header.className = "b05-drainage__header"; + const title = document.createElement("h3"); + title.textContent = labels.title; + const layerButtons = document.createElement("div"); + layerButtons.className = "b05-drainage__layers"; + header.append(title, layerButtons); + + // 세부유역 산정 — B04가 미리 분석해 둔 결과를 읽어 관을 보충하고 세부유역만 나눈다. + // 격자 해석은 하지 않으므로 즉시 끝난다. + const analyzeButton = document.createElement("button"); + analyzeButton.type = "button"; + analyzeButton.className = "b05-drainage__analyze"; + analyzeButton.textContent = labels.analyze; + analyzeButton.title = labels.analyzeTip; + // 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다. + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "b05-drainage__analyze b05-drainage__tool"; + deleteButton.textContent = labels.deleteSelected; + deleteButton.disabled = true; + // 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정. + const autoButton = document.createElement("button"); + autoButton.type = "button"; + autoButton.className = "b05-drainage__analyze b05-drainage__tool"; + autoButton.textContent = labels.auto; + autoButton.title = labels.autoTip; + header.append(analyzeButton, deleteButton, autoButton); + + const viewport = document.createElement("div"); + viewport.className = "b05-drainage__viewport"; + const backgroundImage = document.createElement("img"); + backgroundImage.className = "b05-drainage__image"; + backgroundImage.alt = labels.imageAlt; + backgroundImage.draggable = false; + const canvas = document.createElement("canvas"); + canvas.className = "b05-drainage__canvas"; + const status = document.createElement("span"); + status.className = "b05-drainage__status"; + status.textContent = labels.statusNeedRoute; + // 지도 정중앙 로딩 서클 — 배경도·도엽 레이어·유역 산정이 끝날 때까지 화면이 비어 보인다. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; + // 배관 추가·삭제 우클릭 메뉴 — 편집 토글을 없앤 대신 이쪽으로 옮겼다(B04 지도와 같은 조작). + const contextMenu = createMapContextMenu("b05-drainage"); + // 유입 강도 색띠 범례 — CSS가 지도 우측 세로로 세운다. + const legend = createFlowLegend(); + viewport.append(backgroundImage, canvas, status, progress.root, contextMenu.element, legend.root); + + const summary = document.createElement("div"); + summary.className = "b05-drainage__summary"; + summary.hidden = true; + const basinList = document.createElement("div"); + basinList.className = "b05-drainage__basins"; + basinList.hidden = true; + // 왼쪽 경계를 끌어 폭을 조절한다. 상한은 하단 패널 폭의 70%, + // 세로는 하단 패널에 그대로 딸려 간다(따로 조절하지 않는다 — 사용자 지시). + const widthResizer = createPanelResizer({ + axis: "horizontal", + target: root, + cssVar: "--b05-drainage-width", + direction: -1, + min: metrics.minWidth, + max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * metrics.maxWidthRatio, + storageKey: metrics.widthStorageKey, + }); + + root.append(panelHandle.root, widthResizer.root, header, viewport, summary, basinList); + + return { + root, + panelHandle, + layerButtons, + analyzeButton, + deleteButton, + autoButton, + viewport, + backgroundImage, + canvas, + status, + progress, + contextMenu, + legend, + summary, + basinList, + widthResizer, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index ef4e4a1b..2778649e 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -9,8 +9,13 @@ * * 배관 부속(2026-08-17 UI 개편): 관종·관경(기본 1000) 한 행 + 유입구·유출구 * 소그룹. 유입구는 집수정/기슭막이 택일이고, 유출구는 기슭막이뿐이다(현재 다른 - * 선택지 없음). 기슭막이 조각은 재질·표면처리(찰/메)·길이·높이·돌붙임을 한 벌로 - * 묶어 유입·유출·독립 구조물(D4 기슭막이)이 **같은 인터페이스**를 쓴다. + * 선택지 없음). 기슭막이 조각은 형태·길이·높이·바닥 보호공을 한 벌로 묶어 + * 유입·유출·독립 구조물(D4 기슭막이)이 **같은 인터페이스**를 쓴다. + * + * 보호공(2026-08-17 사용자 지시): 구 "돌붙임 있음/없음 + 표면처리(찰/메)" 두 축을 + * `돌붙임(찰)·돌붙임(메)·도수로` 한 축으로 합쳤다. 도수로·산비탈수로(B4)는 유출부에서 + * 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 이 선택지로 받는다. 치수는 돌붙임이면 + * 면적(㎡), 도수로면 폭(m)이며 둘은 배타다. * 유입·유출 길이가 정해지면 위치 칸(시작·종료 측점)을 기준 − 유입 ~ 기준 + 유출로 * 자동 채운다. 형식·치수 제원은 B06/B07 필수 승격 대상이라 보이되 비필수다. * @@ -27,6 +32,25 @@ import type { PipeSource, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + BOX_SIZE_CUSTOM, + BOX_SIZE_PRESETS, + createRevetmentFields, + createWingFields, + grid, + group, + INLET_REVET_KEYS, + labeled, + numberInput, + optionalSelect, + OUTLET_REVET_KEYS, + putNumber, + REVET_COMMON_DEFAULTS, + STANDALONE_REVET_KEYS, + WING_IN_KEYS, + WING_OUT_KEYS, +} from "./B05_Profile_UI_Drainage_Facility_Fields"; + /** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을 * 가지면 저장한다. */ export interface FacilityAttributes { @@ -105,318 +129,6 @@ export function createFacilityStore() { export type FacilityStore = ReturnType; -/* ── 폼 조각 ────────────────────────────────────────────────────────────── */ - -function labeled(text: string, input: HTMLElement): HTMLLabelElement { - const wrapper = document.createElement("label"); - wrapper.className = "b05-route__field"; - const caption = document.createElement("span"); - caption.textContent = text; - wrapper.append(caption, input); - return wrapper; -} - -function numberInput(step: string, min = "0", placeholder = ""): HTMLInputElement { - const input = document.createElement("input"); - input.type = "number"; - input.step = step; - input.min = min; - if (placeholder) input.placeholder = placeholder; - return input; -} - -/** 양수만 옵션에 싣는다 — 빈칸·0·음수는 값이 없는 것으로 본다. */ -function putNumber(target: Record, key: string, raw: string): void { - const value = Number.parseFloat(raw); - if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1)); -} - -/** 선택지 셀렉트 — 빈("미지정") 항목을 두지 않는다. 첫 항목이 곧 기본값이고, - * 기본값은 구조물별로 따로 지정한다(2026-08-17 사용자 지시 1). */ -function optionalSelect(_blankLabel: string, choices: ReadonlyArray): HTMLSelectElement { - const element = document.createElement("select"); - element.replaceChildren(...choices.map((choice) => new Option(choice, choice))); - return element; -} - -/** 2열 격자 한 줄(칸이 둘 이하면 그대로, 넘치면 줄바꿈). */ -function grid(...fields: HTMLElement[]): HTMLDivElement { - const div = document.createElement("div"); - div.className = "b05-structure__grid"; - div.append(...fields); - return div; -} - -/** 제목 달린 소그룹 상자(유입구·유출구). */ -function group(title: string): { root: HTMLFieldSetElement; body: HTMLDivElement } { - const root = document.createElement("fieldset"); - root.className = "b05-structure__group"; - const legend = document.createElement("legend"); - legend.textContent = title; - const body = document.createElement("div"); - body.className = "b05-structure__group-body"; - root.append(legend, body); - return { root, body }; -} - -/** 기슭막이 형태 — 재질과 표면처리를 한 축으로 합친 목록(2026-08-17 사용자 지시 1). - * 기존 기슭막이 형식 목록에 돌붙임의 찰·메 구분만 더한 것이다. */ -const REVET_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"]; -/** 돌붙임 표면처리 — 찰(모르타르 채움)/메(건쌓기) (돌쌓기 §1·§2). */ -const STONE_FINISHES = ["찰", "메"]; - -/** 기슭막이 옵션 키 묶음 — 유입·유출·독립 구조물이 같은 조각을 쓰되 키만 다르다. */ -export interface RevetmentKeys { - form: string; - length?: string; - height: string; - pitching: string; - pitchingFinish: string; - pitchingArea: string; -} - -/** 부위별 기본 구성 — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m·높이 2.5m, - * 돌붙임 있음(면적 10㎡)에 표면처리만 유입 찰·유출 메 (2026-08-17 사용자 확정). */ -export interface RevetmentDefaults { - form: string; - length: string; - height: string; - pitching: string; - pitchingFinish: string; - pitchingArea: string; -} - -/** 부위와 무관한 공통 기본 치수 — 길이 10m·높이 2.5m·돌붙임 있음 10㎡. */ -const REVET_COMMON_DEFAULTS = { - length: "10", - height: "2.5", - pitching: "있음", - pitchingArea: "10", -} as const; - -export const INLET_REVET_KEYS: RevetmentKeys = { - form: "inlet_revet_form", - length: "inlet_revet_length_m", - height: "inlet_revet_height_m", - pitching: "inlet_pitching", - pitchingFinish: "inlet_pitching_finish", - pitchingArea: "inlet_pitching_area_m2", -}; - -export const OUTLET_REVET_KEYS: RevetmentKeys = { - form: "outlet_revet_form", - length: "outlet_revet_length_m", - height: "outlet_revet_height_m", - pitching: "outlet_pitching", - pitchingFinish: "outlet_pitching_finish", - pitchingArea: "outlet_pitching_area_m2", -}; - -/** 독립 기슭막이(D4) — 길이는 구간형 측점이 정하므로 길이 칸이 없다. */ -export const STANDALONE_REVET_KEYS: RevetmentKeys = { - form: "form", - height: "height_m", - pitching: "pitching", - pitchingFinish: "pitching_finish", - pitchingArea: "pitching_area_m2", -}; - -export interface RevetmentFields { - /** 형태 칸 — 유입·유출 그룹에서 "구조" 옆 2열째에 놓는다(2026-08-17 지시 3·4). */ - formField: HTMLLabelElement; - /** 형태를 뺀 나머지 행(길이·높이, 돌붙임). */ - rows: HTMLElement[]; - /** 길이 칸(없는 구성이면 null). */ - lengthInput: HTMLInputElement | null; - write: (options: Record) => void; - read: (target: Record) => void; - onChange: (handler: () => void) => void; - syncVisibility: () => void; -} - -/** 기슭막이 한 벌(형태·길이·높이 + 돌붙임) — 배수관 유입·유출부와 독립 구조물이 - * 공유하는 입력 인터페이스(2026-08-17 사용자 지시 9·11·12). 재질과 표면처리는 - * "형태" 한 축으로 합쳤고(지시 1), 돌붙임 유무는 빈값 없이 있음/없음뿐이며 기본값이 - * 부위마다 다르다 — 유입 있음·찰 / 유출 없음·메(지시 3). */ -export function createRevetmentFields( - keys: RevetmentKeys, - defaults: RevetmentDefaults, -): RevetmentFields { - const form = document.createElement("select"); - form.replaceChildren(...REVET_FORMS.map((value) => new Option(value, value))); - form.value = defaults.form; - const length = keys.length ? numberInput("0.1") : null; - if (length) length.value = defaults.length; - const height = numberInput("0.1"); - height.value = defaults.height; - const pitching = document.createElement("select"); - pitching.replaceChildren(...["있음", "없음"].map((value) => new Option(value, value))); - pitching.value = defaults.pitching; - const pitchingFinish = document.createElement("select"); - pitchingFinish.replaceChildren(...STONE_FINISHES.map((value) => new Option(value, value))); - pitchingFinish.value = defaults.pitchingFinish; - const pitchingArea = numberInput("0.1"); - pitchingArea.value = defaults.pitchingArea; - - const formField = labeled("형태", form); - const lengthField = length ? labeled("길이 (m)", length) : null; - const heightField = labeled("높이 (m)", height); - const pitchingField = labeled("돌붙임", pitching); - const pitchingFinishField = labeled("돌붙임 표면처리", pitchingFinish); - const pitchingAreaField = labeled("돌붙임 면적 (㎡)", pitchingArea); - - // 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다. - const rows = [ - grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)), - grid(pitchingField, pitchingFinishField, pitchingAreaField), - ]; - - function syncVisibility(): void { - const noPitching = pitching.value === "없음"; - pitchingFinishField.hidden = noPitching; - pitchingAreaField.hidden = noPitching; - } - - // 만들자마자 기본값 상태를 맞춘다 — 첫 화면에서 유입 "있음"이 접혀 보이지 않게. - syncVisibility(); - - return { - formField, - rows, - lengthInput: length, - write(options) { - const text = (key: string): string => - options[key] !== undefined ? String(options[key]) : ""; - form.value = text(keys.form) || defaults.form; - if (length && keys.length) length.value = text(keys.length) || defaults.length; - height.value = text(keys.height) || defaults.height; - pitching.value = text(keys.pitching) || defaults.pitching; - pitchingFinish.value = text(keys.pitchingFinish) || defaults.pitchingFinish; - pitchingArea.value = text(keys.pitchingArea) || defaults.pitchingArea; - syncVisibility(); - }, - read(target) { - const putNumber = (key: string, raw: string): void => { - const value = Number.parseFloat(raw); - if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1)); - }; - if (form.value) target[keys.form] = form.value; - if (length && keys.length) putNumber(keys.length, length.value); - putNumber(keys.height, height.value); - target[keys.pitching] = pitching.value; - if (pitching.value !== "없음") { - target[keys.pitchingFinish] = pitchingFinish.value; - putNumber(keys.pitchingArea, pitchingArea.value); - } - }, - onChange(handler) { - [form, height, pitching, pitchingFinish, pitchingArea].forEach((input) => - input.addEventListener("change", () => { - syncVisibility(); - handler(); - }), - ); - length?.addEventListener("change", handler); - }, - syncVisibility, - }; -} - -/* ── BOX암거 ────────────────────────────────────────────────────────────── */ - -/** 본체 규격 프리셋 — 실무 관측 규격이다(2026-08-17 사용자 확정). 그 밖의 규격은 - * "사용자 지정"으로 폭·높이를 직접 받는다. 저장 키는 프리셋이든 자유 입력이든 - * body_width_m·body_height_m 숫자 그대로다 — 프리셋 전용 키를 만들지 않아야 - * 하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다. */ -const BOX_SIZE_PRESETS: ReadonlyArray = [ - ["2.0×2.0", 2, 2], - ["3.0×3.0", 3, 3], -]; -const BOX_SIZE_CUSTOM = "사용자 지정"; - -/** 날개벽 옵션 키 묶음 — 유입·유출이 같은 조각을 쓰되 키만 다르다. */ -interface WingKeys { - install: string; - height: string; - length: string; - angle: string; -} - -const WING_IN_KEYS: WingKeys = { - install: "wing_in", - height: "wing_in_height_m", - length: "wing_in_length_m", - angle: "wing_in_angle_deg", -}; - -const WING_OUT_KEYS: WingKeys = { - install: "wing_out", - height: "wing_out_height_m", - length: "wing_out_length_m", - angle: "wing_out_angle_deg", -}; - -/** 날개벽 기본 제원 — 설치 있음·짧은쪽 높이 1m·길이 2m·각도 45° - * (2026-08-17 사용자 확정). 유입·유출이 같은 값을 쓴다. */ -const WING_DEFAULTS = { install: "있음", height: "1", length: "2", angle: "45" } as const; - -interface WingFields { - root: HTMLFieldSetElement; - inputs: Array; - syncVisibility: () => void; - write: (options: Record) => void; - read: (target: Record) => void; -} - -/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각. - * 설치 "없음"이면 제원 칸을 접는다(기슭막이 돌붙임과 같은 패턴). */ -function createWingFields(title: string, keys: WingKeys): WingFields { - const install = optionalSelect("미지정", ["있음", "없음"]); - install.value = WING_DEFAULTS.install; - const height = numberInput("0.1"); - height.value = WING_DEFAULTS.height; - const length = numberInput("0.1"); - length.value = WING_DEFAULTS.length; - const angle = numberInput("1"); - angle.value = WING_DEFAULTS.angle; - - const heightField = labeled("짧은쪽 높이 (m)", height); - const dimsRow = grid(labeled("길이 (m)", length), labeled("각도 (°)", angle)); - const box = group(title); - box.body.append(grid(labeled("설치", install), heightField), dimsRow); - - function syncVisibility(): void { - const off = install.value === "없음"; - heightField.hidden = off; - dimsRow.hidden = off; - } - - // 만들자마자 기본값("있음") 상태를 맞춘다. - syncVisibility(); - - return { - root: box.root, - inputs: [install, height, length, angle], - syncVisibility, - write(options) { - const text = (key: string): string => - options[key] !== undefined ? String(options[key]) : ""; - install.value = text(keys.install) || WING_DEFAULTS.install; - height.value = text(keys.height) || WING_DEFAULTS.height; - length.value = text(keys.length) || WING_DEFAULTS.length; - angle.value = text(keys.angle) || WING_DEFAULTS.angle; - syncVisibility(); - }, - read(target) { - target[keys.install] = install.value; - if (install.value !== "있음") return; - putNumber(target, keys.height, height.value); - putNumber(target, keys.length, length.value); - putNumber(target, keys.angle, angle.value); - }, - }; -} - interface FacilityOptionsFormCallbacks { /** 아무 옵션이나 바뀌었을 때 — 패널이 저장 흐름(수정 확정)을 안내하는 데 쓴다. */ onChange?: () => void; @@ -481,7 +193,7 @@ export function createFacilityOptionsForm( const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(찰)", - pitchingFinish: "찰", + protection: "돌붙임(찰)", }); // 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다 // (2026-08-17 사용자 지시 3). @@ -498,7 +210,7 @@ export function createFacilityOptionsForm( const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(메)", - pitchingFinish: "메", + protection: "돌붙임(메)", }); outletGroup.body.append( grid(labeled("구조", outletType), outletRevet.formField), @@ -509,7 +221,7 @@ export function createFacilityOptionsForm( const standaloneRevet = createRevetmentFields(STANDALONE_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(메)", - pitchingFinish: "메", + protection: "돌붙임(메)", }); const standaloneWrap = document.createElement("div"); standaloneWrap.append(grid(standaloneRevet.formField), ...standaloneRevet.rows); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts new file mode 100644 index 00000000..5bf2c0d8 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -0,0 +1,354 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Facility_Fields.ts + * 계곡 통과 시설 부속 옵션 서브폼의 입력 조각 — 라벨·숫자칸·격자·소그룹 상자와 + * 기슭막이 한 벌(형태·길이·높이·보호공), BOX암거 본체 규격·날개벽 조각. + * + * 서브폼 본체(B05_Profile_UI_Drainage_Facility)가 700줄 한계에 닿아 분리했다. + * 조각은 전부 순수 DOM 생성·값 읽기라 본체의 시설 전환 흐름과 독립적이다 — + * 유입·유출·독립 기슭막이(D4)가 같은 조각을 키만 바꿔 쓰는 구조도 그대로다. + * ========================================================================== */ + +/* ── 폼 조각 ────────────────────────────────────────────────────────────── */ + +export function labeled(text: string, input: HTMLElement): HTMLLabelElement { + const wrapper = document.createElement("label"); + wrapper.className = "b05-route__field"; + const caption = document.createElement("span"); + caption.textContent = text; + wrapper.append(caption, input); + return wrapper; +} + +export function numberInput(step: string, min = "0", placeholder = ""): HTMLInputElement { + const input = document.createElement("input"); + input.type = "number"; + input.step = step; + input.min = min; + if (placeholder) input.placeholder = placeholder; + return input; +} + +/** 양수만 옵션에 싣는다 — 빈칸·0·음수는 값이 없는 것으로 본다. */ +export function putNumber(target: Record, key: string, raw: string): void { + const value = Number.parseFloat(raw); + if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1)); +} + +/** 선택지 셀렉트 — 빈("미지정") 항목을 두지 않는다. 첫 항목이 곧 기본값이고, + * 기본값은 구조물별로 따로 지정한다(2026-08-17 사용자 지시 1). */ +export function optionalSelect( + _blankLabel: string, + choices: ReadonlyArray, +): HTMLSelectElement { + const element = document.createElement("select"); + element.replaceChildren(...choices.map((choice) => new Option(choice, choice))); + return element; +} + +/** 2열 격자 한 줄(칸이 둘 이하면 그대로, 넘치면 줄바꿈). */ +export function grid(...fields: HTMLElement[]): HTMLDivElement { + const div = document.createElement("div"); + div.className = "b05-structure__grid"; + div.append(...fields); + return div; +} + +/** 제목 달린 소그룹 상자(유입구·유출구). */ +export function group(title: string): { root: HTMLFieldSetElement; body: HTMLDivElement } { + const root = document.createElement("fieldset"); + root.className = "b05-structure__group"; + const legend = document.createElement("legend"); + legend.textContent = title; + const body = document.createElement("div"); + body.className = "b05-structure__group-body"; + root.append(legend, body); + return { root, body }; +} + +/** 기슭막이 형태 — 재질과 표면처리를 한 축으로 합친 목록(2026-08-17 사용자 지시 1). + * 기존 기슭막이 형식 목록에 돌붙임의 찰·메 구분만 더한 것이다. */ +const REVET_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"]; +/** 바닥 보호공 — 돌붙임의 찰·메 구분에 도수로를 더한 한 축(2026-08-17 사용자 지시). + * 도수로·산비탈수로(B4)는 별표2가 유출구~원지반 도달을 의무화한 공종인데, 실제로는 + * 유출부에서 돌붙임 **대신** 쓰는 선택지라 별도 구조물이 아니라 여기에 넣는다. + * "없음"은 두지 않는다 — 물이 흐르는 자리라 보호공은 반드시 있다(사용자 확정). */ +const PROTECTION_FORMS = ["돌붙임(찰)", "돌붙임(메)", "도수로"]; +const PROTECTION_CHANNEL = "도수로"; + +/** 기슭막이 옵션 키 묶음 — 유입·유출·독립 구조물이 같은 조각을 쓰되 키만 다르다. */ +export interface RevetmentKeys { + form: string; + length?: string; + height: string; + protection: string; + protectionArea: string; + protectionWidth: string; +} + +/** 부위별 기본 구성 — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m·높이 2.5m, + * 보호공은 유입 돌붙임(찰)·유출 돌붙임(메) (2026-08-17 사용자 확정). */ +export interface RevetmentDefaults { + form: string; + length: string; + height: string; + protection: string; + protectionArea: string; + protectionWidth: string; +} + +/** 부위와 무관한 공통 기본 치수 — 길이 10m·높이 2.5m·보호공 면적 10㎡·도수로 폭 1m. + * 도수로 폭 1m는 지식DB에 수치 근거가 없어 2026-08-17 사용자가 확정한 값이다 + * (실무 확인 후 개선 예정). */ +export const REVET_COMMON_DEFAULTS = { + length: "10", + height: "2.5", + protectionArea: "10", + protectionWidth: "1", +} as const; + +/** 보호공 개편(2026-08-17) 전 키 — `*_pitching` 계열이었다. 구 저장분을 읽을 때만 쓴다. */ +const legacyProtectionKey = (key: string): string => key.replace("protection", "pitching"); + +export const INLET_REVET_KEYS: RevetmentKeys = { + form: "inlet_revet_form", + length: "inlet_revet_length_m", + height: "inlet_revet_height_m", + protection: "inlet_protection", + protectionArea: "inlet_protection_area_m2", + protectionWidth: "inlet_protection_width_m", +}; + +export const OUTLET_REVET_KEYS: RevetmentKeys = { + form: "outlet_revet_form", + length: "outlet_revet_length_m", + height: "outlet_revet_height_m", + protection: "outlet_protection", + protectionArea: "outlet_protection_area_m2", + protectionWidth: "outlet_protection_width_m", +}; + +/** 독립 기슭막이(D4) — 길이는 구간형 측점이 정하므로 길이 칸이 없다. */ +export const STANDALONE_REVET_KEYS: RevetmentKeys = { + form: "form", + height: "height_m", + protection: "protection", + protectionArea: "protection_area_m2", + protectionWidth: "protection_width_m", +}; + +export interface RevetmentFields { + /** 형태 칸 — 유입·유출 그룹에서 "구조" 옆 2열째에 놓는다(2026-08-17 지시 3·4). */ + formField: HTMLLabelElement; + /** 형태를 뺀 나머지 행(길이·높이, 보호공). */ + rows: HTMLElement[]; + /** 길이 칸(없는 구성이면 null). */ + lengthInput: HTMLInputElement | null; + write: (options: Record) => void; + read: (target: Record) => void; + onChange: (handler: () => void) => void; + syncVisibility: () => void; +} + +/** 구 저장분의 보호공 값을 새 한 축으로 옮긴다 — 개편 전에는 `*_pitching`(있음/없음)과 + * `*_pitching_finish`(찰/메) 두 축이었다. "없음"은 선택지가 사라졌으므로 부위 + * 기본값으로 올린다(물이 흐르는 자리라 보호공은 필수 — 2026-08-17 사용자 확정). */ +function migrateProtection( + options: Record, + keys: RevetmentKeys, + fallback: string, +): string { + const current = options[keys.protection]; + if (typeof current === "string" && PROTECTION_FORMS.includes(current)) return current; + const legacy = options[legacyProtectionKey(keys.protection)]; + const finish = options[`${legacyProtectionKey(keys.protection)}_finish`]; + if (legacy === "있음" && (finish === "찰" || finish === "메")) return `돌붙임(${finish})`; + return fallback; +} + +/** 기슭막이 한 벌(형태·길이·높이 + 보호공) — 배수관 유입·유출부와 독립 구조물이 + * 공유하는 입력 인터페이스(2026-08-17 사용자 지시 9·11·12). 재질과 표면처리는 + * "형태" 한 축으로 합쳤고(지시 1), 바닥 보호공도 돌붙임 찰·메와 도수로를 한 축으로 + * 합쳤다. 기본값은 부위마다 다르다 — 유입 돌붙임(찰) / 유출 돌붙임(메). + * 치수 칸은 보호공에 따라 갈아 끼운다: 돌붙임이면 면적(㎡), 도수로면 폭(m). */ +export function createRevetmentFields( + keys: RevetmentKeys, + defaults: RevetmentDefaults, +): RevetmentFields { + const form = document.createElement("select"); + form.replaceChildren(...REVET_FORMS.map((value) => new Option(value, value))); + form.value = defaults.form; + const length = keys.length ? numberInput("0.1") : null; + if (length) length.value = defaults.length; + const height = numberInput("0.1"); + height.value = defaults.height; + const protection = document.createElement("select"); + protection.replaceChildren(...PROTECTION_FORMS.map((value) => new Option(value, value))); + protection.value = defaults.protection; + const protectionArea = numberInput("0.1"); + protectionArea.value = defaults.protectionArea; + const protectionWidth = numberInput("0.1"); + protectionWidth.value = defaults.protectionWidth; + + const formField = labeled("형태", form); + const lengthField = length ? labeled("길이 (m)", length) : null; + const heightField = labeled("높이 (m)", height); + const protectionField = labeled("보호공", protection); + const protectionAreaField = labeled("보호공 면적 (㎡)", protectionArea); + const protectionWidthField = labeled("도수로 폭 (m)", protectionWidth); + + // 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다. + const rows = [ + grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)), + grid(protectionField, protectionAreaField, protectionWidthField), + ]; + + function syncVisibility(): void { + const channel = protection.value === PROTECTION_CHANNEL; + protectionAreaField.hidden = channel; + protectionWidthField.hidden = !channel; + } + + // 만들자마자 기본값 상태를 맞춘다 — 첫 화면에서 면적 칸이 접혀 보이지 않게. + syncVisibility(); + + return { + formField, + rows, + lengthInput: length, + write(options) { + const text = (key: string): string => + options[key] !== undefined ? String(options[key]) : ""; + form.value = text(keys.form) || defaults.form; + if (length && keys.length) length.value = text(keys.length) || defaults.length; + height.value = text(keys.height) || defaults.height; + protection.value = migrateProtection(options, keys, defaults.protection); + protectionArea.value = + text(keys.protectionArea) || + text(legacyProtectionKey(keys.protectionArea)) || + defaults.protectionArea; + protectionWidth.value = text(keys.protectionWidth) || defaults.protectionWidth; + syncVisibility(); + }, + read(target) { + const putNumber = (key: string, raw: string): void => { + const value = Number.parseFloat(raw); + if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1)); + }; + if (form.value) target[keys.form] = form.value; + if (length && keys.length) putNumber(keys.length, length.value); + putNumber(keys.height, height.value); + target[keys.protection] = protection.value; + // 면적과 폭은 배타 — 고르지 않은 쪽 값을 남기면 수량이 두 벌로 잡힌다. + if (protection.value === PROTECTION_CHANNEL) { + putNumber(keys.protectionWidth, protectionWidth.value); + } else { + putNumber(keys.protectionArea, protectionArea.value); + } + }, + onChange(handler) { + [form, height, protection, protectionArea, protectionWidth].forEach((input) => + input.addEventListener("change", () => { + syncVisibility(); + handler(); + }), + ); + length?.addEventListener("change", handler); + }, + syncVisibility, + }; +} + +/* ── BOX암거 ────────────────────────────────────────────────────────────── */ + +/** 본체 규격 프리셋 — 실무 관측 규격이다(2026-08-17 사용자 확정). 그 밖의 규격은 + * "사용자 지정"으로 폭·높이를 직접 받는다. 저장 키는 프리셋이든 자유 입력이든 + * body_width_m·body_height_m 숫자 그대로다 — 프리셋 전용 키를 만들지 않아야 + * 하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다. */ +export const BOX_SIZE_PRESETS: ReadonlyArray = [ + ["2.0×2.0", 2, 2], + ["3.0×3.0", 3, 3], +]; +export const BOX_SIZE_CUSTOM = "사용자 지정"; + +/** 날개벽 옵션 키 묶음 — 유입·유출이 같은 조각을 쓰되 키만 다르다. */ +interface WingKeys { + install: string; + height: string; + length: string; + angle: string; +} + +export const WING_IN_KEYS: WingKeys = { + install: "wing_in", + height: "wing_in_height_m", + length: "wing_in_length_m", + angle: "wing_in_angle_deg", +}; + +export const WING_OUT_KEYS: WingKeys = { + install: "wing_out", + height: "wing_out_height_m", + length: "wing_out_length_m", + angle: "wing_out_angle_deg", +}; + +/** 날개벽 기본 제원 — 설치 있음·짧은쪽 높이 1m·길이 2m·각도 45° + * (2026-08-17 사용자 확정). 유입·유출이 같은 값을 쓴다. */ +const WING_DEFAULTS = { install: "있음", height: "1", length: "2", angle: "45" } as const; + +interface WingFields { + root: HTMLFieldSetElement; + inputs: Array; + syncVisibility: () => void; + write: (options: Record) => void; + read: (target: Record) => void; +} + +/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각. + * 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴). */ +export function createWingFields(title: string, keys: WingKeys): WingFields { + const install = optionalSelect("미지정", ["있음", "없음"]); + install.value = WING_DEFAULTS.install; + const height = numberInput("0.1"); + height.value = WING_DEFAULTS.height; + const length = numberInput("0.1"); + length.value = WING_DEFAULTS.length; + const angle = numberInput("1"); + angle.value = WING_DEFAULTS.angle; + + const heightField = labeled("짧은쪽 높이 (m)", height); + const dimsRow = grid(labeled("길이 (m)", length), labeled("각도 (°)", angle)); + const box = group(title); + box.body.append(grid(labeled("설치", install), heightField), dimsRow); + + function syncVisibility(): void { + const off = install.value === "없음"; + heightField.hidden = off; + dimsRow.hidden = off; + } + + // 만들자마자 기본값("있음") 상태를 맞춘다. + syncVisibility(); + + return { + root: box.root, + inputs: [install, height, length, angle], + syncVisibility, + write(options) { + const text = (key: string): string => + options[key] !== undefined ? String(options[key]) : ""; + install.value = text(keys.install) || WING_DEFAULTS.install; + height.value = text(keys.height) || WING_DEFAULTS.height; + length.value = text(keys.length) || WING_DEFAULTS.length; + angle.value = text(keys.angle) || WING_DEFAULTS.angle; + syncVisibility(); + }, + read(target) { + target[keys.install] = install.value; + if (install.value !== "있음") return; + putNumber(target, keys.height, height.value); + putNumber(target, keys.length, length.value); + putNumber(target, keys.angle, angle.value); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts new file mode 100644 index 00000000..9d49e317 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts @@ -0,0 +1,159 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Interact.ts + * 배수유역 지도의 포인터 조작 — 휠 확대/축소, 가운데 버튼 팬, 배관 마커 잡기, + * 유역 폴리곤 고르기. + * + * 패널 본체(B05_Profile_UI_Drainage_Panel)가 700줄 한계에 닿아 분리했다. + * 화면 변환값(배율·오프셋)은 여전히 본체가 들고 있고 여기서는 접근자로만 읽고 쓴다 — + * 그리기·유역 목록·마커 편집기가 같은 값을 보던 구조를 그대로 두기 위함이다. + * ========================================================================== */ + +import { + lonLatToScreen, + type Normalizer, + type ViewState, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { pointInRing } from "./B05_Profile_UI_Drainage_Parts"; +import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; +import type { createMapContextMenu } from "@ui/ui_template_context_menu"; + +/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */ +const CLICK_SLOP_PX = 4; + +export interface DrainageInteractParams { + viewport: HTMLElement; + contextMenu: ReturnType; + pipeEditor: ReturnType; + /** 현재 화면 변환(배율·오프셋·지도 사각형). 본체의 것을 그대로 쓴다. */ + currentView: () => ViewState; + getScale: () => number; + setScale: (value: number) => void; + getOffset: () => { x: number; y: number }; + setOffset: (x: number, y: number) => void; + scheduleDraw: () => void; + /** 유역 고르기에 필요한 현재 상태 — 좌표 변환기가 아직 없으면 고르지 않는다. */ + getNormalizer: () => Normalizer | null; + getBasins: () => ReadonlyArray; + getSelectedBasin: () => number | null; + /** 유역 강조를 바꾸는 유일한 자리(본체 selectBasin). */ + selectBasin: (index: number | null) => void; +} + +/** 뷰포트에 포인터·휠 조작을 붙인다. 반환값은 없다 — 리스너는 뷰포트와 수명이 같다. */ +export function bindDrainageInteractions(params: DrainageInteractParams): void { + const { viewport, contextMenu, pipeEditor, currentView, scheduleDraw } = params; + let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + /** 좌클릭을 시작한 자리 — 끌지 않고 뗐을 때만 유역 고르기로 본다. */ + let basinClickStart: { x: number; y: number } | null = null; + /** 마커를 잡은 좌클릭 — 이 경우 유역 고르기로 넘기지 않는다. */ + let pipeClickStart: { x: number; y: number } | null = null; + + viewport.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const prevScale = params.getScale(); + // 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). B04 2D 지도와 같은 방향이다. + const scale = Math.min(16, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87))); + params.setScale(scale); + // 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작). + const ratio = scale / prevScale; + const rect = viewport.getBoundingClientRect(); + const cursorX = event.clientX - rect.left - rect.width / 2; + const cursorY = event.clientY - rect.top - rect.height / 2; + const offset = params.getOffset(); + params.setOffset( + cursorX * (1 - ratio) + offset.x * ratio, + cursorY * (1 - ratio) + offset.y * ratio, + ); + scheduleDraw(); + }, + { passive: false }, + ); + + viewport.addEventListener("pointerdown", (event) => { + if (contextMenu.contains(event.target)) return; + contextMenu.close(); + // 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다. + if (event.button === 1) event.preventDefault(); + const rect = viewport.getBoundingClientRect(); + // 배관 마커는 **좌클릭으로만** 잡는다 — 우클릭은 메뉴, 가운데 버튼은 팬 전용이다 + // (2026-08-01 사용자 지시). + if ( + event.button === 0 && + pipeEditor.handleDown(currentView(), event.clientX - rect.left, event.clientY - rect.top) + ) { + // 마커를 잡은 좌클릭이므로 유역 고르기로는 넘기지 않는다. + pipeClickStart = { x: event.clientX, y: event.clientY }; + viewport.setPointerCapture(event.pointerId); + return; + } + // 마커를 못 잡은 좌클릭은 유역 고르기 후보로 기억한다(끌지 않고 뗐을 때만 고른다). + if (event.button === 0) basinClickStart = { x: event.clientX, y: event.clientY }; + // 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 유역선 핸들·배관 마커를 고르고 끄는 데만 쓴다 + // (좌버튼이 팬까지 겸하면 마커를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시). + // 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다. + if (event.pointerType === "mouse" && event.button !== 1) return; + const offset = params.getOffset(); + dragStart = { x: event.clientX, y: event.clientY, offsetX: offset.x, offsetY: offset.y }; + viewport.style.cursor = "grabbing"; + viewport.setPointerCapture(event.pointerId); + }); + + viewport.addEventListener("pointermove", (event) => { + const rect = viewport.getBoundingClientRect(); + // 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다. + if (pipeEditor.handleMove(currentView(), event.clientX - rect.left, event.clientY - rect.top)) + return; + if (!dragStart) return; + params.setOffset( + dragStart.offsetX + event.clientX - dragStart.x, + dragStart.offsetY + event.clientY - dragStart.y, + ); + scheduleDraw(); + }); + + /** 유역 폴리곤을 눌러 고른다. 유역 밖을 누르면 강조를 푼다. */ + function pickBasinAt(x: number, y: number): void { + const normalizer = params.getNormalizer(); + if (!normalizer) return; + const view = currentView(); + let hit: number | null = null; + let smallest = Number.POSITIVE_INFINITY; + params.getBasins().forEach((basin) => { + if (basin.polygon_lonlat.length < 3) return; + const ring = basin.polygon_lonlat.map(([lon, lat]) => + lonLatToScreen(normalizer, view, lon, lat), + ); + if (!pointInRing(ring, x, y)) return; + // 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선). + if (basin.area_m2 < smallest) { + smallest = basin.area_m2; + hit = basin.index; + } + }); + params.selectBasin(hit === params.getSelectedBasin() ? null : hit); + } + + const stopDragging = (): void => { + pipeEditor.handleUp(); + dragStart = null; + viewport.style.removeProperty("cursor"); + }; + viewport.addEventListener("pointerup", (event) => { + const start = basinClickStart; + basinClickStart = null; + const dragged = pipeClickStart !== null; + pipeClickStart = null; + if (!dragged && start && event.button === 0) { + const rect = viewport.getBoundingClientRect(); + // 끌었으면 지도 조작이지 고르기가 아니다. + if (Math.hypot(event.clientX - start.x, event.clientY - start.y) <= CLICK_SLOP_PX) { + pickBasinAt(event.clientX - rect.left, event.clientY - rect.top); + } + } + stopDragging(); + }); + viewport.addEventListener("pointercancel", stopDragging); +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index c5068c63..86d2229b 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -1,5 +1,3 @@ -import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; -import { createPanelResizer } from "@ui/ui_template_resizer"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { computeDetailBasins, @@ -16,7 +14,6 @@ import { import { computeMapRect, createNormalizer, - lonLatToScreen, prepareLayer, prepareMetricPolyline, type MapRect, @@ -26,12 +23,12 @@ import { } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { type RoutePoint } from "./B05_Profile_Api_Fetch"; import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows"; -import { buildStrengthArray, createFlowLegend } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp"; +import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp"; import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"; import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import { createFacilityStore, type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; -import { createProgressCircle } from "@ui/ui_template_progress"; -import { createMapContextMenu } from "@ui/ui_template_context_menu"; +import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; +import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render"; import { mountDrainageToggles, @@ -49,15 +46,11 @@ import { summaryText, basinIndexOfPipe, pipeMarkerColor, - pointInRing, reconcilePipes, WIDTH_KEY, type DrainageLayer, } from "./B05_Profile_UI_Drainage_Parts"; -/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */ -const CLICK_SLOP_PX = 4; - // 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널. // 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동). // 지도는 B04에서 분리한 렌더 엔진(B04_PreProcess_UI_MapRender)을 그대로 재사용해 @@ -126,90 +119,49 @@ export interface DrainagePanelCallbacks { } export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel { - const root = document.createElement("aside"); - root.className = "b05-drainage"; - // 이 패널은 하단 패널의 **오른쪽**에 도킹해 오른쪽으로 접힌다(제목 카드와 반대). - // 방향을 넘기지 않으면 화살표가 정확히 거꾸로 나온다. - // 이름표 "배수유역" — 세로(90°)로 화살표 위에 얹힌다(2026-08-04 사용자 지시). - const panelHandle = createWorkflowPanelHandle("side", "right", "배수유역"); - - const header = document.createElement("div"); - header.className = "b05-drainage__header"; - const title = document.createElement("h3"); - title.textContent = L("B05_Drainage_Title"); - const layerButtons = document.createElement("div"); - layerButtons.className = "b05-drainage__layers"; - header.append(title, layerButtons); - - // 세부유역 산정 — B04가 미리 분석해 둔 결과를 읽어 관을 보충하고 세부유역만 나눈다. - // 격자 해석은 하지 않으므로 즉시 끝난다. - const analyzeButton = document.createElement("button"); - analyzeButton.type = "button"; - analyzeButton.className = "b05-drainage__analyze"; - analyzeButton.textContent = L("B05_Drainage_Btn_Analyze"); - analyzeButton.title = L("B05_Drainage_Btn_Analyze_Tip"); - // 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다. - const deleteButton = document.createElement("button"); - deleteButton.type = "button"; - deleteButton.className = "b05-drainage__analyze b05-drainage__tool"; - deleteButton.textContent = L("B05_Drainage_Btn_DeleteSelected"); - deleteButton.disabled = true; - // 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정. - const autoButton = document.createElement("button"); - autoButton.type = "button"; - autoButton.className = "b05-drainage__analyze b05-drainage__tool"; - autoButton.textContent = L("B05_Drainage_Btn_Auto"); - autoButton.title = L("B05_Drainage_Btn_Auto_Tip"); - header.append(analyzeButton, deleteButton, autoButton); - - const viewport = document.createElement("div"); - viewport.className = "b05-drainage__viewport"; - const backgroundImage = document.createElement("img"); - backgroundImage.className = "b05-drainage__image"; - backgroundImage.alt = L("B05_Drainage_ImageAlt"); - backgroundImage.draggable = false; - const canvas = document.createElement("canvas"); - canvas.className = "b05-drainage__canvas"; - const status = document.createElement("span"); - status.className = "b05-drainage__status"; - status.textContent = L("B05_Drainage_Status_NeedRoute"); - // 지도 정중앙 로딩 서클 — 배경도·도엽 레이어·유역 산정이 끝날 때까지 화면이 비어 보인다. - const progress = createProgressCircle({ overlay: true }); - progress.root.hidden = true; - // 배관 추가·삭제 우클릭 메뉴 — 편집 토글을 없앤 대신 이쪽으로 옮겼다(B04 지도와 같은 조작). - const contextMenu = createMapContextMenu("b05-drainage"); - // 유입 강도 색띠 범례 — CSS가 지도 우측 세로로 세운다. - const legend = createFlowLegend(); - viewport.append(backgroundImage, canvas, status, progress.root, contextMenu.element, legend.root); + const chrome = createDrainageChrome( + { + title: L("B05_Drainage_Title"), + analyze: L("B05_Drainage_Btn_Analyze"), + analyzeTip: L("B05_Drainage_Btn_Analyze_Tip"), + deleteSelected: L("B05_Drainage_Btn_DeleteSelected"), + auto: L("B05_Drainage_Btn_Auto"), + autoTip: L("B05_Drainage_Btn_Auto_Tip"), + imageAlt: L("B05_Drainage_ImageAlt"), + statusNeedRoute: L("B05_Drainage_Status_NeedRoute"), + }, + { + minWidth: MIN_PANEL_WIDTH, + maxWidthRatio: MAX_PANEL_WIDTH_RATIO, + widthStorageKey: WIDTH_KEY, + }, + ); + const { + root, + panelHandle, + layerButtons, + analyzeButton, + deleteButton, + autoButton, + viewport, + backgroundImage, + canvas, + status, + progress, + contextMenu, + legend, + summary, + basinList, + widthResizer, + } = chrome; const showProgress = createProgressReporter(progress); - // 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다. - // 관 개수·세부유역 수·종단 Z 출처 — 세부유역이 갈리는 근거라 목록 위에 한 줄로 남긴다. - const summary = document.createElement("div"); - summary.className = "b05-drainage__summary"; - summary.hidden = true; - const basinList = document.createElement("div"); - basinList.className = "b05-drainage__basins"; - basinList.hidden = true; - // 왼쪽 경계를 끌어 폭을 조절한다. 상한은 하단 패널 폭의 70%, - // 세로는 하단 패널에 그대로 딸려 간다(따로 조절하지 않는다 — 사용자 지시). - const widthResizer = createPanelResizer({ - axis: "horizontal", - target: root, - cssVar: "--b05-drainage-width", - direction: -1, - min: MIN_PANEL_WIDTH, - max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * MAX_PANEL_WIDTH_RATIO, - storageKey: WIDTH_KEY, - }); // 계곡 통과 시설(배관/BOX암거/물넘이/세월교) 확장 정보 — 계산기는 chainage만 다루므로 // 여기 보관해 두고 요청마다 되붙인다(2026-08-17 컨테이너 병합). 편집 폼은 사이드 // 「구조물 배치」 하나뿐이다(자동·수동 배관 = 같은 구조물, 별도 UI 없음 — // 2026-08-17 사용자 지시). 여기는 정본 보관·재계산만 맡는다. const facilityStore = createFacilityStore(); - root.append(panelHandle.root, widthResizer.root, header, viewport, summary, basinList); - let projectId: string | null = null; let meta: VWorldMeta | null = null; const preparedLayers = new Map(); @@ -256,11 +208,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra let scale = 1; let offsetX = 0; let offsetY = 0; - let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; - /** 좌클릭을 시작한 자리 — 끌지 않고 뗐을 때만 유역 고르기로 본다. */ - let basinClickStart: { x: number; y: number } | null = null; - /** 마커를 잡은 좌클릭 — 이 경우 유역 고르기로 넘기지 않는다. */ - let pipeClickStart: { x: number; y: number } | null = null; let frameHandle = 0; let loadSequence = 0; let canvasWidth = 0; @@ -579,106 +526,30 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra } } - viewport.addEventListener( - "wheel", - (event) => { - event.preventDefault(); - const prevScale = scale; - // 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). B04 2D 지도와 같은 방향이다. - scale = Math.min(16, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87))); - // 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작). - const ratio = scale / prevScale; - const rect = viewport.getBoundingClientRect(); - const cursorX = event.clientX - rect.left - rect.width / 2; - const cursorY = event.clientY - rect.top - rect.height / 2; - offsetX = cursorX * (1 - ratio) + offsetX * ratio; - offsetY = cursorY * (1 - ratio) + offsetY * ratio; - scheduleDraw(); - }, - { passive: false }, - ); bindPipeContextMenu(viewport, contextMenu, pipeEditor, currentView, (chainage, type) => callbacks.onStructureAdd?.(chainage, type), ); - viewport.addEventListener("pointerdown", (event) => { - if (contextMenu.contains(event.target)) return; - contextMenu.close(); - // 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다. - if (event.button === 1) event.preventDefault(); - const rect = viewport.getBoundingClientRect(); - // 배관 마커는 **좌클릭으로만** 잡는다 — 우클릭은 메뉴, 가운데 버튼은 팬 전용이다 - // (2026-08-01 사용자 지시). - if ( - event.button === 0 && - pipeEditor.handleDown(currentView(), event.clientX - rect.left, event.clientY - rect.top) - ) { - // 마커를 잡은 좌클릭이므로 유역 고르기로는 넘기지 않는다. - pipeClickStart = { x: event.clientX, y: event.clientY }; - viewport.setPointerCapture(event.pointerId); - return; - } - // 마커를 못 잡은 좌클릭은 유역 고르기 후보로 기억한다(끌지 않고 뗐을 때만 고른다). - if (event.button === 0) basinClickStart = { x: event.clientX, y: event.clientY }; - // 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 유역선 핸들·배관 마커를 고르고 끄는 데만 쓴다 - // (좌버튼이 팬까지 겸하면 마커를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시). - // 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다. - if (event.pointerType === "mouse" && event.button !== 1) return; - dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; - viewport.style.cursor = "grabbing"; - viewport.setPointerCapture(event.pointerId); + bindDrainageInteractions({ + viewport, + contextMenu, + pipeEditor, + currentView, + getScale: () => scale, + setScale: (value) => { + scale = value; + }, + getOffset: () => ({ x: offsetX, y: offsetY }), + setOffset: (x, y) => { + offsetX = x; + offsetY = y; + }, + scheduleDraw, + getNormalizer: () => normalizer, + getBasins: () => basins, + getSelectedBasin: () => selectedBasin, + selectBasin: (index) => selectBasin(index), }); - viewport.addEventListener("pointermove", (event) => { - const rect = viewport.getBoundingClientRect(); - // 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다. - if (pipeEditor.handleMove(currentView(), event.clientX - rect.left, event.clientY - rect.top)) - return; - if (!dragStart) return; - offsetX = dragStart.offsetX + event.clientX - dragStart.x; - offsetY = dragStart.offsetY + event.clientY - dragStart.y; - scheduleDraw(); - }); - /** 유역 폴리곤을 눌러 고른다. 유역 밖을 누르면 강조를 푼다. */ - function pickBasinAt(x: number, y: number): void { - if (!normalizer) return; - const view = currentView(); - let hit: number | null = null; - let smallest = Number.POSITIVE_INFINITY; - basins.forEach((basin) => { - if (basin.polygon_lonlat.length < 3) return; - const ring = basin.polygon_lonlat.map(([lon, lat]) => - lonLatToScreen(normalizer as Normalizer, view, lon, lat), - ); - if (!pointInRing(ring, x, y)) return; - // 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선). - if (basin.area_m2 < smallest) { - smallest = basin.area_m2; - hit = basin.index; - } - }); - selectBasin(hit === selectedBasin ? null : hit); - } - - const stopDragging = (): void => { - pipeEditor.handleUp(); - dragStart = null; - viewport.style.removeProperty("cursor"); - }; - viewport.addEventListener("pointerup", (event) => { - const start = basinClickStart; - basinClickStart = null; - const dragged = pipeClickStart !== null; - pipeClickStart = null; - if (!dragged && start && event.button === 0) { - const rect = viewport.getBoundingClientRect(); - // 끌었으면 지도 조작이지 고르기가 아니다. - if (Math.hypot(event.clientX - start.x, event.clientY - start.y) <= CLICK_SLOP_PX) { - pickBasinAt(event.clientX - rect.left, event.clientY - rect.top); - } - } - stopDragging(); - }); - viewport.addEventListener("pointercancel", stopDragging); // 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다 — 늘릴 때마다 확대되면 // 방금 보던 자리를 다시 찾아야 한다(2026-08-02 사용자 지시). diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 4ba9e851..d02afc3c 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -24,16 +24,8 @@ import { writeRouteLatestCache, solveRoute, updateContourInterval, - type CirclePoint, type RouteLatestResponse, - type RoutePoint, } from "./B05_Profile_Api_Fetch"; -import { - type ModelBounds, - type PlacedRoutePoint, - type RouteDesignPoints, - type RoutePointKind, -} from "./B05_Profile_UI_Markers"; import { createRoutePanel, type RoutePanelValues } from "./B05_Profile_UI_Panel"; import { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; @@ -41,7 +33,6 @@ import { navigateTo } from "../A00_Common/router"; import { createSelectionSync } from "./B05_Profile_UI_Selection"; import { createRouteViewer } from "./B05_Profile_UI_Viewer"; import { - irregularLabel, irregularStationId, isPipeStation, structureLabel, @@ -52,7 +43,6 @@ import { pickPipeDiameter, PIPE_DEFAULT_TYPE } from "@config/config_frontend"; import { fetchSectionContext, type SectionDetailResponse, - type SectionStation, } from "../B06_Section/B06_Section_Api_Fetch"; import { invalidateSectionDetail, @@ -68,132 +58,22 @@ import { } from "./B05_Profile_Api_Structures"; import "./B05_Profile_UI_Style.css"; import "./B05_Profile_UI_Style_Structures.css"; +import { + circlePoint, + DEFAULT_ROAD_WIDTHS, + fetchRoadWidths, + interpolateIrregularStations, + restorePoints, + routePoint, + toBounds, +} from "./B05_Profile_UI_Page_Helpers"; + -type GradeClass = RoutePanelValues["gradeClass"]; -type RoadWidths = Record; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, branch: 3, work: 2.5 }; - -async function fetchRoadWidths(projectId: string): Promise { - const response = await fetch(`/api/projects/${projectId}/sections/road-widths`); - if (!response.ok) return DEFAULT_ROAD_WIDTHS; - const payload = (await response.json()) as { forest_road_min_width_m?: Partial }; - return { ...DEFAULT_ROAD_WIDTHS, ...payload.forest_road_min_width_m }; -} - -function toBounds(bounds: { - x_min: number; - x_max: number; - y_min: number; - y_max: number; - z_min: number; - z_max: number; -}): ModelBounds { - return { - x: [bounds.x_min, bounds.x_max], - y: [bounds.y_min, bounds.y_max], - z: [bounds.z_min, bounds.z_max], - }; -} - -function placed( - type: RoutePointKind, - point: RoutePoint | CirclePoint, - index = 0, -): PlacedRoutePoint { - return { - id: `${type}-restored-${index}`, - type, - x: point.x, - y: point.y, - // 표고를 모르면 모르는 채로 넘긴다 — 0으로 눕히면 마커가 지형 한참 아래 평면에 깔린다. - z: point.z ?? null, - ...(type === "ap" || type === "fp" ? { radius_m: (point as CirclePoint).radius_m ?? 25 } : {}), - }; -} - -function restorePoints(latest: RouteLatestResponse): RouteDesignPoints { - const points = latest.route_params?.points; - return { - bp: points?.bp ? placed("bp", points.bp) : null, - ep: points?.ep ? placed("ep", points.ep) : null, - cp: (points?.cp ?? []).map((point, index) => placed("cp", point, index)), - ap: (points?.ap ?? []).map((point, index) => placed("ap", point, index)), - fp: (points?.fp ?? []).map((point, index) => placed("fp", point, index)), - }; -} - -function routePoint(point: PlacedRoutePoint): RoutePoint { - // 서버 스키마는 표고를 빼면 "모름"으로 받는다 — null은 undefined로 바꿔 보낸다. - return { x: point.x, y: point.y, z: point.z ?? undefined }; -} - -function circlePoint(point: PlacedRoutePoint): CirclePoint { - return { ...routePoint(point), radius_m: point.radius_m ?? 25 }; -} - -/** - * 비정규 측점을 규칙 측점 좌표 사이 chainage로 선형보간해 `SectionStation`(월드 좌표·프레임 포함)으로 - * 만든다. 백엔드가 아직 이 측점의 횡단을 생성하지 않으므로, 3D 표시에 필요한 위치만 근사한다. - * 노선 범위를 벗어난 chainage는 제외한다. - */ -function interpolateIrregularStations( - base: SectionStation[], - list: IrregularStation[], - maxChainage: number, -): SectionStation[] { - const sorted = [...base].sort((a, b) => a.chainage_m - b.chainage_m); - if (!sorted.length) return []; - const anchorAt = (chainage: number): SectionStation => { - if (chainage <= sorted[0].chainage_m) return sorted[0]; - const last = sorted[sorted.length - 1]; - if (chainage >= last.chainage_m) return last; - let lo = sorted[0]; - let hi = last; - for (let index = 1; index < sorted.length; index += 1) { - if (sorted[index].chainage_m >= chainage) { - lo = sorted[index - 1]; - hi = sorted[index]; - break; - } - } - const span = hi.chainage_m - lo.chainage_m; - const t = span > 1e-9 ? (chainage - lo.chainage_m) / span : 0; - const lerp = (a: number, b: number): number => a + (b - a) * t; - const centerZ = - lo.center_z !== null && hi.center_z !== null - ? lerp(lo.center_z, hi.center_z) - : (lo.center_z ?? hi.center_z); - return { - ...lo, - center_x: lerp(lo.center_x, hi.center_x), - center_y: lerp(lo.center_y, hi.center_y), - center_z: centerZ, - frame: { - left_xy: [ - lerp(lo.frame.left_xy[0], hi.frame.left_xy[0]), - lerp(lo.frame.left_xy[1], hi.frame.left_xy[1]), - ], - }, - }; - }; - return list - .filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6) - .map((entry) => ({ - ...anchorAt(entry.chainage_m), - station_id: irregularStationId(entry.id), - chainage_m: entry.chainage_m, - label: irregularLabel(entry), - kind: "irregular" as const, - // 3D 측점 라벨이 `측점번호 구조물명`으로 표기할 수 있게 구조물 이름을 실어 보낸다. - structure: entry.structure, - })); -} - export async function renderB05Route(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) { diff --git a/B05_Profile/B05_Profile_UI_Page_Helpers.ts b/B05_Profile/B05_Profile_UI_Page_Helpers.ts new file mode 100644 index 00000000..f2cd97cc --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Page_Helpers.ts @@ -0,0 +1,144 @@ +/* ============================================================================= + * B05_Profile_UI_Page_Helpers.ts + * B05 화면의 순수 도우미 — 설계폭 조회, 모델 경계 변환, 저장분 → 마커 복원, + * 비정규 측점의 3D 좌표 선형보간. + * + * 화면 본체(B05_Profile_UI_Page)가 700줄 한계에 닿아 분리했다. + * 전부 상태를 갖지 않는 변환 함수라 화면 흐름과 독립적이다. + * ========================================================================== */ + +import type { RoutePanelValues } from "./B05_Profile_UI_Panel"; +import type { + ModelBounds, + PlacedRoutePoint, + RouteDesignPoints, + RoutePointKind, +} from "./B05_Profile_UI_Markers"; +import type { CirclePoint, RouteLatestResponse, RoutePoint } from "./B05_Profile_Api_Fetch"; +import type { SectionStation } from "../B06_Section/B06_Section_Api_Fetch"; +import { + irregularLabel, + irregularStationId, + type IrregularStation, +} from "./B05_Profile_UI_IrregularStations"; + +type GradeClass = RoutePanelValues["gradeClass"]; +export type RoadWidths = Record; + +export const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, branch: 3, work: 2.5 }; + +export async function fetchRoadWidths(projectId: string): Promise { + const response = await fetch(`/api/projects/${projectId}/sections/road-widths`); + if (!response.ok) return DEFAULT_ROAD_WIDTHS; + const payload = (await response.json()) as { forest_road_min_width_m?: Partial }; + return { ...DEFAULT_ROAD_WIDTHS, ...payload.forest_road_min_width_m }; +} + +export function toBounds(bounds: { + x_min: number; + x_max: number; + y_min: number; + y_max: number; + z_min: number; + z_max: number; +}): ModelBounds { + return { + x: [bounds.x_min, bounds.x_max], + y: [bounds.y_min, bounds.y_max], + z: [bounds.z_min, bounds.z_max], + }; +} + +export function placed( + type: RoutePointKind, + point: RoutePoint | CirclePoint, + index = 0, +): PlacedRoutePoint { + return { + id: `${type}-restored-${index}`, + type, + x: point.x, + y: point.y, + // 표고를 모르면 모르는 채로 넘긴다 — 0으로 눕히면 마커가 지형 한참 아래 평면에 깔린다. + z: point.z ?? null, + ...(type === "ap" || type === "fp" ? { radius_m: (point as CirclePoint).radius_m ?? 25 } : {}), + }; +} + +export function restorePoints(latest: RouteLatestResponse): RouteDesignPoints { + const points = latest.route_params?.points; + return { + bp: points?.bp ? placed("bp", points.bp) : null, + ep: points?.ep ? placed("ep", points.ep) : null, + cp: (points?.cp ?? []).map((point, index) => placed("cp", point, index)), + ap: (points?.ap ?? []).map((point, index) => placed("ap", point, index)), + fp: (points?.fp ?? []).map((point, index) => placed("fp", point, index)), + }; +} + +export function routePoint(point: PlacedRoutePoint): RoutePoint { + // 서버 스키마는 표고를 빼면 "모름"으로 받는다 — null은 undefined로 바꿔 보낸다. + return { x: point.x, y: point.y, z: point.z ?? undefined }; +} + +export function circlePoint(point: PlacedRoutePoint): CirclePoint { + return { ...routePoint(point), radius_m: point.radius_m ?? 25 }; +} + +/** + * 비정규 측점을 규칙 측점 좌표 사이 chainage로 선형보간해 `SectionStation`(월드 좌표·프레임 포함)으로 + * 만든다. 백엔드가 아직 이 측점의 횡단을 생성하지 않으므로, 3D 표시에 필요한 위치만 근사한다. + * 노선 범위를 벗어난 chainage는 제외한다. + */ +export function interpolateIrregularStations( + base: SectionStation[], + list: IrregularStation[], + maxChainage: number, +): SectionStation[] { + const sorted = [...base].sort((a, b) => a.chainage_m - b.chainage_m); + if (!sorted.length) return []; + const anchorAt = (chainage: number): SectionStation => { + if (chainage <= sorted[0].chainage_m) return sorted[0]; + const last = sorted[sorted.length - 1]; + if (chainage >= last.chainage_m) return last; + let lo = sorted[0]; + let hi = last; + for (let index = 1; index < sorted.length; index += 1) { + if (sorted[index].chainage_m >= chainage) { + lo = sorted[index - 1]; + hi = sorted[index]; + break; + } + } + const span = hi.chainage_m - lo.chainage_m; + const t = span > 1e-9 ? (chainage - lo.chainage_m) / span : 0; + const lerp = (a: number, b: number): number => a + (b - a) * t; + const centerZ = + lo.center_z !== null && hi.center_z !== null + ? lerp(lo.center_z, hi.center_z) + : (lo.center_z ?? hi.center_z); + return { + ...lo, + center_x: lerp(lo.center_x, hi.center_x), + center_y: lerp(lo.center_y, hi.center_y), + center_z: centerZ, + frame: { + left_xy: [ + lerp(lo.frame.left_xy[0], hi.frame.left_xy[0]), + lerp(lo.frame.left_xy[1], hi.frame.left_xy[1]), + ], + }, + }; + }; + return list + .filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6) + .map((entry) => ({ + ...anchorAt(entry.chainage_m), + station_id: irregularStationId(entry.id), + chainage_m: entry.chainage_m, + label: irregularLabel(entry), + kind: "irregular" as const, + // 3D 측점 라벨이 `측점번호 구조물명`으로 표기할 수 있게 구조물 이름을 실어 보낸다. + structure: entry.structure, + })); +} diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 287e51c8..decab2cc 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -88,10 +88,11 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -function section(title: string): { root: HTMLElement; body: HTMLElement } { +function section(title: string, collapsed = false): { root: HTMLElement; body: HTMLElement } { const root = document.createElement("section"); // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시). - root.className = "b05-route__panel-section ui-collapsible ui-sidebar-section"; + // 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫. + root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`; const heading = document.createElement("h3"); heading.className = "ui-collapsible__title"; heading.textContent = title; @@ -296,7 +297,9 @@ export function createRoutePanel(callbacks: PanelCallbacks) { longSampleInterval.wrapper, ); - const gradeLine = section("종단 설계 기준"); + // 종단 설계 기준은 한 번 정하면 자주 손대지 않는다 — 기본은 접힌 상태 + // (2026-08-17 사용자 지시). + const gradeLine = section("종단 설계 기준", true); const terrainField = createSelectField({ label: "지형 구분", options: [ diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts new file mode 100644 index 00000000..78d4585d --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -0,0 +1,112 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_List.ts + * 「구조물 배치」 폼 아래 목록 — 구조물 정본(structures)과 계곡 통과 시설 정본 + * (pipe_points)을 기준점 오름차순으로 하나의 목록에 병합해 그린다. + * + * 패널 본체(B05_Profile_UI_Structures_Panel)가 700줄 한계에 닿아 분리했다. + * 목록은 상태를 갖지 않는다 — 그릴 때마다 현재 목록·선택 상태를 인자로 받고, + * 항목을 누르면 넘겨받은 콜백으로 폼 로드를 되돌려 준다. + * ========================================================================== */ + +import { structureAnchorM, type StructureInstance, type StructureType } from "./B05_Profile_Api_Structures"; +import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel"; +import { formatStation } from "./B05_Profile_Util_Station"; + +export interface StructureListParams { + /** 목록을 담을
    . 그릴 때마다 통째로 갈아 끼운다. */ + list: HTMLElement; + structures: ReadonlyArray; + pipeFacilities: ReadonlyArray; + /** 타입 이름을 찾을 레지스트리 사전. */ + typeMap: Map; + /** 측점 표기용 측점 간격(m). */ + intervalM: number; + /** 설치측 표시 이름 — 패널과 같은 목록을 쓴다. */ + sideLabels: ReadonlyArray<[string, string]>; + /** 지금 폼에 올라와 있는 구조물(없으면 null). */ + editingId: string | null; + /** 지금 폼에 올라와 있는 계곡 통과 시설의 누가거리(없으면 null). */ + selectedPipeChainage: number | null; + onSelectStructure: (structure: StructureInstance) => void; + onSelectPipe: (pipe: PipeFacilityItem) => void; +} + +/** 구조물 한 건의 표시 이름 — 점형은 기준 측점, 구간형은 시작~종료 측점. */ +export function labelOfStructure( + structure: StructureInstance, + typeMap: Map, + intervalM: number, +): string { + const name = typeMap.get(structure.type_id)?.name ?? structure.type_id; + const position = + structure.placement === "interval" + ? `${formatStation(structure.start_m ?? 0, intervalM)}~${formatStation(structure.end_m ?? 0, intervalM)}` + : formatStation(structure.chainage_m ?? 0, intervalM); + return `${position} · ${name}`; +} + +/** 계곡 통과 시설 한 건의 표시 이름 — 점형이라 기준 측점 하나로 적는다 + * (2026-08-17 사용자 지시 2). */ +export function labelOfPipe( + pipe: PipeFacilityItem, + typeMap: Map, + intervalM: number, +): string { + const name = typeMap.get(pipe.facility)?.name ?? pipe.facility; + return `${formatStation(pipe.chainage_m, intervalM)} · ${name}`; +} + +export function renderStructureList(params: StructureListParams): void { + const { list, typeMap, intervalM, editingId, selectedPipeChainage } = params; + list.replaceChildren(); + // 두 정본을 하나의 목록으로 — 기준점 오름차순 병합 (2026-08-17 통합 표시). + const rows: Array< + | { kind: "structure"; anchor: number; structure: StructureInstance } + | { kind: "pipe"; anchor: number; pipe: PipeFacilityItem } + > = [ + ...params.structures.map((structure) => ({ + kind: "structure" as const, + anchor: structureAnchorM(structure), + structure, + })), + ...params.pipeFacilities.map((pipe) => ({ + kind: "pipe" as const, + anchor: pipe.chainage_m, + pipe, + })), + ].sort((left, right) => left.anchor - right.anchor); + + if (!rows.length) { + const empty = document.createElement("li"); + empty.className = "b05-route__irregular-empty"; + empty.textContent = "배치된 구조물이 없습니다."; + list.append(empty); + return; + } + rows.forEach((row) => { + const item = document.createElement("li"); + item.className = "b05-route__irregular-item"; + const name = document.createElement("strong"); + const info = document.createElement("span"); + if (row.kind === "structure") { + const { structure } = row; + item.classList.toggle("is-selected", structure.structure_id === editingId); + name.textContent = labelOfStructure(structure, typeMap, intervalM); + const sideLabel = params.sideLabels.find(([value]) => value === structure.side)?.[1] ?? ""; + info.textContent = [sideLabel, structure.memo].filter(Boolean).join(" · "); + item.addEventListener("click", () => params.onSelectStructure(structure)); + if (structure.structure_id === editingId) item.scrollIntoView({ block: "nearest" }); + } else { + const { pipe } = row; + item.classList.toggle( + "is-selected", + selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05, + ); + name.textContent = labelOfPipe(pipe, typeMap, intervalM); + info.textContent = "배수유역 연동"; + item.addEventListener("click", () => params.onSelectPipe(pipe)); + } + item.append(name, info); + list.append(item); + }); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 1c28f332..bc01d92c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -28,7 +28,7 @@ import { type FacilityAttributes, } from "./B05_Profile_UI_Drainage_Facility"; import { field, numberInput, select, stationFields } from "./B05_Profile_UI_Structures_Fields"; -import { formatStation } from "./B05_Profile_Util_Station"; +import { renderStructureList } from "./B05_Profile_UI_Structures_List"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. */ const GROUP_LABELS: Record = { @@ -315,80 +315,22 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu removeButton.disabled = editingId === null && selectedPipeChainage === null; } - function labelOfStructure(structure: StructureInstance): string { - const type = typeMap().get(structure.type_id); - const name = type?.name ?? structure.type_id; - const step = interval(); - const position = - structure.placement === "interval" - ? `${formatStation(structure.start_m ?? 0, step)}~${formatStation(structure.end_m ?? 0, step)}` - : formatStation(structure.chainage_m ?? 0, step); - return `${position} · ${name}`; - } - - function labelOfPipe(pipe: PipeFacilityItem): string { - const type = typeMap().get(pipe.facility); - const name = type?.name ?? pipe.facility; - // 계곡 통과 시설은 점형 — 기준 측점 하나로 표기한다(2026-08-17 지시 2). - return `${formatStation(pipe.chainage_m, interval())} · ${name}`; - } - function renderList(): void { - list.replaceChildren(); - // 두 정본을 하나의 목록으로 — 기준점 오름차순 병합 (2026-08-17 통합 표시). - const rows: Array< - | { kind: "structure"; anchor: number; structure: StructureInstance } - | { kind: "pipe"; anchor: number; pipe: PipeFacilityItem } - > = [ - ...structures.map((structure) => ({ - kind: "structure" as const, - anchor: structureAnchorM(structure), - structure, - })), - ...pipeFacilities.map((pipe) => ({ - kind: "pipe" as const, - anchor: pipe.chainage_m, - pipe, - })), - ].sort((left, right) => left.anchor - right.anchor); - - if (!rows.length) { - const empty = document.createElement("li"); - empty.className = "b05-route__irregular-empty"; - empty.textContent = "배치된 구조물이 없습니다."; - list.append(empty); - return; - } - rows.forEach((row) => { - const item = document.createElement("li"); - item.className = "b05-route__irregular-item"; - const name = document.createElement("strong"); - const info = document.createElement("span"); - if (row.kind === "structure") { - const { structure } = row; - item.classList.toggle("is-selected", structure.structure_id === editingId); - name.textContent = labelOfStructure(structure); - const sideLabel = SIDE_LABELS.find(([value]) => value === structure.side)?.[1] ?? ""; - info.textContent = [sideLabel, structure.memo].filter(Boolean).join(" · "); - item.addEventListener("click", () => - loadForm(structure.structure_id === editingId ? null : structure), - ); - if (structure.structure_id === editingId) item.scrollIntoView({ block: "nearest" }); - } else { - const { pipe } = row; - item.classList.toggle( - "is-selected", - selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05, - ); - name.textContent = labelOfPipe(pipe); - info.textContent = "배수유역 연동"; - item.addEventListener("click", () => { - loadPipeForm(pipe); - callbacks.onPipeSelect(pipe.chainage_m); - }); - } - item.append(name, info); - list.append(item); + renderStructureList({ + list, + structures, + pipeFacilities, + typeMap: typeMap(), + intervalM: interval(), + sideLabels: SIDE_LABELS, + editingId, + selectedPipeChainage, + onSelectStructure: (structure) => + loadForm(structure.structure_id === editingId ? null : structure), + onSelectPipe: (pipe) => { + loadPipeForm(pipe); + callbacks.onPipeSelect(pipe.chainage_m); + }, }); } diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index c4352adc..2ceb6c41 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -138,6 +138,33 @@ def _parse_span(item: dict[str, Any], chainage: float) -> tuple[float | None, fl return min(values), max(values) +# 보호공 부위별 기본값 (2026-08-17 사용자 확정) — 구 "없음" 저장분을 끌어올릴 때 쓴다. +_PROTECTION_FALLBACK = {"inlet": "돌붙임(찰)", "outlet": "돌붙임(메)"} + + +def _migrate_protection(options: dict[str, Any]) -> dict[str, Any]: + """구 저장분의 보호공 키를 새 한 축으로 옮긴다 (2026-08-17 보호공 개편). + + 개편 전에는 `*_pitching`(있음/없음)과 `*_pitching_finish`(찰/메) 두 축이었다. + 읽는 순간 `*_protection`(돌붙임(찰)/돌붙임(메)/도수로) 한 축으로 바꿔 두면 화면도 + 수량도 옛 키를 알 필요가 없다. "없음"은 선택지가 사라졌으므로 부위 기본값으로 + 올린다 — 물이 흐르는 자리라 보호공은 반드시 있다(사용자 확정). + """ + for side, fallback in _PROTECTION_FALLBACK.items(): + legacy = options.pop(f"{side}_pitching", None) + finish = options.pop(f"{side}_pitching_finish", None) + area = options.pop(f"{side}_pitching_area_m2", None) + if legacy is None and finish is None and area is None: + continue + if f"{side}_protection" not in options: + options[f"{side}_protection"] = ( + f"돌붙임({finish})" if legacy == "있음" and finish in ("찰", "메") else fallback + ) + if area is not None and f"{side}_protection_area_m2" not in options: + options[f"{side}_protection_area_m2"] = area + return options + + def parse_pipe_points(values: Any) -> list[PipePoint]: """외부에서 들어온 시설 목록(파일·요청 본문)을 정리한다. 누가거리 오름차순.""" if not isinstance(values, list): @@ -163,7 +190,11 @@ def parse_pipe_points(values: Any) -> list[PipePoint]: facility=facility if facility in _KNOWN_FACILITIES else PIPE_FACILITY_PIPE, start_m=start, end_m=end, - options=dict(options) if isinstance(options, dict) and options else None, + options=( + _migrate_protection(dict(options)) + if isinstance(options, dict) and options + else None + ), ) ) points.sort(key=lambda point: point.chainage_m)