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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user