From 38ea71e15bf464e99fa8f7c5cafa5b7fe80b9cdb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 17 Aug 2026 15:02:45 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B05):=20=EB=B0=B0=EA=B4=80=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EC=9D=84=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=ED=8F=BC=EC=9C=BC=EB=A1=9C=20=EB=8B=A8=EC=9D=BC?= =?UTF-8?q?=ED=99=94=20=E2=80=94=20=EB=B3=84=EB=8F=84=20=EC=8B=9C=EC=84=A4?= =?UTF-8?q?=20=ED=8F=BC=20=ED=8F=90=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자동 배관·수동 배관은 같은 구조물(2026-08-17 사용자 지시) — 별도 편집 UI (createFacilityEditor) 폐지. 배관/BOX암거/물넘이/세월교도 일반 구조물과 같은 흐름(구조물군 → 종류 → 시작·기준·종료 측점 → 옵션 → 추가/수정/삭제) - createFacilityOptionsForm: 부속 옵션 서브폼이 구조물 폼 옵션 자리에 삽입. 시설 종류는 종류 드롭다운, 위치는 측점 칸이 담당 - loadPipeForm: 목록·그래프·3D·배수유역도 어디서 골라도 같은 폼에 로드, 버튼 [수정]. onPipeUpdate 신설(기준점 이동=관 이동, 옵션만=재계산) - 기슭막이 총/앞/뒤 → 시작·종료 측점 칸 자동 채움. onPipesChanged에 source·options 추가. 배수유역 패널은 지도·정본 보관·재계산만 담당 - 700줄 대응: 입력 조각을 B05_Profile_UI_Structures_Fields.ts로 분리 (패널 685줄). tmp/tests 98건·typecheck·build 통과 --- .../B05_Profile_UI_Drainage_Facility.ts | 205 +++++++-------- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 73 +++--- B05_Profile/B05_Profile_UI_Page.ts | 12 +- B05_Profile/B05_Profile_UI_Panel.ts | 12 +- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 4 +- B05_Profile/B05_Profile_UI_Selection.ts | 5 +- .../B05_Profile_UI_Structures_Fields.ts | 90 +++++++ .../B05_Profile_UI_Structures_Panel.ts | 242 +++++++++--------- 8 files changed, 364 insertions(+), 279 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Structures_Fields.ts diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 6ef95a06..2ddbb700 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -1,23 +1,22 @@ /* ============================================================================= * B05_Profile_UI_Drainage_Facility.ts - * 계곡 통과 시설 편집 소구획 — 선택한 관 지점의 시설 종류·구간·부속 옵션. + * 계곡 통과 시설(배관/BOX암거/물넘이포장/세월교)의 부속 옵션 서브폼 + 시설 보관소. * - * 같은 계곡 교차점에서 유량·지형에 따라 배관/BOX암거/물넘이포장/세월교를 택일한다 - * (2026-08-17 사용자 확정 — 교량은 임도용이 아니라 없다). 정본은 `pipe_points.json` - * 하나이고, 세부유역 계산은 기준점(chainage)만 읽으므로 종류를 바꿔도 계산은 같다. + * 자동 배관이든 수동 배관이든 같은 구조물이다 — 별도 편집 UI를 두지 않고 사이드 + * 「구조물 배치」 폼의 옵션 자리에 이 서브폼이 끼워진다(2026-08-17 사용자 지시). + * 시설 종류는 구조물 배치의 "종류" 드롭다운이, 위치(시작·기준·종료 측점)는 위치 + * 칸이 담당하므로 여기에는 부속 옵션 필드만 있다. * * 배관 부속(2026-08-17 확정): 유형(계곡부형/보완형 — 자동 배치 source로 제안), * 집수정 유무(토글 시 유입부 옵션 전환), 관경(기본 1000), 기슭막이(길이 총/앞/뒤 - * 상호 연계 — 범위를 만든다), 돌붙임. 형식·치수 제원은 B06/B07 필수 승격 대상이라 - * 여기서는 보이되 비필수다. 시설 확장 정보는 패널이 chainage 기준으로 들고 다니며 - * 요청에 되붙인다(`FacilityStore`). + * 상호 연계 — 범위를 만든다 → onSpanSuggest로 위치 칸에 반영), 돌붙임. 형식·치수 + * 제원은 B06/B07 필수 승격 대상이라 보이되 비필수다. * ========================================================================== */ -import { - PIPE_FACILITY_LABELS, - type DetailPipeInput, - type PipeFacility, - type PipeSource, +import type { + DetailPipeInput, + PipeFacility, + PipeSource, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; /** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을 @@ -98,19 +97,25 @@ export function createFacilityStore() { export type FacilityStore = ReturnType; -interface FacilityEditorCallbacks { - /** 입력이 확정될 때(종류·구간·옵션 change) — 패널이 저장소 갱신·재계산을 맡는다. */ - onApply: (chainageM: number, attributes: FacilityAttributes | null) => void; +interface FacilityOptionsFormCallbacks { + /** 아무 옵션이나 바뀌었을 때 — 패널이 저장 흐름(수정 확정)을 안내하는 데 쓴다. */ + onChange?: () => void; + /** 기슭막이 총/앞/뒤가 정해졌을 때 — 위치 칸(시작·종료 측점) 자동 채움 제안. + * 범위 = 기준 − 앞 ~ 기준 + 뒤 (2026-08-17 확정). */ + onSpanSuggest?: (frontM: number, backM: number) => void; } -export interface FacilityEditor { +export interface FacilityOptionsForm { root: HTMLElement; - /** 선택된 관 지점을 폼에 올린다. null이면 숨긴다. source는 유형 제안에 쓴다. */ - sync: ( - chainageM: number | null, - attributes: FacilityAttributes | null, + /** 시설 종류에 맞는 옵션 필드를 보여주고 값을 채운다. null이면 통째로 숨긴다. + * source는 배관 유형(계곡부형/보완형) 제안 근거다. */ + setFacility: ( + facility: PipeFacility | null, + options?: Record, source?: PipeSource, ) => void; + /** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). */ + readOptions: () => Record; } function labeled(text: string, input: HTMLElement): HTMLLabelElement { @@ -156,22 +161,14 @@ function suggestFlowType(source: PipeSource | undefined): string { return ""; } -/** 선택한 관 지점 아래 붙는 시설 편집 폼. */ -export function createFacilityEditor(callbacks: FacilityEditorCallbacks): FacilityEditor { +/** 구조물 배치 폼의 옵션 자리에 끼워지는 계곡 통과 시설 부속 옵션 서브폼. */ +export function createFacilityOptionsForm( + callbacks: FacilityOptionsFormCallbacks = {}, +): FacilityOptionsForm { const root = document.createElement("div"); root.className = "b05-drainage__facility"; root.hidden = true; - const facilitySelect = document.createElement("select"); - facilitySelect.replaceChildren( - ...PIPE_FACILITY_LABELS.map(([value, label]) => new Option(label, value)), - ); - - // 구간 — 기준점 앞뒤로 유입·유출 부속이 차지하는 범위(m, 누가거리). 비우면 폭 0. - // 기슭막이 길이를 넣으면 자동으로 채워지고, 직접 고치면 그 값이 우선한다. - const startInput = numberInput("0.1", "0", "비우면 기준점"); - const endInput = numberInput("0.1", "0", "비우면 기준점"); - // ── 배관 부속 (2026-08-17 확정) ────────────────────────────────────────── const flowSelect = optionalSelect("미지정", ["계곡부형", "보완형"]); const catchBasinSelect = optionalSelect("자동(유형 따름)", ["있음", "없음"]); @@ -220,7 +217,6 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili const fordDiameterSelect = optionalSelect("선택", ["800", "1000", "1200", "1500"]); const fordCount = numberInput("1", "1", "련"); - const spanRow = row(labeled("시작 (m)", startInput), labeled("종료 (m)", endInput)); const pipeTypeRow = row( labeled("유형", flowSelect), labeled("관경", pipeDiameterSelect), @@ -258,8 +254,6 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili ); root.append( - labeled("시설 종류", facilitySelect), - spanRow, pipeTypeRow, pipeInletRow, revetRow, @@ -270,7 +264,7 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili fordRow, ); - let currentChainage: number | null = null; + let currentFacility: PipeFacility | null = null; let currentSource: PipeSource | undefined; let syncing = false; @@ -304,21 +298,10 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili revetBack.value = String(Number(back.toFixed(1))); } - /** 기슭막이 길이로 구간을 자동 채운다(범위 = 기준 − 앞 ~ 기준 + 뒤). - * 시작·종료를 직접 고친 경우는 건드리지 않도록, 기슭막이 입력 change에서만 부른다. */ - function applyRevetSpan(): void { - if (currentChainage === null) return; - const length = Number.parseFloat(revetLength.value); - const front = Number.parseFloat(revetFront.value); - const back = Number.parseFloat(revetBack.value); - if (!Number.isFinite(length) || length <= 0) return; - if (!Number.isFinite(front) || !Number.isFinite(back)) return; - startInput.value = (currentChainage - front).toFixed(1); - endInput.value = (currentChainage + back).toFixed(1); - } - function syncVisibility(): void { - const facility = facilitySelect.value as PipeFacility; + const facility = currentFacility; + root.hidden = facility === null; + if (facility === null) return; const isPipe = facility === "pipe"; const isBox = facility === "box_culvert"; pipeTypeRow.hidden = !isPipe; @@ -347,75 +330,25 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1)); } - function readAttributes(): FacilityAttributes { - const facility = facilitySelect.value as PipeFacility; - const attributes: FacilityAttributes = { facility }; - const start = Number.parseFloat(startInput.value); - const end = Number.parseFloat(endInput.value); - if (Number.isFinite(start) && Number.isFinite(end)) { - attributes.start_m = Math.min(start, end); - attributes.end_m = Math.max(start, end); - } - const options: Record = {}; - if (facility === "pipe") { - if (flowSelect.value) options.flow_type = flowSelect.value; - options.pipe_diameter_mm = Number(pipeDiameterSelect.value); - if (pipeMaterialSelect.value) options.pipe_kind = pipeMaterialSelect.value; - if (catchBasinSelect.value) options.catch_basin = catchBasinSelect.value; - if (catchBasinFormSelect.value && effectiveCatchBasin() === "있음") - options.catch_basin_form = catchBasinFormSelect.value; - if (revetSelect.value) options.revetment = revetSelect.value; - if (revetSelect.value !== "없음") { - putNumber(options, "revet_length_m", revetLength.value); - putNumber(options, "revet_front_m", revetFront.value); - if (revetFormSelect.value) options.revet_form = revetFormSelect.value; - } - if (pitchSelect.value) options.stone_pitching = pitchSelect.value; - if (pitchTypeSelect.value && pitchSelect.value !== "없음") - options.stone_pitching_type = pitchTypeSelect.value; - } else if (facility === "box_culvert") { - putNumber(options, "body_width_m", boxWidth.value); - putNumber(options, "body_height_m", boxHeight.value); - if (wingIn.value) options.wing_in = wingIn.value; - if (wingIn.value === "있음") { - putNumber(options, "wing_in_height_m", wingInHeight.value); - putNumber(options, "wing_in_length_m", wingInLength.value); - putNumber(options, "wing_in_angle_deg", wingInAngle.value); - } - if (wingOut.value) options.wing_out = wingOut.value; - if (wingOut.value === "있음") { - putNumber(options, "wing_out_height_m", wingOutHeight.value); - putNumber(options, "wing_out_length_m", wingOutLength.value); - putNumber(options, "wing_out_angle_deg", wingOutAngle.value); - } - } else if (facility === "ford_bridge") { - if (fordKindSelect.value) options.pipe_kind = fordKindSelect.value; - if (fordDiameterSelect.value) options.pipe_diameter_mm = Number(fordDiameterSelect.value); - const count = Number.parseInt(fordCount.value, 10); - if (Number.isFinite(count) && count > 0) options.pipe_count = count; - } - if (Object.keys(options).length) attributes.options = options; - return attributes; - } - function emit(): void { - if (syncing || currentChainage === null) return; + if (syncing || currentFacility === null) return; syncVisibility(); - callbacks.onApply(currentChainage, readAttributes()); + callbacks.onChange?.(); } [revetLength, revetFront, revetBack].forEach((input, index) => input.addEventListener("change", () => { if (syncing) return; linkRevet(index === 2 ? "back" : index === 1 ? "front" : "length"); - applyRevetSpan(); + const front = Number.parseFloat(revetFront.value); + const back = Number.parseFloat(revetBack.value); + if (Number.isFinite(front) && Number.isFinite(back)) { + callbacks.onSpanSuggest?.(front, back); + } emit(); }), ); [ - facilitySelect, - startInput, - endInput, flowSelect, pipeDiameterSelect, pipeMaterialSelect, @@ -442,19 +375,17 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili return { root, - sync(chainageM, attributes, source) { - currentChainage = chainageM; + setFacility(facility, options = {}, source) { + currentFacility = facility; currentSource = source; - root.hidden = chainageM === null; - if (chainageM === null) return; + if (facility === null) { + syncVisibility(); + return; + } syncing = true; - const options = attributes?.options ?? {}; const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; - facilitySelect.value = attributes?.facility ?? "pipe"; - startInput.value = attributes?.start_m !== undefined ? String(attributes.start_m) : ""; - endInput.value = attributes?.end_m !== undefined ? String(attributes.end_m) : ""; - const isFord = attributes?.facility === "ford_bridge"; + const isFord = facility === "ford_bridge"; flowSelect.value = text("flow_type"); pipeDiameterSelect.value = (!isFord && text("pipe_diameter_mm")) || "1000"; pipeMaterialSelect.value = isFord ? "" : text("pipe_kind"); @@ -489,5 +420,47 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili syncing = false; syncVisibility(); }, + readOptions() { + const facility = currentFacility; + const options: Record = {}; + if (facility === "pipe") { + if (flowSelect.value) options.flow_type = flowSelect.value; + options.pipe_diameter_mm = Number(pipeDiameterSelect.value); + if (pipeMaterialSelect.value) options.pipe_kind = pipeMaterialSelect.value; + if (catchBasinSelect.value) options.catch_basin = catchBasinSelect.value; + if (catchBasinFormSelect.value && effectiveCatchBasin() === "있음") + options.catch_basin_form = catchBasinFormSelect.value; + if (revetSelect.value) options.revetment = revetSelect.value; + if (revetSelect.value !== "없음") { + putNumber(options, "revet_length_m", revetLength.value); + putNumber(options, "revet_front_m", revetFront.value); + if (revetFormSelect.value) options.revet_form = revetFormSelect.value; + } + if (pitchSelect.value) options.stone_pitching = pitchSelect.value; + if (pitchTypeSelect.value && pitchSelect.value !== "없음") + options.stone_pitching_type = pitchTypeSelect.value; + } else if (facility === "box_culvert") { + putNumber(options, "body_width_m", boxWidth.value); + putNumber(options, "body_height_m", boxHeight.value); + if (wingIn.value) options.wing_in = wingIn.value; + if (wingIn.value === "있음") { + putNumber(options, "wing_in_height_m", wingInHeight.value); + putNumber(options, "wing_in_length_m", wingInLength.value); + putNumber(options, "wing_in_angle_deg", wingInAngle.value); + } + if (wingOut.value) options.wing_out = wingOut.value; + if (wingOut.value === "있음") { + putNumber(options, "wing_out_height_m", wingOutHeight.value); + putNumber(options, "wing_out_length_m", wingOutLength.value); + putNumber(options, "wing_out_angle_deg", wingOutAngle.value); + } + } else if (facility === "ford_bridge") { + if (fordKindSelect.value) options.pipe_kind = fordKindSelect.value; + if (fordDiameterSelect.value) options.pipe_diameter_mm = Number(fordDiameterSelect.value); + const count = Number.parseInt(fordCount.value, 10); + if (Number.isFinite(count) && count > 0) options.pipe_count = count; + } + return options; + }, }; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index c22070aa..9fae6e75 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -29,7 +29,7 @@ import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows"; import { buildStrengthArray, createFlowLegend } 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 { createFacilityEditor, createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; +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 { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render"; @@ -80,20 +80,23 @@ export interface DrainagePanel { /** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우). * 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다. */ setPipeChainages: (chainages: ReadonlyArray) => void; - /** 종단 테이블 우클릭·통합 목록에서 계곡 통과 시설을 넣거나 지울 때. - * facility를 주면 그 종류로 추가한다(기본 배관). */ - addPipe: (chainageM: number, facility?: PipeFacility) => void; + /** 종단 테이블 우클릭·통합 목록에서 계곡 통과 시설을 넣을 때. + * attributes에 시설 종류·구간·부속 옵션이 담긴다(생략 = 맨 배관). */ + addPipe: (chainageM: number, attributes?: FacilityAttributes) => void; + /** 사이드 폼 [수정] — 기준점 이동·구간·부속 옵션을 정본에 반영하고 재계산한다. */ + updatePipeFacility: ( + fromChainageM: number, + toChainageM: number, + attributes: FacilityAttributes, + ) => void; removePipe: (chainageM: number) => void; /** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */ savePipes: () => Promise; /** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */ selectBasinByChainage: (chainageM: number | null) => void; - /** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·시설 폼·유역 강조까지 연다. + /** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다. * 이미 같으면 무시(2026-08-17 전역 선택 동기화). */ selectPipeAtChainage: (chainageM: number | null) => void; - /** 부속 옵션 폼(시설 편집) DOM — 사이드 「구조물 배치」 섹션이 가져다 붙인다 - * (구조물 편집은 사이드 패널이 정위치, 2026-08-17 사용자 지시). */ - facilityForm: HTMLElement; /** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */ markStation: (chainageM: number | null) => void; dispose: () => void; @@ -109,6 +112,8 @@ export interface DrainagePanelCallbacks { facility: PipeFacility; start_m?: number; end_m?: number; + source?: PipeSource; + options?: Record; }>, ) => void; /** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */ @@ -198,18 +203,12 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra storageKey: WIDTH_KEY, }); // 계곡 통과 시설(배관/BOX암거/물넘이/세월교) 확장 정보 — 계산기는 chainage만 다루므로 - // 여기 보관해 두고 요청마다 되붙인다(2026-08-17 컨테이너 병합). + // 여기 보관해 두고 요청마다 되붙인다(2026-08-17 컨테이너 병합). 편집 폼은 사이드 + // 「구조물 배치」 하나뿐이다(자동·수동 배관 = 같은 구조물, 별도 UI 없음 — + // 2026-08-17 사용자 지시). 여기는 정본 보관·재계산만 맡는다. const facilityStore = createFacilityStore(); - const facilityEditor = createFacilityEditor({ - onApply: (chainageM, attributes) => { - facilityStore.set(chainageM, attributes); - void analyze(); // 즉시 재계산·응답 반영 — 계산은 같지만 시설 표기가 정본 경로를 탄다. - }, - }); root.append(panelHandle.root, widthResizer.root, header, viewport, summary, basinList); - // 시설 폼은 여기 두지 않는다 — 사이드 「구조물 배치」 섹션이 `facilityForm`으로 - // 가져다 붙인다(구조물 편집은 사이드가 정위치, 2026-08-17 사용자 지시). let projectId: string | null = null; let meta: VWorldMeta | null = null; @@ -363,17 +362,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage); - /** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼·시설 편집 폼 갱신. */ + /** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */ function syncPipeSelection(): void { const index = pipeEditor.selected(); deleteButton.disabled = index === null; const picked = index === null ? null : pipeEditor.pipes()[index]; - facilityEditor.sync( - picked ? picked.chainage_m : null, - picked ? facilityStore.get(picked.chainage_m) : null, - // 자동 배치 source — 배관 유형(계곡부형/보완형) 제안 근거 (2026-08-17). - picked ? ((picked.reason || "user") as PipeSource) : undefined, - ); selectBasin(basinIndexOfPipe(basins, picked ?? null)); // 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은 // Page의 selectionSyncing 가드가 끊는다). 값이 안 바뀌면 조용히 — 재계산(apply) @@ -446,8 +439,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra // 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null. // 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고, // 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조). - // 시설 종류·구간도 함께 — 통합 목록·그래프 라벨이 세월교/BOX암거를 구분한다. - pipeEditor.chainages().map((chainage) => { + // 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이 + // 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합). + pipeEditor.pipes().map((pipe) => { + const chainage = Math.round(pipe.chainage_m * 100) / 100; const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51); const attributes = facilityStore.get(chainage); return { @@ -457,6 +452,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra facility: attributes?.facility ?? "pipe", start_m: attributes?.start_m, end_m: attributes?.end_m, + source: (pipe.reason || "user") as PipeSource, + options: attributes?.options, }; }), ); @@ -742,13 +739,26 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra markedChainage = chainageM; scheduleDraw(); }, - addPipe(chainageM, facility) { - // 시설 종류를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다. - if (facility && facility !== "pipe") { - facilityStore.set(chainageM, { facility }); - } + addPipe(chainageM, attributes) { + // 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다. + facilityStore.set(chainageM, attributes ?? null); pipeEditor.addAtChainage(chainageM); }, + updatePipeFacility(fromChainageM, toChainageM, attributes) { + facilityStore.set(fromChainageM, null); + facilityStore.set(toChainageM, attributes); + if (Math.abs(fromChainageM - toChainageM) > 0.005) { + // 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고, + // attach가 새 위치로 시설 정보를 승계한다. + const index = pipeEditor + .pipes() + .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < 0.51); + if (index >= 0) pipeEditor.moveTo(index, toChainageM); + else void analyze(); + } else { + void analyze(); // 위치는 그대로 — 옵션·구간만 정본 경로로 재반영. + } + }, removePipe(chainageM) { const index = pipeEditor .pipes() @@ -769,7 +779,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra syncPipeSelection(); scheduleDraw(); }, - facilityForm: facilityEditor.root, async savePipes() { if (!projectId) return 0; const response = await saveDetailPipePoints( diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 84578016..c02ccaf7 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -230,6 +230,8 @@ export async function renderB05Route(root: HTMLElement): Promise { facility: pipe.facility, start_m: pipe.start_m, end_m: pipe.end_m, + source: pipe.source, + options: pipe.options, })), ); }, @@ -359,7 +361,10 @@ export async function renderB05Route(root: HTMLElement): Promise { onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset), onStructuresChange: (next) => applyStructures(next), // 계곡 통과 시설(A군) — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합). - onPipeFacilityAdd: (chainage, facility) => profilePanel.drainage.addPipe(chainage, facility), + onPipeFacilityAdd: (chainage, attributes) => + profilePanel.drainage.addPipe(chainage, attributes), + onPipeFacilityUpdate: (from, to, attributes) => + profilePanel.drainage.updatePipeFacility(from, to, attributes), onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage), onPipeFacilitySelect: (chainage) => { if (selectionSyncing) return; @@ -396,11 +401,6 @@ export async function renderB05Route(root: HTMLElement): Promise { if (currentSectionDetail) renderStationLines(currentSectionDetail); } - // 부속 옵션 폼(시설 편집)은 사이드 「구조물 배치」 섹션 안에 붙인다 — 구조물 - // 편집은 사이드가 정위치(2026-08-17 사용자 지시). 폼 데이터는 배수유역 패널이 - // 정본(pipe_points)과 동기화한다. - panel.structures.mountFacilityForm(profilePanel.drainage.facilityForm); - viewer.markers.onChange(markStale); viewer.markers.onSelectionChange(panel.setSelected); viewer.markers.onStationSelectionChange((stationId) => { diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 6ffd1d9c..287e51c8 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -1,6 +1,6 @@ import type { PlacedRoutePoint, RoutePointKind } from "./B05_Profile_UI_Markers"; import type { StructureInstance } from "./B05_Profile_Api_Structures"; -import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { createStructuresSection, type StructuresSection } from "./B05_Profile_UI_Structures_Panel"; import { type ButtonVariant, createButton, createSelectField } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; @@ -69,8 +69,13 @@ interface PanelCallbacks { onStructuresChange: (structures: StructureInstance[]) => void; /** 구조물을 목록에서 선택/해제할 때 해당 구조물(또는 null). */ onStructureSelect: (structure: StructureInstance | null) => void; - /** 계곡 통과 시설 추가·삭제·선택 — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합). */ - onPipeFacilityAdd: (chainageM: number, facility: PipeFacility) => void; + /** 계곡 통과 시설 추가·수정·삭제·선택 — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합). */ + onPipeFacilityAdd: (chainageM: number, attributes: FacilityAttributes) => void; + onPipeFacilityUpdate: ( + fromChainageM: number, + toChainageM: number, + attributes: FacilityAttributes, + ) => void; onPipeFacilityRemove: (chainageM: number) => void; onPipeFacilitySelect: (chainageM: number) => void; /** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */ @@ -353,6 +358,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { onSelect: callbacks.onStructureSelect, getInterval: () => Number(stationInterval.value) || 20, onPipeAdd: callbacks.onPipeFacilityAdd, + onPipeUpdate: callbacks.onPipeFacilityUpdate, onPipeRemove: callbacks.onPipeFacilityRemove, onPipeSelect: callbacks.onPipeFacilitySelect, }); diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 372216a9..76eb332a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -22,7 +22,7 @@ import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel"; -import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import { mountStructureMarks } from "./B05_Profile_UI_Structures_Marks"; import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures"; @@ -218,6 +218,8 @@ export interface RouteProfilePanelCallbacks { facility: PipeFacility; start_m?: number; end_m?: number; + source?: PipeSource; + options?: Record; }>, ) => void; /** 테이블에서 구조물 라인을 끌어 옮김. */ diff --git a/B05_Profile/B05_Profile_UI_Selection.ts b/B05_Profile/B05_Profile_UI_Selection.ts index bfb09e74..957aa0bc 100644 --- a/B05_Profile/B05_Profile_UI_Selection.ts +++ b/B05_Profile/B05_Profile_UI_Selection.ts @@ -30,8 +30,9 @@ export interface SelectionSyncPorts { selectBasin: (chainageM: number | null) => void; /** 배수유역도 계획선 위 측점 마킹(누가거리 기준). 유역 없는 구조물도 위치를 보여 준다. */ markStation?: (chainageM: number | null) => void; - /** 배수유역도 관 마커 선택 + 시설 폼 열기(누가거리 기준) — 어느 화면에서 배관을 - * 골라도 부속 옵션 폼까지 열리게 한다(2026-08-17 전역 선택 동기화). */ + /** 배수유역도 관 마커 선택(누가거리 기준) — 어느 화면에서 배관을 골라도 지도 + * 마커·유역 강조가 따라오게 한다(2026-08-17 전역 선택 동기화). 부속 옵션 폼은 + * 사이드 「구조물 배치」가 selectSidebar 경로에서 연다. */ selectPipeForm?: (chainageM: number | null) => void; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts new file mode 100644 index 00000000..95eecae5 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -0,0 +1,90 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Fields.ts + * 「구조물 배치」 폼 입력 조각 — 라벨 필드·숫자칸·셀렉트·측점번호+잔여거리 두 칸. + * + * 패널 본체(B05_Profile_UI_Structures_Panel)가 700줄 한계에 닿아 분리했다. + * 측점 두 칸은 구 비정규 측점 UI와 같은 입력 방식이다(2026-08-17 사용자 확정) — + * 저장은 누가거리(m), 화면 입출력만 측점번호+잔여거리. + * ========================================================================== */ + +import { chainageToStation } from "./B05_Profile_Util_Station"; + +export function field(labelText: string, input: HTMLElement): HTMLLabelElement { + const wrapper = document.createElement("label"); + wrapper.className = "b05-route__field"; + const caption = document.createElement("span"); + caption.textContent = labelText; + wrapper.append(caption, input); + return wrapper; +} + +export function numberInput(step = "0.1", min = "0"): HTMLInputElement { + const input = document.createElement("input"); + input.type = "number"; + input.step = step; + input.min = min; + return input; +} + +export function select(options: ReadonlyArray<[string, string]>): HTMLSelectElement { + const element = document.createElement("select"); + element.replaceChildren(...options.map(([value, label]) => new Option(label, value))); + return element; +} + +/** 측점번호 + 잔여거리 두 칸 묶음. */ +export interface StationFields { + wrap: HTMLElement; + station: HTMLInputElement; + remainder: HTMLInputElement; + /** 두 칸을 누가거리로 읽는다. 비었으면 null, 형식 오류면 붉히고 null. */ + read: (intervalM: number, required: boolean) => number | null; + /** 누가거리를 두 칸에 나눠 싣는다. null이면 비운다. */ + write: (chainageM: number | null, intervalM: number) => void; + clearInvalid: () => void; +} + +export function stationFields(labelText: string): StationFields { + const station = numberInput("1", "0"); + station.placeholder = "측점"; + const remainder = numberInput("0.1", "0"); + remainder.placeholder = "+m"; + const pair = document.createElement("div"); + pair.className = "b05-structure__station-pair"; + pair.append(station, remainder); + const wrap = field(labelText, pair); + const markInvalid = (bad: boolean): void => { + station.classList.toggle("is-invalid", bad); + remainder.classList.toggle("is-invalid", bad); + }; + return { + wrap, + station, + remainder, + read(intervalM, required) { + const stationText = station.value.trim(); + const remainderText = remainder.value.trim(); + if (!stationText && !remainderText) { + markInvalid(required); + return null; + } + const stationNo = stationText ? Number(stationText) : 0; + const rest = remainderText ? Number(remainderText) : 0; + const valid = + Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0; + markInvalid(!valid); + return valid ? stationNo * intervalM + rest : null; + }, + write(chainageM, intervalM) { + if (chainageM === null) { + station.value = ""; + remainder.value = ""; + return; + } + const parts = chainageToStation(chainageM, intervalM); + station.value = String(parts.station); + remainder.value = parts.remainder.toFixed(1); + }, + clearInvalid: () => markInvalid(false), + }; +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 46adbc50..df555c77 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -6,9 +6,11 @@ * 컨테이너 병합). 위치 입력은 측점번호+잔여거리 두 칸이고, 순서는 시작 측점 → * 기준 측점 → 종료 측점이다(점형은 기준 측점만). 저장은 누가거리(m)로 한다. * - * 계곡 통과 시설(배관/BOX암거/물넘이/세월교, A군 managed_by)은 관 지점 정본 - * (`pipe_points.json`) 소관 — 여기서는 목록에 병합 표시하고 추가·삭제·선택을 - * 콜백으로 배수유역 패널에 넘긴다. 상세 치수는 B06/B07 몫이라 받지 않는다. + * 계곡 통과 시설(배관/BOX암거/물넘이/세월교, A군 managed_by)도 **같은 폼**에서 + * 편집한다 — 자동 배치 배관이든 수동 배관이든 같은 구조물이라 별도 UI를 두지 + * 않는다(2026-08-17 사용자 지시). 저장소만 관 지점 정본(`pipe_points.json`)이라 + * 추가·수정·삭제를 콜백으로 배수유역 패널에 넘긴다. 부속 옵션(유형·집수정· + * 기슭막이·돌붙임·날개벽)은 옵션 자리의 서브폼이 그린다. 상세 치수는 B06/B07 몫. * ========================================================================== */ import { @@ -20,8 +22,13 @@ import { type StructureSide, type StructureType, } from "./B05_Profile_Api_Structures"; -import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { chainageToStation, formatStation } from "./B05_Profile_Util_Station"; +import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + createFacilityOptionsForm, + 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"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. */ const GROUP_LABELS: Record = { @@ -45,12 +52,16 @@ const SIDE_LABELS: Array<[StructureSide, string]> = [ /** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */ const DEFAULT_INTERVAL_LENGTH_M = 15; -/** 계곡 통과 시설 1건 — 관 지점 정본에서 온 병합 표시용 항목. */ +/** 계곡 통과 시설 1건 — 관 지점 정본에서 온 병합 표시·폼 편집용 항목. */ export interface PipeFacilityItem { chainage_m: number; facility: PipeFacility; start_m?: number; end_m?: number; + /** 자동 배치 출처(stream/spacing/user) — 배관 유형(계곡부형/보완형) 제안 근거. */ + source?: PipeSource; + /** 부속 옵션(유형·집수정·기슭막이·돌붙임·날개벽·세월교 관 등). */ + options?: Record; } export interface StructuresSection { @@ -62,10 +73,6 @@ export interface StructuresSection { getStructures: () => StructureInstance[]; /** 계곡 통과 시설 목록을 병합 표시한다(정본 = pipe_points, 배수유역 패널 경유). */ setPipeFacilities: (pipes: PipeFacilityItem[]) => void; - /** 부속 옵션 폼(배수유역 패널 소유 시설 편집 DOM)을 이 섹션 안에 붙인다 — - * 구조물 편집은 사이드 패널이 정위치(2026-08-17 사용자 지시). 표시/숨김은 - * 폼 스스로(선택된 관이 없으면 hidden) 관리한다. */ - mountFacilityForm: (element: HTMLElement) => void; /** 그래프·3D에서 고른 계곡 통과 시설을 목록에서 강조한다(null = 해제). */ selectPipeByChainage: (chainageM: number | null) => void; /** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */ @@ -84,94 +91,21 @@ interface StructuresCallbacks { onSelect: (structure: StructureInstance | null) => void; /** 측점간격(m) — 측점번호+잔여거리 ↔ 누가거리 환산에 쓴다. */ getInterval: () => number; - /** 계곡 통과 시설 추가 — 관 지점 정본에 넣는다(배수유역 패널 경유). */ - onPipeAdd: (chainageM: number, facility: PipeFacility) => void; + /** 계곡 통과 시설 추가 — 관 지점 정본에 넣는다(배수유역 패널 경유). + * attributes에 시설 종류·구간·부속 옵션이 담긴다. */ + onPipeAdd: (chainageM: number, attributes: FacilityAttributes) => void; + /** 계곡 통과 시설 수정 — 기준점 이동·구간·부속 옵션 반영(관 지점 정본 경유). */ + onPipeUpdate: ( + fromChainageM: number, + toChainageM: number, + attributes: FacilityAttributes, + ) => void; /** 계곡 통과 시설 삭제. */ onPipeRemove: (chainageM: number) => void; /** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫). */ onPipeSelect: (chainageM: number) => void; } -function field(labelText: string, input: HTMLElement): HTMLLabelElement { - const wrapper = document.createElement("label"); - wrapper.className = "b05-route__field"; - const caption = document.createElement("span"); - caption.textContent = labelText; - wrapper.append(caption, input); - return wrapper; -} - -function numberInput(step = "0.1", min = "0"): HTMLInputElement { - const input = document.createElement("input"); - input.type = "number"; - input.step = step; - input.min = min; - return input; -} - -function select(options: ReadonlyArray<[string, string]>): HTMLSelectElement { - const element = document.createElement("select"); - element.replaceChildren(...options.map(([value, label]) => new Option(label, value))); - return element; -} - -/** 측점번호 + 잔여거리 두 칸 묶음 — 구 비정규 측점 UI와 같은 입력 방식(2026-08-17). */ -interface StationFields { - wrap: HTMLElement; - station: HTMLInputElement; - remainder: HTMLInputElement; - /** 두 칸을 누가거리로 읽는다. 비었으면 null, 형식 오류면 붉히고 null. */ - read: (intervalM: number, required: boolean) => number | null; - /** 누가거리를 두 칸에 나눠 싣는다. null이면 비운다. */ - write: (chainageM: number | null, intervalM: number) => void; - clearInvalid: () => void; -} - -function stationFields(labelText: string): StationFields { - const station = numberInput("1", "0"); - station.placeholder = "측점"; - const remainder = numberInput("0.1", "0"); - remainder.placeholder = "+m"; - const pair = document.createElement("div"); - pair.className = "b05-structure__station-pair"; - pair.append(station, remainder); - const wrap = field(labelText, pair); - const markInvalid = (bad: boolean): void => { - station.classList.toggle("is-invalid", bad); - remainder.classList.toggle("is-invalid", bad); - }; - return { - wrap, - station, - remainder, - read(intervalM, required) { - const stationText = station.value.trim(); - const remainderText = remainder.value.trim(); - if (!stationText && !remainderText) { - markInvalid(required); - return null; - } - const stationNo = stationText ? Number(stationText) : 0; - const rest = remainderText ? Number(remainderText) : 0; - const valid = - Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0; - markInvalid(!valid); - return valid ? stationNo * intervalM + rest : null; - }, - write(chainageM, intervalM) { - if (chainageM === null) { - station.value = ""; - remainder.value = ""; - return; - } - const parts = chainageToStation(chainageM, intervalM); - station.value = String(parts.station); - remainder.value = parts.remainder.toFixed(1); - }, - clearInvalid: () => markInvalid(false), - }; -} - export function createStructuresSection(callbacks: StructuresCallbacks): StructuresSection { const root = document.createElement("section"); root.className = "b05-route__panel-section ui-collapsible ui-sidebar-section"; @@ -208,6 +142,20 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const optionRow = document.createElement("div"); optionRow.className = "b05-route__irregular-row"; + // 계곡 통과 시설(배관 등)의 부속 옵션 서브폼 — 일반 구조물의 옵션 칸과 같은 + // 자리에서, 같은 흐름(종류 선택 → 옵션 → 추가/수정)으로 편집한다(2026-08-17 + // 사용자 지시 — 자동·수동 배관은 같은 구조물, 별도 UI 금지). 기슭막이 길이를 + // 넣으면 시작·종료 측점이 기준 − 앞 ~ 기준 + 뒤로 자동 채워진다. + const facilityOptions = createFacilityOptionsForm({ + onSpanSuggest: (frontM, backM) => { + const step = interval(); + const anchor = anchorFields.read(step, false); + if (anchor === null) return; + startFields.write(Math.max(0, anchor - frontM), step); + endFields.write(anchor + backM, step); + }, + }); + const primary = document.createElement("button"); primary.type = "button"; primary.className = "b05-route__irregular-btn is-primary"; @@ -234,6 +182,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu positionRow, sideRow, optionRow, + facilityOptions.root, actions, list, ); @@ -266,20 +215,34 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const interval = (): number => callbacks.getInterval(); - /** 배치형태·관리 주체에 맞춰 위치 칸을 바꾼다. 계곡 통과 시설은 기준 측점만 받고 - * 설치측·이격도 정본(관 지점) 소관이라 감춘다. 종류 미선택(빈값)이면 입력 칸 - * 전체를 숨긴다 — 리셋 직후 상태(2026-08-17 사용자 지시). */ + /** 배치형태·관리 주체에 맞춰 위치 칸을 바꾼다. 계곡 통과 시설은 기준 측점 필수 + + * 시작·종료(범위, 기슭막이 길이로 자동 채움 가능)를 받고, 설치측·이격은 정본 + * (관 지점) 소관이라 감춘다. 종류 미선택(빈값)이면 입력 칸 전체를 숨긴다 — + * 리셋 직후 상태(2026-08-17 사용자 지시). */ function syncPlacementFields(): void { const type = currentType(); - const isInterval = !type?.managed_by && type?.placement === "interval"; + const managed = !!type?.managed_by; + const isInterval = !!type && (managed || type.placement === "interval"); positionRow.hidden = !type; startFields.wrap.hidden = !isInterval; endFields.wrap.hidden = !isInterval; - sideRow.hidden = !type || !!type.managed_by; + sideRow.hidden = !type || managed; primary.disabled = !type; - anchorFields.wrap.querySelector("span")!.textContent = isInterval - ? "기준 측점 (비우면 시작)" - : "기준 측점"; + anchorFields.wrap.querySelector("span")!.textContent = + isInterval && !managed ? "기준 측점 (비우면 시작)" : "기준 측점"; + } + + /** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설일 때만, 값은 인자로). */ + function syncFacilityForm( + options: Record = {}, + source?: PipeSource, + ): void { + const type = currentType(); + facilityOptions.setFacility( + type?.managed_by ? (type.type_id as PipeFacility) : null, + options, + source, + ); } /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07 @@ -338,10 +301,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu keepTypeId && candidates.some((type) => type.type_id === keepTypeId) ? keepTypeId : ""; syncPlacementFields(); renderOptionFields(); + syncFacilityForm(); } function syncButtons(): void { - primary.textContent = editingId ? "수정" : "추가"; + primary.textContent = editingId || selectedPipeChainage !== null ? "수정" : "추가"; removeButton.disabled = editingId === null && selectedPipeChainage === null; } @@ -417,10 +381,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu name.textContent = labelOfPipe(pipe); info.textContent = "배수유역 연동"; item.addEventListener("click", () => { - loadForm(null); - selectedPipeChainage = pipe.chainage_m; - syncButtons(); - renderList(); + loadPipeForm(pipe); callbacks.onPipeSelect(pipe.chainage_m); }); } @@ -454,6 +415,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu memoField.value = ""; renderOptionFields(); } + syncFacilityForm(); [anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid()); syncPlacementFields(); syncButtons(); @@ -461,6 +423,30 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu callbacks.onSelect(target); } + /** 계곡 통과 시설을 폼에 올린다 — 일반 구조물과 같은 흐름(종류·측점·옵션·수정). + * 정본이 관 지점이라 editingId 대신 선택 누가거리로 추적한다. */ + function loadPipeForm(pipe: PipeFacilityItem): void { + const hadStructure = editingId !== null; + editingId = null; + selectedPipeChainage = pipe.chainage_m; + const step = interval(); + const type = typeMap().get(pipe.facility); + if (type) { + groupSelect.value = type.group; + syncTypeOptions(pipe.facility); + } + anchorFields.write(pipe.chainage_m, step); + startFields.write(pipe.start_m ?? null, step); + endFields.write(pipe.end_m ?? null, step); + memoField.value = ""; + syncFacilityForm(pipe.options ?? {}, pipe.source); + [anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid()); + syncPlacementFields(); + syncButtons(); + renderList(); + if (hadStructure) callbacks.onSelect(null); + } + function readOptions(): Record { const values: Record = {}; optionInputs.forEach((input) => { @@ -481,14 +467,28 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu if (!type) return; const step = interval(); - // 계곡 통과 시설 — 기준 측점만 받아 관 지점 정본으로 보낸다(시설 종류 = type_id). + // 계곡 통과 시설 — 기준 측점 + 구간 + 부속 옵션을 관 지점 정본으로 보낸다 + // (시설 종류 = type_id). 목록에서 골라 온 경우는 수정(기준점 이동 포함)이다. if (type.managed_by) { const anchor = anchorFields.read(step, true); if (anchor === null) { anchorFields.station.focus(); return; } - callbacks.onPipeAdd(anchor, type.type_id as PipeFacility); + const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility }; + const start = startFields.read(step, false); + const end = endFields.read(step, false); + if (start !== null && end !== null && end > start) { + attributes.start_m = start; + attributes.end_m = end; + } + const options = facilityOptions.readOptions(); + if (Object.keys(options).length) attributes.options = options; + if (selectedPipeChainage !== null) { + callbacks.onPipeUpdate(selectedPipeChainage, anchor, attributes); + } else { + callbacks.onPipeAdd(anchor, attributes); + } loadForm(null); return; } @@ -620,29 +620,33 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu renderList(); }, getStructures: () => [...structures], - mountFacilityForm(element) { - // 목록 위, 액션 버튼 다음 — 계곡 시설을 고르면 그 자리에서 부속 옵션을 편집한다. - body.insertBefore(element, list); - }, setPipeFacilities(pipes) { pipeFacilities = pipes.map((pipe) => ({ ...pipe })); if ( selectedPipeChainage !== null && !pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < 0.05) ) { + // 고르고 있던 관이 사라졌다(삭제·재계산 이동) — 폼도 함께 닫는다. selectedPipeChainage = null; + facilityOptions.setFacility(null); syncButtons(); } renderList(); }, selectPipeByChainage(chainageM) { - selectedPipeChainage = chainageM; - if (chainageM !== null && editingId) { - editingId = null; - callbacks.onSelect(null); + if (chainageM === null) { + if (selectedPipeChainage === null) return; + selectedPipeChainage = null; + facilityOptions.setFacility(null); + syncButtons(); + renderList(); + return; } - syncButtons(); - renderList(); + // 그래프·3D·배수유역도에서 고른 관도 목록 강조 + 폼 로드까지 — 어느 경로든 + // 같은 편집 화면이 열린다(2026-08-17 전역 선택 동기화). + const pipe = pipeFacilities.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.05); + if (!pipe) return; + loadPipeForm(pipe); }, selectById(structureId) { if (structureId === null) { @@ -654,9 +658,9 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu addAt(chainageM, typeId) { const type = typeMap().get(typeId); if (!type) return; - // 계곡 통과 시설은 관 지점 정본으로 바로 보낸다. + // 계곡 통과 시설은 관 지점 정본으로 바로 보낸다(옵션은 추가 후 폼에서). if (type.managed_by) { - callbacks.onPipeAdd(chainageM, typeId as PipeFacility); + callbacks.onPipeAdd(chainageM, { facility: typeId as PipeFacility }); return; } const placement = placementOf(typeId);