/* ============================================================================= * B05_Profile_UI_Structures_Panel_Commit.ts * 구조물 배치 패널의 **폼 ↔ 정본 왕복** — 폼 불러오기(구조물·계곡 통과 시설)와 * [추가]/[수정] 저장, 목록 알림. * * `B05_Profile_UI_Structures_Panel` 이 700줄을 넘겨 떼어낸 조각이다(2026-09-04). * 검증 순서·값·분기는 옮기기 전 그대로이고, 패널 클로저가 쥐던 값만 `ctx` 로 받는다 * (가변 상태 4개는 읽기 함수 + set* 로 넘긴다 — 주인은 그대로 패널이다). * ========================================================================== */ import { structureAnchorM, type StructureInstance, type StructureType, } from "./B05_Profile_Api_Structures"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility"; import type { StationFields } from "./B05_Profile_UI_Structures_Fields"; import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; import type { PipeFacilityItem, StructuresCallbacks, } from "./B05_Profile_UI_Structures_Panel_Types"; /** 패널이 쥔 값 중 폼 왕복·저장에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ export interface StructuresCommitContext { callbacks: StructuresCallbacks; structures: StructureInstance[]; groupSelect: HTMLSelectElement; memoField: HTMLInputElement; anchorFields: StationFields; startFields: StationFields; endFields: StationFields; facilityOptions: FacilityOptionsForm; optionInputs: Array<{ key: string; required: boolean; input: HTMLInputElement | HTMLSelectElement; read: () => string | number; isEmpty: () => boolean; }>; /** 계곡 통과 시설 목록 — 포장 구간이 물넘이와 겹치는지 판정할 때 쓴다. */ pipeFacilities: () => PipeFacilityItem[]; interval: () => number; typeMap: () => Map; currentType: () => StructureType | null; facilityFormKind: (type: StructureType | null) => PipeFacility | null; hasComputedRange: (type: StructureType | null) => boolean; readLengthM: () => number | null; readBeforeM: () => number; cancelTempPipe: () => void; renderList: () => void; renderOptionFields: (values?: Record) => void; syncButtons: () => void; syncFacilityForm: (options?: Record, designFlow?: number | null) => void; syncPlacementFields: () => void; syncTypeOptions: (keepTypeId?: string) => void; /* 가변 상태 — 주인은 패널이다. */ editingId: () => string | null; setEditingId: (value: string | null) => void; selectedPipeChainage: () => number | null; setSelectedPipeChainage: (value: number | null) => void; positionConfirmed: () => boolean; setPositionConfirmed: (value: boolean) => void; tempPipeChainage: () => number | null; setTempPipeChainage: (value: number | null) => void; } export function loadForm(ctx: StructuresCommitContext, target: StructureInstance | null): void { // 다른 항목으로 넘어가면 임시 배치(A군 미리보기)는 취소된다. ctx.cancelTempPipe(); ctx.setEditingId(target?.structure_id ?? null); ctx.setSelectedPipeChainage(null); // 위치가 실려 열리는 폼(기존 항목)은 확정 상태, 빈 폼은 미확정으로 시작한다. ctx.setPositionConfirmed(target !== null); const step = ctx.interval(); if (target) { const type = ctx.typeMap().get(target.type_id); if (type) { ctx.groupSelect.value = type.group; ctx.syncTypeOptions(target.type_id); } ctx.anchorFields.write(structureAnchorM(target), step); ctx.startFields.write(target.start_m ?? null, step); ctx.endFields.write(target.end_m ?? null, step); ctx.memoField.value = target.memo ?? ""; ctx.renderOptionFields(target.options); // 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된 // 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19). if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) { const lengthEntry = ctx.optionInputs.find((entry) => entry.key === "length_m"); if (lengthEntry && lengthEntry.isEmpty()) { lengthEntry.input.value = (target.end_m - target.start_m).toFixed(1); } const beforeEntry = ctx.optionInputs.find((entry) => entry.key === "before_m"); if (beforeEntry && target.options.before_m === undefined) { // 옵션이 없던 시절 항목은 기본값(5)이 아니라 저장된 기준−시작을 그대로 쓴다 // — 0이어도 명시해야 기본값이 범위를 옆으로 밀지 않는다. const before = Math.max(structureAnchorM(target) - target.start_m, 0); beforeEntry.input.value = before.toFixed(1); } const afterEntry = ctx.optionInputs.find((entry) => entry.key === "after_m"); if (afterEntry && target.options.after_m === undefined) { const after = Math.max(target.end_m - structureAnchorM(target), 0); afterEntry.input.value = after.toFixed(1); } } ctx.syncFacilityForm(target.options); } else { ctx.anchorFields.write(null, step); ctx.startFields.write(null, step); ctx.endFields.write(null, step); ctx.memoField.value = ""; ctx.renderOptionFields(); ctx.syncFacilityForm(); } [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid()); ctx.syncPlacementFields(); ctx.syncButtons(); ctx.renderList(); ctx.callbacks.onSelect(target); } /** 계곡 통과 시설을 폼에 올린다 — 일반 구조물과 같은 흐름(종류·측점·옵션·수정). * 정본이 관 지점이라 ctx.editingId() 대신 선택 누가거리로 추적한다. */ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityItem): void { // 임시 배치 중인 그 관이 재계산을 거쳐 되돌아온 경우 — 사용자가 폼에 입력하는 // 중이므로 폼을 다시 그리지 않는다(입력이 지워진다, 2026-08-18 사용자 보고). // 재계산으로 갱신된 설계유량만 살짝 반영하고 선택 표시를 맞춘다. // 관은 계획선에 스냅되며 입력 누가거리와 살짝 어긋날 수 있다 — 0.51m까지 같은 // 관으로 본다(다른 곳의 관 매칭 기준과 동일). const isTemp = ctx.tempPipeChainage() !== null && Math.abs(pipe.chainage_m - (ctx.tempPipeChainage() as number)) < 0.51; if (isTemp) { ctx.setTempPipeChainage(pipe.chainage_m); ctx.setSelectedPipeChainage(pipe.chainage_m); ctx.facilityOptions.setDesignFlow(pipe.design_flow_m3s ?? null); ctx.syncButtons(); ctx.renderList(); return; } // 다른 항목으로 넘어가는 것이므로 임시 배치를 취소한다. ctx.cancelTempPipe(); const hadStructure = ctx.editingId() !== null; ctx.setEditingId(null); ctx.setSelectedPipeChainage(pipe.chainage_m); ctx.setPositionConfirmed(true); // 기존 관 — 위치가 실려 열린다. const step = ctx.interval(); const type = ctx.typeMap().get(pipe.facility); if (type) { ctx.groupSelect.value = type.group; ctx.syncTypeOptions(pipe.facility); } ctx.anchorFields.write(pipe.chainage_m, step); // 계곡 통과 시설은 점형 — 시작·종료 칸은 감춰지므로 비워 둔다. ctx.startFields.write(null, step); ctx.endFields.write(null, step); ctx.memoField.value = ""; ctx.syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null); [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid()); ctx.syncPlacementFields(); ctx.syncButtons(); ctx.renderList(); if (hadStructure) ctx.callbacks.onSelect(null); } export function readOptions(ctx: StructuresCommitContext): Record { // 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다. if (ctx.facilityFormKind(ctx.currentType())) return ctx.facilityOptions.readOptions(); const values: Record = {}; ctx.optionInputs.forEach((input) => { // 빈 칸은 아예 넣지 않는다 — 빈 숫자를 0으로 저장하면 "0으로 확정"과 구분이 안 된다. if (input.isEmpty()) return; values[input.key] = input.read(); }); return values; } export function emit(ctx: StructuresCommitContext): void { ctx.renderList(); ctx.callbacks.onChange([...ctx.structures]); } export async function commit(ctx: StructuresCommitContext, live = false): Promise { const type = ctx.currentType(); if (!type) return; const step = ctx.interval(); // 계곡 통과 시설 — 기준 측점 + 구간 + 부속 옵션을 관 지점 정본으로 보낸다 // (시설 종류 = type_id). 목록에서 골라 온 경우는 수정(기준점 이동 포함)이다. if (type.managed_by) { const anchor = ctx.anchorFields.read(step, true); if (anchor === null) { ctx.anchorFields.station.focus(); return; } // 배수관 등 계곡 통과 시설은 점형 — 기준 측점 하나뿐이다(2026-08-17 지시 2). const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility }; const options = ctx.facilityOptions.readOptions(); if (Object.keys(options).length) attributes.options = options; if (ctx.tempPipeChainage() !== null) { // 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영. const from = ctx.tempPipeChainage() as number; ctx.setTempPipeChainage(null); ctx.callbacks.onPipeUpdate(from, anchor, attributes); } else if (ctx.selectedPipeChainage() !== null) { ctx.callbacks.onPipeUpdate(ctx.selectedPipeChainage() as number, anchor, attributes); } else { ctx.callbacks.onPipeAdd(anchor, attributes); } // 실시간 반영 중에는 폼을 닫지 않는다 — 이어서 다른 칸도 고쳐야 한다. if (!live) loadForm(ctx, null); return; } const placement = type.placement; let anchor: number | null; let start: number | null = null; let end: number | null = null; if (placement === "interval" && ctx.hasComputedRange(type)) { // 범위 계산 타입(C군 등) — 시작 = 기준 − 전길이, 종료 = 시작 + 길이 // (2026-08-19 사용자 지시: 전/후 분할). 전길이가 기준을 넘어 시작이 음수면 거절. anchor = ctx.anchorFields.read(step, true); if (anchor === null) { ctx.anchorFields.station.focus(); return; } const length = ctx.readLengthM(); if (length === null) { ctx.optionInputs.find((entry) => entry.key === "length_m")?.input.focus(); return; } // 높이는 비워도 놓음 — 자리부터 잡고 치수를 뒤에 넣는 길을 막지 않음. 높이가 물량 밑수인 // 벽은 B08 에서 줄만 서고 미확정(금액 밖)으로 뜸(2026-09-14 브레인 판정 ①, `missing_height_reason`). const before = ctx.readBeforeM(); if (anchor - before < -0.005) { const beforeInput = ctx.optionInputs.find((entry) => entry.key === "before_m")?.input; beforeInput?.classList.add("is-invalid"); beforeInput?.focus(); return; } start = Math.max(anchor - before, 0); end = start + length; } else if (placement === "interval") { start = ctx.startFields.read(step, true); end = ctx.endFields.read(step, true); if (start === null || end === null || end <= start) { (start === null ? ctx.startFields.station : ctx.endFields.station).focus(); return; } // 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다. anchor = ctx.anchorFields.read(step, false) ?? start; if (anchor < start || anchor > end) { ctx.anchorFields.station.classList.add("is-invalid"); ctx.anchorFields.station.focus(); return; } } else { anchor = ctx.anchorFields.read(step, true); if (anchor === null) { ctx.anchorFields.station.focus(); return; } } // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다). const missing = ctx.optionInputs.find((entry) => entry.required && entry.isEmpty()); if (missing) { missing.input.focus(); return; } const base = { type_id: type.type_id, placement, chainage_m: anchor, start_m: start, end_m: end, options: readOptions(ctx), memo: ctx.memoField.value.trim(), placement_source: "manual" as const, status: "draft" as const, revision: 0, geometry: null, }; // 포장 구간은 물넘이포장과 겹칠 수 없다 — 통째로 품으면 나누고, 끝에 걸치면 멈춘다 // (2026-08-28 사용자 확정). 다른 타입은 원래 구간 한 벌 그대로다. const spans = type.type_id === "pavement_concrete" && start !== null && end !== null ? await splitPavementRange(start, end, ctx.pipeFacilities()) : [{ start, end }]; if (!spans) return; const records = spans.map((span) => ({ ...base, start_m: span.start, end_m: span.end, chainage_m: span.start === null || span.end === null ? anchor : Math.min(Math.max(anchor as number, span.start), span.end), })); if (ctx.editingId()) { const index = ctx.structures.findIndex((entry) => entry.structure_id === ctx.editingId()); if (index >= 0) ctx.structures[index] = { ...ctx.structures[index], ...records[0] }; records.slice(1).forEach((record) => ctx.structures.push({ ...record, structure_id: null })); } else { records.forEach((record) => ctx.structures.push({ ...record, structure_id: null })); } if (!live) loadForm(ctx, null); emit(ctx); }