/* ============================================================================= * B05_Profile_UI_Structures_Panel.ts * 「구조물 배치」 사이드바 섹션 — 구조물군(A~G) → 타입 → 위치(측점) → B05 옵션. * * 구 「구조물 배치」(비정규 측점, 자유 텍스트)를 흡수한 단일 섹션이다(2026-08-17 * 컨테이너 병합). 위치 입력은 측점번호+잔여거리 두 칸이고, 순서는 시작 측점 → * 기준 측점 → 종료 측점이다(점형은 기준 측점만). 저장은 누가거리(m)로 한다. * * 계곡 통과 시설(배관/BOX암거/물넘이/세월교, A군 managed_by)은 관 지점 정본 * (`pipe_points.json`) 소관 — 여기서는 목록에 병합 표시하고 추가·삭제·선택을 * 콜백으로 배수유역 패널에 넘긴다. 상세 치수는 B06/B07 몫이라 받지 않는다. * ========================================================================== */ import { defaultOptions, isB05Option, structureAnchorM, type StructureInstance, type StructurePlacement, 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"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. */ const GROUP_LABELS: Record = { A: "A 횡단배수", B: "B 종단배수", C: "C 사면안정", D: "D 계류·사방", E: "E 안전·부대·용지", F: "F 생태·녹화", G: "G 노면공", 호환: "기타", }; const SIDE_LABELS: Array<[StructureSide, string]> = [ ["left", "좌측"], ["right", "우측"], ["center", "중심"], ["cross", "횡단"], ]; /** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */ const DEFAULT_INTERVAL_LENGTH_M = 15; /** 계곡 통과 시설 1건 — 관 지점 정본에서 온 병합 표시용 항목. */ export interface PipeFacilityItem { chainage_m: number; facility: PipeFacility; start_m?: number; end_m?: number; } export interface StructuresSection { root: HTMLElement; /** 서버에서 받은 타입 목록을 채운다(최초 1회). */ setTypes: (types: StructureType[]) => void; /** 서버 정본으로 목록을 교체한다(진입·복원·저장 후). */ setStructures: (structures: StructureInstance[]) => void; getStructures: () => StructureInstance[]; /** 계곡 통과 시설 목록을 병합 표시한다(정본 = pipe_points, 배수유역 패널 경유). */ setPipeFacilities: (pipes: PipeFacilityItem[]) => void; /** 그래프·3D에서 고른 계곡 통과 시설을 목록에서 강조한다(null = 해제). */ selectPipeByChainage: (chainageM: number | null) => void; /** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */ selectById: (structureId: string | null) => void; /** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */ addAt: (chainageM: number, typeId: string) => void; /** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */ moveById: (structureId: string, toChainageM: number) => boolean; removeById: (structureId: string) => boolean; } interface StructuresCallbacks { /** 목록이 바뀔 때(추가·수정·삭제) 전체 목록을 넘긴다. */ onChange: (structures: StructureInstance[]) => void; /** 목록에서 고르거나 해제할 때. */ onSelect: (structure: StructureInstance | null) => void; /** 측점간격(m) — 측점번호+잔여거리 ↔ 누가거리 환산에 쓴다. */ getInterval: () => number; /** 계곡 통과 시설 추가 — 관 지점 정본에 넣는다(배수유역 패널 경유). */ onPipeAdd: (chainageM: number, facility: PipeFacility) => 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"; const heading = document.createElement("h3"); heading.className = "ui-collapsible__title"; heading.textContent = "구조물 배치"; const body = document.createElement("div"); body.className = "b05-route__panel-body"; root.append(heading, body); const groupSelect = select([]); const typeSelect = select([]); const sideSelect = select(SIDE_LABELS); // 위치 입력 순서 = 시작 측점 → 기준 측점 → 종료 측점 (2026-08-17 사용자 확정). // 점형은 기준 측점만 보인다. const startFields = stationFields("시작 측점"); const anchorFields = stationFields("기준 측점"); const endFields = stationFields("종료 측점"); const offsetField = numberInput(); offsetField.value = "0"; const memoField = document.createElement("input"); memoField.type = "text"; memoField.placeholder = "메모(선택)"; const positionRow = document.createElement("div"); positionRow.className = "b05-route__irregular-row"; positionRow.append(startFields.wrap, anchorFields.wrap, endFields.wrap); const sideRow = document.createElement("div"); sideRow.className = "b05-route__irregular-row"; sideRow.append(field("설치측", sideSelect), field("이격 (m)", offsetField)); // 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다. const optionRow = document.createElement("div"); optionRow.className = "b05-route__irregular-row"; const primary = document.createElement("button"); primary.type = "button"; primary.className = "b05-route__irregular-btn is-primary"; const removeButton = document.createElement("button"); removeButton.type = "button"; removeButton.className = "b05-route__irregular-btn is-danger"; removeButton.textContent = "삭제"; const resetButton = document.createElement("button"); resetButton.type = "button"; resetButton.className = "b05-route__irregular-btn"; resetButton.textContent = "리셋"; const actions = document.createElement("div"); actions.className = "b05-route__irregular-actions"; actions.append(primary, removeButton, resetButton); const list = document.createElement("ul"); list.className = "b05-route__irregular-list"; // 사용법 설명 문단은 두지 않는다 — 공간 대비 의미 없음(2026-08-17 사용자 지시, // 필요 시 별도 설명 페이지로). body.append( field("구조물군", groupSelect), field("종류", typeSelect), positionRow, sideRow, optionRow, actions, list, ); let types: StructureType[] = []; let structures: StructureInstance[] = []; let pipeFacilities: PipeFacilityItem[] = []; let editingId: string | null = null; /** 목록에서 고른 계곡 통과 시설(누가거리 키). 삭제 버튼이 이쪽으로 동작한다. */ let selectedPipeChainage: number | null = null; let optionInputs: Array<{ key: string; required: boolean; input: HTMLInputElement | HTMLSelectElement; read: () => string | number; isEmpty: () => boolean; }> = []; function typeMap(): Map { return new Map(types.map((type) => [type.type_id, type])); } function currentType(): StructureType | null { return typeMap().get(typeSelect.value) ?? null; } function placementOf(typeId: string): StructurePlacement { return typeMap().get(typeId)?.placement ?? "point"; } const interval = (): number => callbacks.getInterval(); /** 배치형태·관리 주체에 맞춰 위치 칸을 바꾼다. 계곡 통과 시설은 기준 측점만 받고 * 설치측·이격도 정본(관 지점) 소관이라 감춘다. 종류 미선택(빈값)이면 입력 칸 * 전체를 숨긴다 — 리셋 직후 상태(2026-08-17 사용자 지시). */ function syncPlacementFields(): void { const type = currentType(); const isInterval = !type?.managed_by && type?.placement === "interval"; positionRow.hidden = !type; startFields.wrap.hidden = !isInterval; endFields.wrap.hidden = !isInterval; sideRow.hidden = !type || !!type.managed_by; primary.disabled = !type; anchorFields.wrap.querySelector("span")!.textContent = isInterval ? "기준 측점 (비우면 시작)" : "기준 측점"; } /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07 * 몫이라 그리지 않는다(B05 = 유무·종류·위치 단계, 2026-08-17 사용자 확정). */ function renderOptionFields(values: Record = {}): void { const type = currentType(); optionInputs = []; optionRow.replaceChildren(); const visible = type && !type.managed_by ? type.options.filter(isB05Option) : []; if (!visible.length) { optionRow.hidden = true; return; } optionRow.hidden = false; visible.forEach((option) => { const preset = values[option.key] ?? option.default ?? ""; let input: HTMLInputElement | HTMLSelectElement; if (option.input === "select") { // 필수 선택지는 첫 항목을 슬쩍 고른 것처럼 두지 않는다 — 재료·형식은 설계자가 // 고르는 값이라, 안 고른 상태를 빈칸으로 드러낸다(2026-08-16 크로스체크 지적 2). const choices = option.choices.map((choice) => [choice, choice] as [string, string]); input = select(option.required ? [["", "선택하세요"], ...choices] : choices); input.value = String(preset || (option.required ? "" : (option.choices[0] ?? ""))); } else if (option.input === "number") { input = numberInput("0.1", "0"); input.value = String(preset ?? ""); if (option.required) (input as HTMLInputElement).placeholder = "필수 입력"; } else { input = document.createElement("input"); (input as HTMLInputElement).type = "text"; input.value = String(preset ?? ""); } const label = option.unit ? `${option.label} (${option.unit})` : option.label; optionRow.append(field(label, input)); optionInputs.push({ key: option.key, required: !!option.required, input, read: () => (option.input === "number" ? Number(input.value) || 0 : input.value), isEmpty: () => input.value.trim() === "", }); }); } /** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도 * 포함한다(추가 시 관 지점 정본으로 간다, 2026-08-17 통합). 첫 항목은 빈칸 — * 초기·리셋 상태를 "안 고름"으로 드러낸다. */ function syncTypeOptions(keepTypeId?: string): void { const group = groupSelect.value; const candidates = group ? types.filter((type) => type.group === group) : []; typeSelect.replaceChildren( new Option("— 선택 —", ""), ...candidates.map((type) => new Option(type.name, type.type_id)), ); typeSelect.value = keepTypeId && candidates.some((type) => type.type_id === keepTypeId) ? keepTypeId : ""; syncPlacementFields(); renderOptionFields(); } function syncButtons(): void { primary.textContent = editingId ? "수정" : "추가"; 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; const step = interval(); const position = pipe.start_m !== undefined && pipe.end_m !== undefined && pipe.end_m > pipe.start_m ? `${formatStation(pipe.start_m, step)}~${formatStation(pipe.end_m, step)}` : formatStation(pipe.chainage_m, step); return `${position} · ${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", () => { loadForm(null); selectedPipeChainage = pipe.chainage_m; syncButtons(); renderList(); callbacks.onPipeSelect(pipe.chainage_m); }); } item.append(name, info); list.append(item); }); } function loadForm(target: StructureInstance | null): void { editingId = target?.structure_id ?? null; selectedPipeChainage = null; const step = interval(); if (target) { const type = typeMap().get(target.type_id); if (type) { groupSelect.value = type.group; syncTypeOptions(target.type_id); } anchorFields.write(structureAnchorM(target), step); startFields.write(target.start_m ?? null, step); endFields.write(target.end_m ?? null, step); sideSelect.value = target.side; offsetField.value = String(target.offset_m ?? 0); memoField.value = target.memo ?? ""; renderOptionFields(target.options); } else { anchorFields.write(null, step); startFields.write(null, step); endFields.write(null, step); offsetField.value = "0"; memoField.value = ""; renderOptionFields(); } [anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid()); syncPlacementFields(); syncButtons(); renderList(); callbacks.onSelect(target); } function readOptions(): Record { const values: Record = {}; optionInputs.forEach((input) => { // 빈 칸은 아예 넣지 않는다 — 빈 숫자를 0으로 저장하면 "0으로 확정"과 구분이 안 된다. if (input.isEmpty()) return; values[input.key] = input.read(); }); return values; } function emit(): void { renderList(); callbacks.onChange([...structures]); } function commit(): void { const type = currentType(); if (!type) return; const step = interval(); // 계곡 통과 시설 — 기준 측점만 받아 관 지점 정본으로 보낸다(시설 종류 = 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); loadForm(null); return; } const placement = type.placement; let anchor: number | null; let start: number | null = null; let end: number | null = null; if (placement === "interval") { start = startFields.read(step, true); end = endFields.read(step, true); if (start === null || end === null || end <= start) { (start === null ? startFields.station : endFields.station).focus(); return; } // 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다. anchor = anchorFields.read(step, false) ?? start; if (anchor < start || anchor > end) { anchorFields.station.classList.add("is-invalid"); anchorFields.station.focus(); return; } } else { anchor = anchorFields.read(step, true); if (anchor === null) { anchorFields.station.focus(); return; } } // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다). const missing = 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, side: sideSelect.value as StructureSide, offset_m: Number(offsetField.value) || 0, options: readOptions(), memo: memoField.value.trim(), placement_source: "manual" as const, status: "draft" as const, revision: 0, geometry: null, }; if (editingId) { const index = structures.findIndex((entry) => entry.structure_id === editingId); if (index >= 0) structures[index] = { ...structures[index], ...base }; } else { structures.push({ ...base, structure_id: null }); } loadForm(null); emit(); } groupSelect.addEventListener("change", () => { syncTypeOptions(); // 종류가 바뀌면 기존 항목 수정이 아니라 새 항목 추가로 넘어간다(옵션 스키마가 달라진다). if (editingId) { editingId = null; syncButtons(); renderList(); callbacks.onSelect(null); } }); typeSelect.addEventListener("change", () => { syncPlacementFields(); renderOptionFields(); if (editingId) { editingId = null; syncButtons(); renderList(); callbacks.onSelect(null); } }); primary.addEventListener("click", commit); removeButton.addEventListener("click", () => { // 계곡 통과 시설이 골라져 있으면 관 지점 정본에서 지운다(배수유역 패널 경유). if (selectedPipeChainage !== null) { callbacks.onPipeRemove(selectedPipeChainage); selectedPipeChainage = null; syncButtons(); renderList(); return; } if (!editingId) return; const index = structures.findIndex((entry) => entry.structure_id === editingId); if (index >= 0) structures.splice(index, 1); loadForm(null); emit(); }); resetButton.addEventListener("click", () => { // 리셋은 폼만 아니라 구조물군·종류도 빈칸으로 되돌린다(2026-08-17 사용자 지시). groupSelect.value = ""; syncTypeOptions(); loadForm(null); }); body.insertBefore(field("메모", memoField), actions); syncButtons(); renderList(); return { root, setTypes(next) { types = next; const groups = [...new Set(next.map((t) => t.group))]; // 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. groupSelect.replaceChildren( new Option("— 선택 —", ""), ...groups.map((group) => new Option(GROUP_LABELS[group] ?? group, group)), ); groupSelect.value = ""; syncTypeOptions(); }, setStructures(next) { // 서버 정본 주입 — onChange를 울리지 않는다. 울리면 Page가 다시 저장을 걸어 // 저장→재조회→주입→저장의 무한 고리가 된다(변경 알림은 사용자 조작에서만). structures = next.map((entry) => ({ ...entry })); loadForm(null); renderList(); }, getStructures: () => [...structures], setPipeFacilities(pipes) { pipeFacilities = pipes.map((pipe) => ({ ...pipe })); if ( selectedPipeChainage !== null && !pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < 0.05) ) { selectedPipeChainage = null; syncButtons(); } renderList(); }, selectPipeByChainage(chainageM) { selectedPipeChainage = chainageM; if (chainageM !== null && editingId) { editingId = null; callbacks.onSelect(null); } syncButtons(); renderList(); }, selectById(structureId) { if (structureId === null) { loadForm(null); return; } loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null); }, addAt(chainageM, typeId) { const type = typeMap().get(typeId); if (!type) return; // 계곡 통과 시설은 관 지점 정본으로 바로 보낸다. if (type.managed_by) { callbacks.onPipeAdd(chainageM, typeId as PipeFacility); return; } const placement = placementOf(typeId); const step = interval(); // B05 단계 필수 옵션이 있는 타입만 폼을 연다 — 상세(detail) 필수는 B06/B07 // 몫이라 바로 추가해도 서버가 받는다(2026-08-17 phase 분리). if (type.options.some((option) => option.required && isB05Option(option))) { groupSelect.value = type.group; syncTypeOptions(typeId); typeSelect.value = typeId; editingId = null; anchorFields.write(chainageM, step); startFields.write(placement === "interval" ? chainageM : null, step); endFields.write( placement === "interval" ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step, ); sideSelect.value = "center"; offsetField.value = "0"; memoField.value = ""; syncPlacementFields(); renderOptionFields(); syncButtons(); renderList(); root.scrollIntoView({ block: "nearest" }); const firstRequired = optionInputs.find((entry) => entry.required); (firstRequired?.input ?? anchorFields.station).focus(); return; } structures.push({ structure_id: null, type_id: typeId, placement, chainage_m: chainageM, start_m: placement === "interval" ? chainageM : null, end_m: placement === "interval" ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, side: "center", offset_m: 0, options: defaultOptions(type), memo: "", placement_source: "manual", status: "draft", revision: 0, geometry: null, }); emit(); }, moveById(structureId, toChainageM) { const index = structures.findIndex((entry) => entry.structure_id === structureId); if (index < 0) return false; const target = structures[index]; if (target.placement === "interval") { // 구간은 길이를 유지한 채 통째로 옮긴다 — 기준점을 끌면 시작·종료가 따라온다. const length = (target.end_m ?? 0) - (target.start_m ?? 0); const anchorOffset = structureAnchorM(target) - (target.start_m ?? 0); const start = toChainageM - anchorOffset; structures[index] = { ...target, chainage_m: toChainageM, start_m: start, end_m: start + length, }; } else { structures[index] = { ...target, chainage_m: toChainageM }; } emit(); return true; }, removeById(structureId) { const index = structures.findIndex((entry) => entry.structure_id === structureId); if (index < 0) return false; structures.splice(index, 1); if (editingId === structureId) loadForm(null); emit(); return true; }, }; }