Files
Aislo/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts
T
eomsangdonandClaude Opus 5 22d4df5aff feat(B05): 3D 예상형상에 배수관 세트 구조물 반영
B06 횡단(computeCulvertLayout)을 그대로 재사용해 3D 코리도에 기슭막이·집수정·
배관을 세우고, 그 구간 성토/절토 단면을 횡단도와 같은 형상으로 다시 그린다.

구조물 솔리드(_Corridor_Structures.ts 신규)
- 기슭막이: 단면 폴리곤을 노선 따라 1m 간격 로프트 스윕(직선 압출 아님)
- 집수정: 부재 외곽 직육면체, 연장 basin_length_m(기본 2m) 전후 균등
- 배관: 관 하단선 축 원통(관경 지름)
- 스윕 프레임에 계획고 차(dz)를 실어 종단 경사 반영
- flat 법선 + EdgesGeometry 모서리 검은선(성토부·노폭 리본과 같은 규칙)

단면 재구성(_Corridor_Station.ts)
- 구조물 측 비탈 = 노견 → (노폭 연장) → 구조물 상단 → 전면 → 벽 하단 성토부선
  → 다단 기슭막이 → 지반/절토선 끝 한 줄(PIECE_COLS cut·fill 9 -> 20)
- 성토로 늘어난 노폭은 비탈이 아니라 노견 리본을 넓힌다
- 집수정 계류측 cutLine까지 이어 원지반을 그만큼 걷어낸다

구간 처리(_Corridor_Build.ts)
- 구조물 점유 구간(기준측점 전/후)이 다음 측점을 넘어가도 같은 단면 유지
- 그 구간은 지반 트림 생략, 비탈 앵커를 노견 조각 바깥 끝으로 통일

길이·전후 입력
- 레지스트리 pipe 옵션 추가: inlet/outlet_revet_before_m·after_m(5/5),
  inlet_basin_length_m(2)
- B05 배수관 서브폼에 기준측점 전/후 칸(전+후=길이 연동)·집수정 길이 칸
- _side_spec이 전/후·집수정 길이를 CulvertSideSpec으로 전달

비정규 측점 프레임 정정(_Page_Helpers.ts)
- left_xy 선형보간(단위길이 깨짐·방향이 현 쪽으로 누움) -> 노선 폴리라인 접선에서
  직접 산출(left = (-ty, tx)), 폴백은 각도 보간

BUILD_VERSION 10 — 저장본 자동 만료

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 21:46:48 +09:00

365 lines
16 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_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-19 사용자 지시 1). 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은
* 레지스트리 옵션 방식으로 옮겼다(같은 지시 3). */
export interface RevetmentKeys {
form: string;
length?: string;
height: string;
/** 기준측점 전/후 몫(m) — 3D 예상형상 종방향 배치(2026-08-23 사용자). */
before?: string;
after?: string;
}
/** 부위별 기본 구성 — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m·높이 2.5m. */
export interface RevetmentDefaults {
form: string;
length: string;
height: string;
before: string;
after: string;
}
/** 부위와 무관한 공통 기본 치수 — 길이 10m(전 5·후 5)·높이 2.5m. */
export const REVET_COMMON_DEFAULTS = {
length: "10",
height: "2.5",
before: "5",
after: "5",
} as const;
export const INLET_REVET_KEYS: RevetmentKeys = {
form: "inlet_revet_form",
length: "inlet_revet_length_m",
height: "inlet_revet_height_m",
before: "inlet_revet_before_m",
after: "inlet_revet_after_m",
};
export const OUTLET_REVET_KEYS: RevetmentKeys = {
form: "outlet_revet_form",
length: "outlet_revet_length_m",
height: "outlet_revet_height_m",
before: "outlet_revet_before_m",
after: "outlet_revet_after_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;
}
/** 기슭막이 한 벌(형태·길이·높이) — 배수관 유입·유출부가 공유하는 입력 인터페이스
* (2026-08-17 사용자 지시 9·11). 재질과 표면처리는 "형태" 한 축(지시 1). 바닥
* 보호공(돌붙임) 한 벌은 뺐다 — 기슭막이는 사면 공정이라 그 아래 돌붙임이 붙을
* 자리가 아니다(2026-08-19 사용자 지시 1). */
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;
// 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동:
// 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다.
const before = keys.before && length ? numberInput("0.1") : null;
const after = keys.after && length ? numberInput("0.1") : null;
if (before) before.value = defaults.before;
if (after) after.value = defaults.after;
let lastSplitEdit: "before" | "after" = "before";
const readNumber = (input: HTMLInputElement | null): number => {
const value = input ? Number.parseFloat(input.value) : Number.NaN;
return Number.isFinite(value) && value >= 0 ? value : 0;
};
const syncSplit = (source: "length" | "before" | "after"): void => {
if (!length || !before || !after) return;
if (source !== "length") lastSplitEdit = source;
const total = readNumber(length);
const keep = lastSplitEdit === "after" ? after : before;
const kept = Math.min(readNumber(keep), total);
keep.value = kept.toFixed(1);
(keep === before ? after : before).value = Math.max(total - kept, 0).toFixed(1);
};
const formField = labeled("형태", form);
const lengthField = length ? labeled("길이 (m)", length) : null;
const heightField = labeled("높이 (m)", height);
const beforeField = before ? labeled("기준측점 전 (m)", before) : null;
const afterField = after ? labeled("기준측점 후 (m)", after) : null;
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
const rows = [
grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)),
...(beforeField && afterField ? [grid(beforeField, afterField)] : []),
];
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;
if (before && keys.before) before.value = text(keys.before) || defaults.before;
if (after && keys.after) after.value = text(keys.after) || defaults.after;
syncSplit("length");
},
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);
// 전/후는 0도 유효(기준이 곧 시작/끝) — putNumber(>0)와 달리 0을 남긴다.
const putSplit = (key: string | undefined, input: HTMLInputElement | null): void => {
if (!key || !input) return;
const value = Number.parseFloat(input.value);
if (Number.isFinite(value) && value >= 0) target[key] = Number(value.toFixed(1));
};
putSplit(keys.before, before);
putSplit(keys.after, after);
},
onChange(handler) {
[form, height].forEach((input) => input.addEventListener("change", handler));
length?.addEventListener("change", () => {
syncSplit("length");
handler();
});
before?.addEventListener("change", () => {
syncSplit("before");
handler();
});
after?.addEventListener("change", () => {
syncSplit("after");
handler();
});
},
};
}
/* ── 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);
},
};
}