지시 2 — 배수유역도가 유효직경 다음에 추천을 보여 주고, 초기 전처리 계산이 그 값으로 관 지점을 설정한다: - recommend_structure(): 유효직경 임계로 구조물·관경을 고른다. D≤1500 배관(규격 스냅 800·1000·1200·1500), 1500<D≤2000 BOX암거 후보, D>2000 세월교 검토. 판정 근거는 유량뿐 — 계곡 횡단경사·하천 차수는 지형 계산이 필요해 뺐고 화면이 "현장 확인"으로 안내한다(2026-08-17 사용자 확정) - WatershedBasin에 recommended_facility·recommended_diameter_mm·required_area_m2 추가, 응답 payload와 DetailBasin 타입까지 배선 - _apply_recommendations(): 자동 배치 관의 **빈칸에만** 추천을 채운다. 사용자가 놓은 관(source=user)·이미 바꾼 시설·이미 있는 옵션 키는 건드리지 않는다. carry_facility_attributes 뒤에 얹어 승계값이 덮이지 않게 했다 - 유역 목록 표기: `Ø1234mm → 추천 Ø1500 배관` / `→ BOX암거 후보` - 추천 관경 목록은 레지스트리 pipe_diameter_mm 선택지와 같아야 한다 — 폼에서 못 고르는 값을 추천하지 않도록 테스트로 묶었다 지시 1 — 물넘이포장·세월교는 지정할 옵션이 거의 없으니 계산값이라도 보여 준다: - 레지스트리에 ford_width_m(월류 폭) 신설. 기본값 없음 — 지식DB에 폭 근거가 없어 프로그램이 지어내지 않는다 - fordSection(): 설계유량과 폭으로 필요 수심을 되짚는다. 광폭 근사로 출발해 실제 동수반경(R=A/P)으로 수렴시킨다 — 근사값을 그대로 쓰면 통수능이 늘 설계유량에 못 미쳐 판정이 무의미해진다. n=0.017·S=10%는 실무 수리계산서 역산값 (울진1 물넘이·관 시트가 둘 다 S=0.10에서 검산 일치) - 폼에 월류 폭 한 칸 + 읽기전용 요약(설계유량·필요 수심·필요 단면·유속). 설계유량은 유역 payload에서 폼까지 배선(onPipesChanged → Page → setFacility) ⚠ 실무 시트의 수심 0.27m는 Qd 역산값이 아니라 가정 수심이다(B=20·h=0.27의 통수능 41.2㎥/s ≫ 그 시트 Qd 2.54㎥/s). 실무는 단면을 가정해 통수능을 검증하고, 프로그램은 필요 최소 수심을 낸다 — 화면 문구도 "필요 수심"으로 적었다. 검증: pytest tmp/tests 124 passed·7 skipped(신규 17건), npm run typecheck 무오류, npm run build 성공. 물넘이 산식은 프론트 전용이라 수치 손검증으로 대신했다 — Qd=2.54·B=20에서 필요 수심 0.0503m·유속 2.526m/s, 통수능이 2.5400㎥/s로 Qd와 일치. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
399 lines
18 KiB
TypeScript
399 lines
18 KiB
TypeScript
/* =============================================================================
|
||
* B05_Profile_UI_Drainage_Facility_Fields.ts
|
||
* 계곡 통과 시설 부속 옵션 서브폼의 입력 조각 — 라벨·숫자칸·격자·소그룹 상자와
|
||
* 기슭막이 한 벌(형태·길이·높이·보호공), BOX암거 본체 규격·날개벽 조각.
|
||
*
|
||
* 서브폼 본체(B05_Profile_UI_Drainage_Facility)가 700줄 한계에 닿아 분리했다.
|
||
* 조각은 전부 순수 DOM 생성·값 읽기라 본체의 시설 전환 흐름과 독립적이다 —
|
||
* 유입·유출·독립 기슭막이(D4)가 같은 조각을 키만 바꿔 쓰는 구조도 그대로다.
|
||
* ========================================================================== */
|
||
|
||
import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
|
||
|
||
/* ── 물넘이·세월교 개략 단면 ────────────────────────────────────────────── */
|
||
|
||
/** 월류 단면 한 벌 — 설계유량을 흘리는 데 **필요한** 수심·단면적과 그때의 유속. */
|
||
export interface FordSection {
|
||
depthM: number;
|
||
areaM2: number;
|
||
velocityMs: number;
|
||
}
|
||
|
||
/** 물넘이(노면 개수로)의 개략 단면 — 설계유량과 월류 폭으로 필요 수심을 되짚는다.
|
||
*
|
||
* 광폭 직사각형(B ≫ h)으로 보면 Manning을 h에 대해 바로 풀 수 있다:
|
||
* Qd = B·h·(1/n)·h^(2/3)·√S → h = (Qd·n / (B·√S))^(3/5)
|
||
* 다만 실제 동수반경은 R = A/P (P = B + 2h)로 h보다 작아, 광폭 근사값을 그대로 쓰면
|
||
* 통수능이 설계유량에 조금 못 미친다. 그래서 그 값을 출발점 삼아 실제 R로 몇 번
|
||
* 되짚어 **B·h·V(h) = Qd**가 되는 h로 수렴시킨다.
|
||
*
|
||
* n·S는 실무 수리계산서 역산값이다(`config_frontend.FORD_MANNING_N`·`FORD_SLOPE`,
|
||
* 2026-08-17 확인). 폭 B는 지식DB에 수치 근거가 없어 사용자가 넣는다 — 프로그램이
|
||
* 지어내지 않는다. 유량이나 폭이 없으면 null(표시할 값 없음)이다.
|
||
*
|
||
* ⚠ 여기서 내는 수심은 **필요 최소 수심**이다. 실무 수리계산서가 적는 수심은 설계자가
|
||
* 가정한 값이고(울진1: B=20m·h=0.27m → 통수능 41.2㎥/s ≫ Qd 2.54㎥/s), 그 시트는
|
||
* 가정 단면의 통수능이 설계유량을 넘는지 검증하는 방식이다. 둘은 의미가 다르다. */
|
||
export function fordSection(designFlowM3s: number | null, widthM: number): FordSection | null {
|
||
if (!designFlowM3s || designFlowM3s <= 0 || !Number.isFinite(widthM) || widthM <= 0) return null;
|
||
const slopeRoot = Math.sqrt(FORD_SLOPE);
|
||
const velocityAt = (depth: number): number =>
|
||
(1 / FORD_MANNING_N) * Math.pow((widthM * depth) / (widthM + 2 * depth), 2 / 3) * slopeRoot;
|
||
// 광폭 근사 출발값 — 실제 R보다 크게 잡히므로 아래 반복이 조금씩 키워 수렴한다.
|
||
let depth = Math.pow((designFlowM3s * FORD_MANNING_N) / (widthM * slopeRoot), 3 / 5);
|
||
for (let index = 0; index < 20; index += 1) {
|
||
const next = designFlowM3s / (widthM * velocityAt(depth));
|
||
if (Math.abs(next - depth) < 1e-6) {
|
||
depth = next;
|
||
break;
|
||
}
|
||
depth = next;
|
||
}
|
||
return { depthM: depth, areaM2: widthM * depth, velocityMs: velocityAt(depth) };
|
||
}
|
||
|
||
/* ── 폼 조각 ────────────────────────────────────────────────────────────── */
|
||
|
||
export 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;
|
||
}
|
||
|
||
export function numberInput(step: string, min = "0", placeholder = ""): HTMLInputElement {
|
||
const input = document.createElement("input");
|
||
input.type = "number";
|
||
input.step = step;
|
||
input.min = min;
|
||
if (placeholder) input.placeholder = placeholder;
|
||
return input;
|
||
}
|
||
|
||
/** 양수만 옵션에 싣는다 — 빈칸·0·음수는 값이 없는 것으로 본다. */
|
||
export 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));
|
||
}
|
||
|
||
/** 선택지 셀렉트 — 빈("미지정") 항목을 두지 않는다. 첫 항목이 곧 기본값이고,
|
||
* 기본값은 구조물별로 따로 지정한다(2026-08-17 사용자 지시 1). */
|
||
export function optionalSelect(
|
||
_blankLabel: string,
|
||
choices: ReadonlyArray<string>,
|
||
): HTMLSelectElement {
|
||
const element = document.createElement("select");
|
||
element.replaceChildren(...choices.map((choice) => new Option(choice, choice)));
|
||
return element;
|
||
}
|
||
|
||
/** 2열 격자 한 줄(칸이 둘 이하면 그대로, 넘치면 줄바꿈). */
|
||
export function grid(...fields: HTMLElement[]): HTMLDivElement {
|
||
const div = document.createElement("div");
|
||
div.className = "b05-structure__grid";
|
||
div.append(...fields);
|
||
return div;
|
||
}
|
||
|
||
/** 제목 달린 소그룹 상자(유입구·유출구). */
|
||
export function group(title: string): { root: HTMLFieldSetElement; body: HTMLDivElement } {
|
||
const root = document.createElement("fieldset");
|
||
root.className = "b05-structure__group";
|
||
const legend = document.createElement("legend");
|
||
legend.textContent = title;
|
||
const body = document.createElement("div");
|
||
body.className = "b05-structure__group-body";
|
||
root.append(legend, body);
|
||
return { root, body };
|
||
}
|
||
|
||
/** 기슭막이 형태 — 재질과 표면처리를 한 축으로 합친 목록(2026-08-17 사용자 지시 1).
|
||
* 기존 기슭막이 형식 목록에 돌붙임의 찰·메 구분만 더한 것이다. */
|
||
const REVET_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"];
|
||
/** 바닥 보호공 — 돌붙임의 찰·메 구분에 도수로를 더한 한 축(2026-08-17 사용자 지시).
|
||
* 도수로·산비탈수로(B4)는 별표2가 유출구~원지반 도달을 의무화한 공종인데, 실제로는
|
||
* 유출부에서 돌붙임 **대신** 쓰는 선택지라 별도 구조물이 아니라 여기에 넣는다.
|
||
* "없음"은 두지 않는다 — 물이 흐르는 자리라 보호공은 반드시 있다(사용자 확정). */
|
||
const PROTECTION_FORMS = ["돌붙임(찰)", "돌붙임(메)", "도수로"];
|
||
const PROTECTION_CHANNEL = "도수로";
|
||
|
||
/** 기슭막이 옵션 키 묶음 — 유입·유출·독립 구조물이 같은 조각을 쓰되 키만 다르다. */
|
||
export interface RevetmentKeys {
|
||
form: string;
|
||
length?: string;
|
||
height: string;
|
||
protection: string;
|
||
protectionArea: string;
|
||
protectionWidth: string;
|
||
}
|
||
|
||
/** 부위별 기본 구성 — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m·높이 2.5m,
|
||
* 보호공은 유입 돌붙임(찰)·유출 돌붙임(메) (2026-08-17 사용자 확정). */
|
||
export interface RevetmentDefaults {
|
||
form: string;
|
||
length: string;
|
||
height: string;
|
||
protection: string;
|
||
protectionArea: string;
|
||
protectionWidth: string;
|
||
}
|
||
|
||
/** 부위와 무관한 공통 기본 치수 — 길이 10m·높이 2.5m·보호공 면적 10㎡·도수로 폭 1m.
|
||
* 도수로 폭 1m는 지식DB에 수치 근거가 없어 2026-08-17 사용자가 확정한 값이다
|
||
* (실무 확인 후 개선 예정). */
|
||
export const REVET_COMMON_DEFAULTS = {
|
||
length: "10",
|
||
height: "2.5",
|
||
protectionArea: "10",
|
||
protectionWidth: "1",
|
||
} as const;
|
||
|
||
/** 보호공 개편(2026-08-17) 전 키 — `*_pitching` 계열이었다. 구 저장분을 읽을 때만 쓴다. */
|
||
const legacyProtectionKey = (key: string): string => key.replace("protection", "pitching");
|
||
|
||
export const INLET_REVET_KEYS: RevetmentKeys = {
|
||
form: "inlet_revet_form",
|
||
length: "inlet_revet_length_m",
|
||
height: "inlet_revet_height_m",
|
||
protection: "inlet_protection",
|
||
protectionArea: "inlet_protection_area_m2",
|
||
protectionWidth: "inlet_protection_width_m",
|
||
};
|
||
|
||
export const OUTLET_REVET_KEYS: RevetmentKeys = {
|
||
form: "outlet_revet_form",
|
||
length: "outlet_revet_length_m",
|
||
height: "outlet_revet_height_m",
|
||
protection: "outlet_protection",
|
||
protectionArea: "outlet_protection_area_m2",
|
||
protectionWidth: "outlet_protection_width_m",
|
||
};
|
||
|
||
/** 독립 기슭막이(D4) — 길이는 구간형 측점이 정하므로 길이 칸이 없다. */
|
||
export const STANDALONE_REVET_KEYS: RevetmentKeys = {
|
||
form: "form",
|
||
height: "height_m",
|
||
protection: "protection",
|
||
protectionArea: "protection_area_m2",
|
||
protectionWidth: "protection_width_m",
|
||
};
|
||
|
||
export interface RevetmentFields {
|
||
/** 형태 칸 — 유입·유출 그룹에서 "구조" 옆 2열째에 놓는다(2026-08-17 지시 3·4). */
|
||
formField: HTMLLabelElement;
|
||
/** 형태를 뺀 나머지 행(길이·높이, 보호공). */
|
||
rows: HTMLElement[];
|
||
/** 길이 칸(없는 구성이면 null). */
|
||
lengthInput: HTMLInputElement | null;
|
||
write: (options: Record<string, string | number>) => void;
|
||
read: (target: Record<string, string | number>) => void;
|
||
onChange: (handler: () => void) => void;
|
||
syncVisibility: () => void;
|
||
}
|
||
|
||
/** 구 저장분의 보호공 값을 새 한 축으로 옮긴다 — 개편 전에는 `*_pitching`(있음/없음)과
|
||
* `*_pitching_finish`(찰/메) 두 축이었다. "없음"은 선택지가 사라졌으므로 부위
|
||
* 기본값으로 올린다(물이 흐르는 자리라 보호공은 필수 — 2026-08-17 사용자 확정). */
|
||
function migrateProtection(
|
||
options: Record<string, string | number>,
|
||
keys: RevetmentKeys,
|
||
fallback: string,
|
||
): string {
|
||
const current = options[keys.protection];
|
||
if (typeof current === "string" && PROTECTION_FORMS.includes(current)) return current;
|
||
const legacy = options[legacyProtectionKey(keys.protection)];
|
||
const finish = options[`${legacyProtectionKey(keys.protection)}_finish`];
|
||
if (legacy === "있음" && (finish === "찰" || finish === "메")) return `돌붙임(${finish})`;
|
||
return fallback;
|
||
}
|
||
|
||
/** 기슭막이 한 벌(형태·길이·높이 + 보호공) — 배수관 유입·유출부와 독립 구조물이
|
||
* 공유하는 입력 인터페이스(2026-08-17 사용자 지시 9·11·12). 재질과 표면처리는
|
||
* "형태" 한 축으로 합쳤고(지시 1), 바닥 보호공도 돌붙임 찰·메와 도수로를 한 축으로
|
||
* 합쳤다. 기본값은 부위마다 다르다 — 유입 돌붙임(찰) / 유출 돌붙임(메).
|
||
* 치수 칸은 보호공에 따라 갈아 끼운다: 돌붙임이면 면적(㎡), 도수로면 폭(m). */
|
||
export function createRevetmentFields(
|
||
keys: RevetmentKeys,
|
||
defaults: RevetmentDefaults,
|
||
): RevetmentFields {
|
||
const form = document.createElement("select");
|
||
form.replaceChildren(...REVET_FORMS.map((value) => new Option(value, value)));
|
||
form.value = defaults.form;
|
||
const length = keys.length ? numberInput("0.1") : null;
|
||
if (length) length.value = defaults.length;
|
||
const height = numberInput("0.1");
|
||
height.value = defaults.height;
|
||
const protection = document.createElement("select");
|
||
protection.replaceChildren(...PROTECTION_FORMS.map((value) => new Option(value, value)));
|
||
protection.value = defaults.protection;
|
||
const protectionArea = numberInput("0.1");
|
||
protectionArea.value = defaults.protectionArea;
|
||
const protectionWidth = numberInput("0.1");
|
||
protectionWidth.value = defaults.protectionWidth;
|
||
|
||
const formField = labeled("형태", form);
|
||
const lengthField = length ? labeled("길이 (m)", length) : null;
|
||
const heightField = labeled("높이 (m)", height);
|
||
const protectionField = labeled("보호공", protection);
|
||
const protectionAreaField = labeled("보호공 면적 (㎡)", protectionArea);
|
||
const protectionWidthField = labeled("도수로 폭 (m)", protectionWidth);
|
||
|
||
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
|
||
const rows = [
|
||
grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)),
|
||
grid(protectionField, protectionAreaField, protectionWidthField),
|
||
];
|
||
|
||
function syncVisibility(): void {
|
||
const channel = protection.value === PROTECTION_CHANNEL;
|
||
protectionAreaField.hidden = channel;
|
||
protectionWidthField.hidden = !channel;
|
||
}
|
||
|
||
// 만들자마자 기본값 상태를 맞춘다 — 첫 화면에서 면적 칸이 접혀 보이지 않게.
|
||
syncVisibility();
|
||
|
||
return {
|
||
formField,
|
||
rows,
|
||
lengthInput: length,
|
||
write(options) {
|
||
const text = (key: string): string =>
|
||
options[key] !== undefined ? String(options[key]) : "";
|
||
form.value = text(keys.form) || defaults.form;
|
||
if (length && keys.length) length.value = text(keys.length) || defaults.length;
|
||
height.value = text(keys.height) || defaults.height;
|
||
protection.value = migrateProtection(options, keys, defaults.protection);
|
||
protectionArea.value =
|
||
text(keys.protectionArea) ||
|
||
text(legacyProtectionKey(keys.protectionArea)) ||
|
||
defaults.protectionArea;
|
||
protectionWidth.value = text(keys.protectionWidth) || defaults.protectionWidth;
|
||
syncVisibility();
|
||
},
|
||
read(target) {
|
||
const putNumber = (key: string, raw: string): void => {
|
||
const value = Number.parseFloat(raw);
|
||
if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1));
|
||
};
|
||
if (form.value) target[keys.form] = form.value;
|
||
if (length && keys.length) putNumber(keys.length, length.value);
|
||
putNumber(keys.height, height.value);
|
||
target[keys.protection] = protection.value;
|
||
// 면적과 폭은 배타 — 고르지 않은 쪽 값을 남기면 수량이 두 벌로 잡힌다.
|
||
if (protection.value === PROTECTION_CHANNEL) {
|
||
putNumber(keys.protectionWidth, protectionWidth.value);
|
||
} else {
|
||
putNumber(keys.protectionArea, protectionArea.value);
|
||
}
|
||
},
|
||
onChange(handler) {
|
||
[form, height, protection, protectionArea, protectionWidth].forEach((input) =>
|
||
input.addEventListener("change", () => {
|
||
syncVisibility();
|
||
handler();
|
||
}),
|
||
);
|
||
length?.addEventListener("change", handler);
|
||
},
|
||
syncVisibility,
|
||
};
|
||
}
|
||
|
||
/* ── BOX암거 ────────────────────────────────────────────────────────────── */
|
||
|
||
/** 본체 규격 프리셋 — 실무 관측 규격이다(2026-08-17 사용자 확정). 그 밖의 규격은
|
||
* "사용자 지정"으로 폭·높이를 직접 받는다. 저장 키는 프리셋이든 자유 입력이든
|
||
* body_width_m·body_height_m 숫자 그대로다 — 프리셋 전용 키를 만들지 않아야
|
||
* 하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다. */
|
||
export const BOX_SIZE_PRESETS: ReadonlyArray<readonly [string, number, number]> = [
|
||
["2.0×2.0", 2, 2],
|
||
["3.0×3.0", 3, 3],
|
||
];
|
||
export const BOX_SIZE_CUSTOM = "사용자 지정";
|
||
|
||
/** 날개벽 옵션 키 묶음 — 유입·유출이 같은 조각을 쓰되 키만 다르다. */
|
||
interface WingKeys {
|
||
install: string;
|
||
height: string;
|
||
length: string;
|
||
angle: string;
|
||
}
|
||
|
||
export const WING_IN_KEYS: WingKeys = {
|
||
install: "wing_in",
|
||
height: "wing_in_height_m",
|
||
length: "wing_in_length_m",
|
||
angle: "wing_in_angle_deg",
|
||
};
|
||
|
||
export const WING_OUT_KEYS: WingKeys = {
|
||
install: "wing_out",
|
||
height: "wing_out_height_m",
|
||
length: "wing_out_length_m",
|
||
angle: "wing_out_angle_deg",
|
||
};
|
||
|
||
/** 날개벽 기본 제원 — 설치 있음·짧은쪽 높이 1m·길이 2m·각도 45°
|
||
* (2026-08-17 사용자 확정). 유입·유출이 같은 값을 쓴다. */
|
||
const WING_DEFAULTS = { install: "있음", height: "1", length: "2", angle: "45" } as const;
|
||
|
||
interface WingFields {
|
||
root: HTMLFieldSetElement;
|
||
inputs: Array<HTMLInputElement | HTMLSelectElement>;
|
||
syncVisibility: () => void;
|
||
write: (options: Record<string, string | number>) => void;
|
||
read: (target: Record<string, string | number>) => void;
|
||
}
|
||
|
||
/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각.
|
||
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴). */
|
||
export function createWingFields(title: string, keys: WingKeys): WingFields {
|
||
const install = optionalSelect("미지정", ["있음", "없음"]);
|
||
install.value = WING_DEFAULTS.install;
|
||
const height = numberInput("0.1");
|
||
height.value = WING_DEFAULTS.height;
|
||
const length = numberInput("0.1");
|
||
length.value = WING_DEFAULTS.length;
|
||
const angle = numberInput("1");
|
||
angle.value = WING_DEFAULTS.angle;
|
||
|
||
const heightField = labeled("짧은쪽 높이 (m)", height);
|
||
const dimsRow = grid(labeled("길이 (m)", length), labeled("각도 (°)", angle));
|
||
const box = group(title);
|
||
box.body.append(grid(labeled("설치", install), heightField), dimsRow);
|
||
|
||
function syncVisibility(): void {
|
||
const off = install.value === "없음";
|
||
heightField.hidden = off;
|
||
dimsRow.hidden = off;
|
||
}
|
||
|
||
// 만들자마자 기본값("있음") 상태를 맞춘다.
|
||
syncVisibility();
|
||
|
||
return {
|
||
root: box.root,
|
||
inputs: [install, height, length, angle],
|
||
syncVisibility,
|
||
write(options) {
|
||
const text = (key: string): string =>
|
||
options[key] !== undefined ? String(options[key]) : "";
|
||
install.value = text(keys.install) || WING_DEFAULTS.install;
|
||
height.value = text(keys.height) || WING_DEFAULTS.height;
|
||
length.value = text(keys.length) || WING_DEFAULTS.length;
|
||
angle.value = text(keys.angle) || WING_DEFAULTS.angle;
|
||
syncVisibility();
|
||
},
|
||
read(target) {
|
||
target[keys.install] = install.value;
|
||
if (install.value !== "있음") return;
|
||
putNumber(target, keys.height, height.value);
|
||
putNumber(target, keys.length, length.value);
|
||
putNumber(target, keys.angle, angle.value);
|
||
},
|
||
};
|
||
}
|