refactor(B05): 배관 편집을 구조물 배치 폼으로 단일화 — 별도 시설 폼 폐지

- 자동 배관·수동 배관은 같은 구조물(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 통과
This commit is contained in:
2026-08-17 15:02:45 +09:00
parent 1e4da21dbc
commit 38ea71e15b
8 changed files with 364 additions and 279 deletions
+123 -119
View File
@@ -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<string, string> = {
@@ -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<string, string | number>;
}
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<string, string | number> = {},
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<string, string | number> {
const values: Record<string, string | number> = {};
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);