- 자동 배관·수동 배관은 같은 구조물(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 통과
467 lines
19 KiB
TypeScript
467 lines
19 KiB
TypeScript
/* =============================================================================
|
||
* B05_Profile_UI_Drainage_Facility.ts
|
||
* 계곡 통과 시설(배관/BOX암거/물넘이포장/세월교)의 부속 옵션 서브폼 + 시설 보관소.
|
||
*
|
||
* 자동 배관이든 수동 배관이든 같은 구조물이다 — 별도 편집 UI를 두지 않고 사이드
|
||
* 「구조물 배치」 폼의 옵션 자리에 이 서브폼이 끼워진다(2026-08-17 사용자 지시).
|
||
* 시설 종류는 구조물 배치의 "종류" 드롭다운이, 위치(시작·기준·종료 측점)는 위치
|
||
* 칸이 담당하므로 여기에는 부속 옵션 필드만 있다.
|
||
*
|
||
* 배관 부속(2026-08-17 확정): 유형(계곡부형/보완형 — 자동 배치 source로 제안),
|
||
* 집수정 유무(토글 시 유입부 옵션 전환), 관경(기본 1000), 기슭막이(길이 총/앞/뒤
|
||
* 상호 연계 — 범위를 만든다 → onSpanSuggest로 위치 칸에 반영), 돌붙임. 형식·치수
|
||
* 제원은 B06/B07 필수 승격 대상이라 보이되 비필수다.
|
||
* ========================================================================== */
|
||
|
||
import type {
|
||
DetailPipeInput,
|
||
PipeFacility,
|
||
PipeSource,
|
||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||
|
||
/** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을
|
||
* 가지면 저장한다. */
|
||
export interface FacilityAttributes {
|
||
facility: PipeFacility;
|
||
start_m?: number;
|
||
end_m?: number;
|
||
options?: Record<string, string | number>;
|
||
}
|
||
|
||
/** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */
|
||
function hasExtras(attributes: FacilityAttributes): boolean {
|
||
if (attributes.facility !== "pipe") return true;
|
||
if (attributes.start_m !== undefined || attributes.end_m !== undefined) return true;
|
||
return !!attributes.options && Object.keys(attributes.options).length > 0;
|
||
}
|
||
|
||
/** 관 이동·재계산 왕복에서 시설 정보를 잃지 않게 chainage 최근접으로 되붙이는 보관소.
|
||
* 백엔드 `carry_facility_attributes`와 같은 기준(정확 일치 → 최근접)이다. */
|
||
export function createFacilityStore() {
|
||
let entries: Array<{ chainage: number; attributes: FacilityAttributes }> = [];
|
||
|
||
return {
|
||
/** 서버 응답으로 전체를 다시 채운다 — 응답이 정본이다. */
|
||
replaceFromResponse(
|
||
points: ReadonlyArray<{
|
||
chainage_m: number;
|
||
facility?: PipeFacility;
|
||
start_m?: number;
|
||
end_m?: number;
|
||
options?: Record<string, string | number>;
|
||
}>,
|
||
): void {
|
||
entries = points
|
||
.map((point) => ({
|
||
chainage: point.chainage_m,
|
||
attributes: {
|
||
facility: (point.facility ?? "pipe") as PipeFacility,
|
||
start_m: point.start_m,
|
||
end_m: point.end_m,
|
||
options: point.options,
|
||
},
|
||
}))
|
||
.filter((entry) => hasExtras(entry.attributes));
|
||
},
|
||
/** 한 지점의 시설을 바꾼다(맨 배관으로 되돌리면 엔트리 삭제). */
|
||
set(chainageM: number, attributes: FacilityAttributes | null): void {
|
||
entries = entries.filter((entry) => Math.abs(entry.chainage - chainageM) > 0.005);
|
||
if (attributes && hasExtras(attributes)) {
|
||
entries.push({ chainage: chainageM, attributes });
|
||
}
|
||
},
|
||
get(chainageM: number): FacilityAttributes | null {
|
||
const hit = entries.find((entry) => Math.abs(entry.chainage - chainageM) <= 0.005);
|
||
return hit ? hit.attributes : null;
|
||
},
|
||
/** 요청 목록에 시설 정보를 되붙인다. 관이 끌려 이동한 뒤에도(정확 일치 없음)
|
||
* 가장 가까운 엔트리를 한 번씩만 소비해 승계한다. */
|
||
attach(points: ReadonlyArray<{ chainage_m: number; source: PipeSource }>): DetailPipeInput[] {
|
||
const pool = [...entries];
|
||
return points.map((point) => {
|
||
if (!pool.length) return { ...point };
|
||
let best = 0;
|
||
for (let index = 1; index < pool.length; index += 1) {
|
||
if (
|
||
Math.abs(pool[index].chainage - point.chainage_m) <
|
||
Math.abs(pool[best].chainage - point.chainage_m)
|
||
)
|
||
best = index;
|
||
}
|
||
const [entry] = pool.splice(best, 1);
|
||
return { ...point, ...entry.attributes };
|
||
});
|
||
},
|
||
};
|
||
}
|
||
|
||
export type FacilityStore = ReturnType<typeof createFacilityStore>;
|
||
|
||
interface FacilityOptionsFormCallbacks {
|
||
/** 아무 옵션이나 바뀌었을 때 — 패널이 저장 흐름(수정 확정)을 안내하는 데 쓴다. */
|
||
onChange?: () => void;
|
||
/** 기슭막이 총/앞/뒤가 정해졌을 때 — 위치 칸(시작·종료 측점) 자동 채움 제안.
|
||
* 범위 = 기준 − 앞 ~ 기준 + 뒤 (2026-08-17 확정). */
|
||
onSpanSuggest?: (frontM: number, backM: number) => void;
|
||
}
|
||
|
||
export interface FacilityOptionsForm {
|
||
root: HTMLElement;
|
||
/** 시설 종류에 맞는 옵션 필드를 보여주고 값을 채운다. null이면 통째로 숨긴다.
|
||
* source는 배관 유형(계곡부형/보완형) 제안 근거다. */
|
||
setFacility: (
|
||
facility: PipeFacility | null,
|
||
options?: Record<string, string | number>,
|
||
source?: PipeSource,
|
||
) => void;
|
||
/** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). */
|
||
readOptions: () => Record<string, string | number>;
|
||
}
|
||
|
||
function labeled(text: string, input: HTMLElement): HTMLLabelElement {
|
||
const wrapper = document.createElement("label");
|
||
wrapper.className = "b05-route__field";
|
||
const caption = document.createElement("span");
|
||
caption.textContent = text;
|
||
wrapper.append(caption, input);
|
||
return wrapper;
|
||
}
|
||
|
||
function numberInput(step: string, min: string, placeholder = ""): HTMLInputElement {
|
||
const input = document.createElement("input");
|
||
input.type = "number";
|
||
input.step = step;
|
||
input.min = min;
|
||
if (placeholder) input.placeholder = placeholder;
|
||
return input;
|
||
}
|
||
|
||
/** 첫 항목이 빈값(자동/미지정)인 셀렉트 — 명시하지 않으면 저장하지 않는다. */
|
||
function optionalSelect(blankLabel: string, choices: ReadonlyArray<string>): HTMLSelectElement {
|
||
const element = document.createElement("select");
|
||
element.replaceChildren(
|
||
new Option(blankLabel, ""),
|
||
...choices.map((choice) => new Option(choice, choice)),
|
||
);
|
||
return element;
|
||
}
|
||
|
||
function row(...fields: HTMLElement[]): HTMLDivElement {
|
||
const div = document.createElement("div");
|
||
div.className = "b05-route__irregular-row";
|
||
div.append(...fields);
|
||
return div;
|
||
}
|
||
|
||
/** 자동 배치 source → 유형 제안 (2026-08-17 확정: 세류선 교차 = 계곡부형(유량 多),
|
||
* 간격 보충 = 보완형(유량 少·집수정), 수동 = 미지정). */
|
||
function suggestFlowType(source: PipeSource | undefined): string {
|
||
if (source === "stream") return "계곡부형";
|
||
if (source === "spacing") return "보완형";
|
||
return "";
|
||
}
|
||
|
||
/** 구조물 배치 폼의 옵션 자리에 끼워지는 계곡 통과 시설 부속 옵션 서브폼. */
|
||
export function createFacilityOptionsForm(
|
||
callbacks: FacilityOptionsFormCallbacks = {},
|
||
): FacilityOptionsForm {
|
||
const root = document.createElement("div");
|
||
root.className = "b05-drainage__facility";
|
||
root.hidden = true;
|
||
|
||
// ── 배관 부속 (2026-08-17 확정) ──────────────────────────────────────────
|
||
const flowSelect = optionalSelect("미지정", ["계곡부형", "보완형"]);
|
||
const catchBasinSelect = optionalSelect("자동(유형 따름)", ["있음", "없음"]);
|
||
const catchBasinFormSelect = optionalSelect("선택", [
|
||
"동물이동형",
|
||
"□형(기본형)",
|
||
"돌집수정 ㄴ형",
|
||
"돌집수정 ㄷ형",
|
||
]);
|
||
const pipeDiameterSelect = document.createElement("select");
|
||
pipeDiameterSelect.replaceChildren(
|
||
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
|
||
);
|
||
pipeDiameterSelect.value = "1000"; // 별표2 원칙값 + 사용자 확정 기본 (2026-08-17)
|
||
const pipeMaterialSelect = optionalSelect("선택", ["흄관", "VR관", "파형강관"]);
|
||
const revetSelect = optionalSelect("자동(유형 따름)", ["있음", "없음"]);
|
||
const revetLength = numberInput("0.1", "0");
|
||
const revetFront = numberInput("0.1", "0");
|
||
const revetBack = numberInput("0.1", "0");
|
||
const revetFormSelect = optionalSelect("선택", [
|
||
"돌쌓기(찰)",
|
||
"돌쌓기(메)",
|
||
"돌붙임",
|
||
"콘크리트",
|
||
"돌망태",
|
||
"통나무·목재틀",
|
||
"바자",
|
||
]);
|
||
const pitchSelect = optionalSelect("자동(유형 따름)", ["있음", "없음"]);
|
||
const pitchTypeSelect = optionalSelect("선택", ["찰", "메"]);
|
||
|
||
// ── BOX암거 — 본체·날개벽 (짧은쪽 높이/길이/각도, 유입·유출 개별) ─────────
|
||
const boxWidth = numberInput("0.1", "0");
|
||
const boxHeight = numberInput("0.1", "0");
|
||
const wingIn = optionalSelect("미지정", ["있음", "없음"]);
|
||
const wingInHeight = numberInput("0.1", "0");
|
||
const wingInLength = numberInput("0.1", "0");
|
||
const wingInAngle = numberInput("1", "0");
|
||
const wingOut = optionalSelect("미지정", ["있음", "없음"]);
|
||
const wingOutHeight = numberInput("0.1", "0");
|
||
const wingOutLength = numberInput("0.1", "0");
|
||
const wingOutAngle = numberInput("1", "0");
|
||
|
||
// ── 세월교 — 구체 내 배관 종류/크기/수량으로 폭을 정한다 ─────────────────
|
||
const fordKindSelect = optionalSelect("선택", ["흄관", "VR관", "파형강관"]);
|
||
const fordDiameterSelect = optionalSelect("선택", ["800", "1000", "1200", "1500"]);
|
||
const fordCount = numberInput("1", "1", "련");
|
||
|
||
const pipeTypeRow = row(
|
||
labeled("유형", flowSelect),
|
||
labeled("관경", pipeDiameterSelect),
|
||
labeled("관종", pipeMaterialSelect),
|
||
);
|
||
const pipeInletRow = row(
|
||
labeled("집수정", catchBasinSelect),
|
||
labeled("집수정 형식", catchBasinFormSelect),
|
||
);
|
||
const revetRow = row(
|
||
labeled("기슭막이", revetSelect),
|
||
labeled("길이 (m)", revetLength),
|
||
labeled("앞 (m)", revetFront),
|
||
labeled("뒤 (m)", revetBack),
|
||
labeled("형식", revetFormSelect),
|
||
);
|
||
const pitchRow = row(labeled("돌붙임", pitchSelect), labeled("형식", pitchTypeSelect));
|
||
const boxRow = row(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight));
|
||
const wingInRow = row(
|
||
labeled("날개벽(유입)", wingIn),
|
||
labeled("짧은쪽 높이 (m)", wingInHeight),
|
||
labeled("길이 (m)", wingInLength),
|
||
labeled("각도 (°)", wingInAngle),
|
||
);
|
||
const wingOutRow = row(
|
||
labeled("날개벽(유출)", wingOut),
|
||
labeled("짧은쪽 높이 (m)", wingOutHeight),
|
||
labeled("길이 (m)", wingOutLength),
|
||
labeled("각도 (°)", wingOutAngle),
|
||
);
|
||
const fordRow = row(
|
||
labeled("배관 종류", fordKindSelect),
|
||
labeled("배관 크기", fordDiameterSelect),
|
||
labeled("수량 (련)", fordCount),
|
||
);
|
||
|
||
root.append(
|
||
pipeTypeRow,
|
||
pipeInletRow,
|
||
revetRow,
|
||
pitchRow,
|
||
boxRow,
|
||
wingInRow,
|
||
wingOutRow,
|
||
fordRow,
|
||
);
|
||
|
||
let currentFacility: PipeFacility | null = null;
|
||
let currentSource: PipeSource | undefined;
|
||
let syncing = false;
|
||
|
||
/** 명시값이 없을 때의 유형 — 자동 배치 source 제안을 따른다. */
|
||
function effectiveFlow(): string {
|
||
return flowSelect.value || suggestFlowType(currentSource);
|
||
}
|
||
|
||
/** 집수정 유무 — 명시값 우선, 없으면 유형이 정한다(보완형 = 있음). */
|
||
function effectiveCatchBasin(): string {
|
||
if (catchBasinSelect.value) return catchBasinSelect.value;
|
||
const flow = effectiveFlow();
|
||
return flow ? (flow === "보완형" ? "있음" : "없음") : "";
|
||
}
|
||
|
||
/** 기슭막이 총/앞/뒤 상호 연계(2026-08-17 확정 — 예: 총 10 중 앞 4 → 뒤 6).
|
||
* changed 필드를 기준으로 나머지를 재계산한다. */
|
||
function linkRevet(changed: "length" | "front" | "back"): void {
|
||
const length = Number.parseFloat(revetLength.value);
|
||
if (!Number.isFinite(length) || length <= 0) return;
|
||
let front = Number.parseFloat(revetFront.value);
|
||
let back = Number.parseFloat(revetBack.value);
|
||
if (changed === "back" && Number.isFinite(back)) {
|
||
back = Math.min(Math.max(back, 0), length);
|
||
front = length - back;
|
||
} else {
|
||
front = Number.isFinite(front) ? Math.min(Math.max(front, 0), length) : length / 2;
|
||
back = length - front;
|
||
}
|
||
revetFront.value = String(Number(front.toFixed(1)));
|
||
revetBack.value = String(Number(back.toFixed(1)));
|
||
}
|
||
|
||
function syncVisibility(): void {
|
||
const facility = currentFacility;
|
||
root.hidden = facility === null;
|
||
if (facility === null) return;
|
||
const isPipe = facility === "pipe";
|
||
const isBox = facility === "box_culvert";
|
||
pipeTypeRow.hidden = !isPipe;
|
||
pipeInletRow.hidden = !isPipe;
|
||
revetRow.hidden = !isPipe;
|
||
pitchRow.hidden = !isPipe;
|
||
boxRow.hidden = !isBox;
|
||
wingInRow.hidden = !isBox;
|
||
wingOutRow.hidden = !isBox;
|
||
fordRow.hidden = facility !== "ford_bridge";
|
||
if (isPipe) {
|
||
// 유형 첫 항목에 제안을 드러낸다 — 저장은 사용자가 고른 값만.
|
||
const suggested = suggestFlowType(currentSource);
|
||
flowSelect.options[0].text = suggested ? `자동(${suggested})` : "미지정";
|
||
catchBasinFormSelect.parentElement!.hidden = effectiveCatchBasin() !== "있음";
|
||
pitchTypeSelect.parentElement!.hidden = pitchSelect.value === "없음";
|
||
const noRevet = revetSelect.value === "없음";
|
||
[revetLength, revetFront, revetBack, revetFormSelect].forEach(
|
||
(input) => (input.parentElement!.hidden = noRevet),
|
||
);
|
||
}
|
||
}
|
||
|
||
function putNumber(target: Record<string, string | number>, key: string, raw: string): void {
|
||
const value = Number.parseFloat(raw);
|
||
if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1));
|
||
}
|
||
|
||
function emit(): void {
|
||
if (syncing || currentFacility === null) return;
|
||
syncVisibility();
|
||
callbacks.onChange?.();
|
||
}
|
||
|
||
[revetLength, revetFront, revetBack].forEach((input, index) =>
|
||
input.addEventListener("change", () => {
|
||
if (syncing) return;
|
||
linkRevet(index === 2 ? "back" : index === 1 ? "front" : "length");
|
||
const front = Number.parseFloat(revetFront.value);
|
||
const back = Number.parseFloat(revetBack.value);
|
||
if (Number.isFinite(front) && Number.isFinite(back)) {
|
||
callbacks.onSpanSuggest?.(front, back);
|
||
}
|
||
emit();
|
||
}),
|
||
);
|
||
[
|
||
flowSelect,
|
||
pipeDiameterSelect,
|
||
pipeMaterialSelect,
|
||
catchBasinSelect,
|
||
catchBasinFormSelect,
|
||
revetSelect,
|
||
revetFormSelect,
|
||
pitchSelect,
|
||
pitchTypeSelect,
|
||
boxWidth,
|
||
boxHeight,
|
||
wingIn,
|
||
wingInHeight,
|
||
wingInLength,
|
||
wingInAngle,
|
||
wingOut,
|
||
wingOutHeight,
|
||
wingOutLength,
|
||
wingOutAngle,
|
||
fordKindSelect,
|
||
fordDiameterSelect,
|
||
fordCount,
|
||
].forEach((input) => input.addEventListener("change", emit));
|
||
|
||
return {
|
||
root,
|
||
setFacility(facility, options = {}, source) {
|
||
currentFacility = facility;
|
||
currentSource = source;
|
||
if (facility === null) {
|
||
syncVisibility();
|
||
return;
|
||
}
|
||
syncing = true;
|
||
const text = (key: string): string =>
|
||
options[key] !== undefined ? String(options[key]) : "";
|
||
const isFord = facility === "ford_bridge";
|
||
flowSelect.value = text("flow_type");
|
||
pipeDiameterSelect.value = (!isFord && text("pipe_diameter_mm")) || "1000";
|
||
pipeMaterialSelect.value = isFord ? "" : text("pipe_kind");
|
||
catchBasinSelect.value = text("catch_basin");
|
||
catchBasinFormSelect.value = text("catch_basin_form");
|
||
revetSelect.value = text("revetment");
|
||
revetLength.value = text("revet_length_m");
|
||
revetFront.value = text("revet_front_m");
|
||
// 뒤 = 총 − 앞 (파생값 — 저장하지 않고 표시만 한다).
|
||
const length = Number.parseFloat(revetLength.value);
|
||
const front = Number.parseFloat(revetFront.value);
|
||
revetBack.value =
|
||
Number.isFinite(length) && Number.isFinite(front)
|
||
? String(Number((length - front).toFixed(1)))
|
||
: "";
|
||
revetFormSelect.value = text("revet_form");
|
||
pitchSelect.value = text("stone_pitching");
|
||
pitchTypeSelect.value = text("stone_pitching_type");
|
||
boxWidth.value = text("body_width_m");
|
||
boxHeight.value = text("body_height_m");
|
||
wingIn.value = text("wing_in");
|
||
wingInHeight.value = text("wing_in_height_m");
|
||
wingInLength.value = text("wing_in_length_m");
|
||
wingInAngle.value = text("wing_in_angle_deg");
|
||
wingOut.value = text("wing_out");
|
||
wingOutHeight.value = text("wing_out_height_m");
|
||
wingOutLength.value = text("wing_out_length_m");
|
||
wingOutAngle.value = text("wing_out_angle_deg");
|
||
fordKindSelect.value = isFord ? text("pipe_kind") : "";
|
||
fordDiameterSelect.value = isFord ? text("pipe_diameter_mm") : "";
|
||
fordCount.value = isFord ? text("pipe_count") : "";
|
||
syncing = false;
|
||
syncVisibility();
|
||
},
|
||
readOptions() {
|
||
const facility = currentFacility;
|
||
const options: Record<string, string | number> = {};
|
||
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;
|
||
},
|
||
};
|
||
}
|