Files
Aislo/B05_Profile/B05_Profile_UI_Drainage_Facility.ts
T
eomsangdon 38ea71e15b 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 통과
2026-08-17 15:02:45 +09:00

467 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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;
},
};
}