Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-10-05 23:44:02 +09:00
10 changed files with 590 additions and 64 deletions
@@ -0,0 +1,88 @@
/* =============================================================================
* design_structure_ford.ts
* 횡단배수 › 세월교 명세 — 사용자 구조물 표 04(`docs/raw/구조물정의_표/04_횡단배수_세월교.md`) 그대로.
*
* 컨테이너 셋: 세월교(필수) · 유입 구조(필수) · 유출 구조(필수). 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈.
* 저장 키 = pipe_points.json 옛 키 그대로(B06 세월교 세트 · B07 · B08 이 읽는 자리) — 새 키 = 유입 · 유출 구조
* (`inlet_type` · `outlet_type` = 없음 · 날개벽 · 흙막이) · 고르면 옛 날개벽 설치 칸(`wing_in` · `wing_out`)도 함께 적음.
* 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래로는 안 받음) — 계산은 `design_structure_ford_calc`.
* import 없음 — 시험이 이 파일만 옮겨 돌림.
* ========================================================================== */
import type { SpecContainer, SpecChoice, StructureSpec } from "./design_structure_spec";
/** 유입 · 유출 구조 — 날개벽만 옛 설치 칸이 「있음」. */
const guard = (side: "in" | "out"): SpecChoice[] => [
{ value: "없음", label: "없음", also: { [`wing_${side}`]: "없음" } },
{ value: "날개벽", label: "날개벽", also: { [`wing_${side}`]: "있음" } },
{ value: "흙막이", label: "흙막이", also: { [`wing_${side}`]: "없음" } },
];
const guardContainer = (id: "inlet" | "outlet", title: string, link: string): SpecContainer => ({
id,
title,
required: true,
link,
items: [
{
id: "structure",
label: "구조",
kind: "select",
key: `${id}_type`,
choices: guard(id === "inlet" ? "in" : "out"),
},
],
});
export const FORD_SPEC: StructureSpec = {
typeId: "ford_bridge",
containers: [
{
id: "ford",
title: "세월교",
required: true,
link: "gford",
items: [
{
id: "diameter",
label: "관경",
kind: "select",
key: "pipe_diameter_mm",
asNumber: true,
choices: ["800", "1000", "1200", "1500"].map((v) => ({ value: v, label: v })),
},
{ id: "width", label: "월류 폭 (m)", kind: "number", key: "ford_width_m", step: 0.1 },
{ id: "height", label: "월류 높이 (m)", kind: "number", key: "ford_height_m", step: 0.01 },
{ id: "count", label: "수량 (련)", kind: "number", key: "pipe_count", step: 1 },
],
},
guardContainer("inlet", "유입 구조", "gfordin"),
guardContainer("outlet", "유출 구조", "gfordout"),
],
// 표 04 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
retired: [
"pipe_kind",
"wing_in_height_m",
"wing_in_length_m",
"wing_in_angle_deg",
"wing_out_height_m",
"wing_out_length_m",
"wing_out_angle_deg",
],
};
/** 구조를 날개벽 설치 칸(옛 저장 · B06 조정창이 고치는 칸)에 맞춤 — 있음 = 날개벽 · 없음 = 날개벽이면 없음
* (흙막이는 그대로) · 설치 칸이 없으면 구조 그대로. */
export function withLegacyGuards(
options: Record<string, string | number>,
): Record<string, string | number> {
const out = { ...options };
for (const [type, wing] of [
["inlet_type", "wing_in"],
["outlet_type", "wing_out"],
]) {
if (out[wing] === "있음") out[type] = "날개벽";
else if (out[wing] === "없음" && (out[type] ?? "날개벽") === "날개벽") out[type] = "없음";
}
return out;
}
@@ -0,0 +1,27 @@
/* =============================================================================
* design_structure_ford_calc.ts
* 세월교 명세 계산 — 월류 폭 ↔ 월류 높이 연동(PLAN 52-4).
*
* 높이 = 담당 유역 설계유량을 그 월류 폭으로 넘기는 필요 수심(Manning 개수로 · `fordSection` 그대로).
* 근거: Manning 공식 `resources/knowledge/technical_info/01_임도/03_계산정보/통수단면.md:23~33`
* (KDS 44 40 00 2.7.1~2.7.2) · 조도계수 0.017 = 같은 파일 :59(콘크리트 수로 보통 · 울진 물넘이) ·
* 경사 10 % = 실무 수리계산서 역산값(`config_frontend.FORD_SLOPE` · 지식DB 수치 없음).
* 폭을 바꾸면 높이 칸을 이 값으로 채움 · 사용자는 올려 적을 수 있고 이 값 아래로는 안 받음.
* 높이는 저장되는 입력값 — 서버는 받은 높이로 노면을 내림(`B06_Section_Engine_Culvert._ford_set`) · 재계산 안 함.
* 설계유량이 없으면 null = 손 입력.
* ========================================================================== */
import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
/** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */
export function fordHeightM(designFlowM3s: number | null, widthM: number): number | null {
const section = fordSection(designFlowM3s, widthM);
return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null;
}
/** 높이 빈 칸 안내. */
export function fordHeightHint(designFlowM3s: number | null, widthM: number): string {
const need = fordHeightM(designFlowM3s, widthM);
if (need !== null) return `필요 수심 ${need.toFixed(2)} 이상`;
return designFlowM3s ? "월류 폭을 넣으면 셈" : "설계유량 없음 — 손 입력";
}
+6 -2
View File
@@ -8,10 +8,11 @@
* (`inlet_type` · `*_revet_*`)에 둠. 새 키 = 집수정 높이 · 선택 컨테이너(`soil_add_{n}_*` · `chute_add_{n}_*`).
* 정함(PLAN 52): 유출 형태 = 흙막이 6가지 · 도수로/돌붙임 = 구조만 · 길이 = 전 + 후 맞물림.
* 계산(관 길이 · 높이 안내)은 `design_structure_pipe_calc`.
* import 없음 — 시험이 이 파일만 옮겨 돌림.
* import = 같은 폴더 명세뿐 — 시험이 이 폴더 파일만 옮겨 돌림.
* ========================================================================== */
import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec";
import { FORD_SPEC } from "./design_structure_ford";
const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이
const BASIN = "집수정";
@@ -184,4 +185,7 @@ export const PIPE_SPEC: StructureSpec = {
};
/** 명세로 그리는 종류 — 종류 키 → 명세(표가 오는 대로 더함). */
export const STRUCTURE_SPECS: Readonly<Record<string, StructureSpec>> = { pipe: PIPE_SPEC };
export const STRUCTURE_SPECS: Readonly<Record<string, StructureSpec>> = {
pipe: PIPE_SPEC,
ford_bridge: FORD_SPEC,
};
+67 -57
View File
@@ -53,6 +53,8 @@ import {
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form";
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford";
import { fordHeightHint, fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
import { guardHeightHint, pipeLengthM } from "../A00_Common/design/design_structure_pipe_calc";
import {
hasLinks,
@@ -250,45 +252,31 @@ export function createFacilityOptionsForm(
root.className = "b05-drainage__facility";
root.hidden = true;
// ── 관종 · 관경 한 행 — 세월교 몫(배관은 아래 명세 폼 · PLAN 52-1) ─────────
const pipeMaterial = optionalSelect("— 안 정함 —", ["흄관", "VR관", "파형강관"]);
const pipeDiameter = document.createElement("select");
pipeDiameter.replaceChildren(
new Option("— 안 정함 —", ""),
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
);
const pipeRow = grid(
labeled("관종", pipeMaterial, "pipe_kind"),
labeled("관경", pipeDiameter, "pipe_diameter_mm"),
);
// ── 배관 = 사용자 구조물 표 01 명세(PLAN 52-1) — 컨테이너 다섯 · 옛 관종 · 날개벽 칸은 뺌(지난 정의 값) ──
let linkSource: SpecLinks | null = null;
let chainage: number | null = null;
const pipeForm = createSpecForm(
{
get: () => linkSource?.get() ?? {},
pick: (key) => linkSource?.pick(key),
clear: (key) => linkSource?.clear(key),
set: (key, item) => linkSource?.set(key, item),
const specLinks: SpecLinks = {
get: () => linkSource?.get() ?? {},
pick: (key) => linkSource?.pick(key),
clear: (key) => linkSource?.clear(key),
set: (key, item) => linkSource?.set(key, item),
};
const pipeForm = createSpecForm(specLinks, {
auto: (_c, item) => {
if (item.id !== "length") return "";
const length = pipeLengthM(chainage);
return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2);
},
{
auto: (_c, item) => {
if (item.id !== "length") return "";
const length = pipeLengthM(chainage);
return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2);
},
hint: (c, item, state) => {
const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이";
return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : "";
},
onChange: (c, item) => {
if (c.id === "inlet" && (item.id === "structure" || item.id === "form"))
inletStructureHandler?.(inletStructureOf());
emit();
},
hint: (c, item, state) => {
const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이";
return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : "";
},
);
onChange: (c, item) => {
if (c.id === "inlet" && (item.id === "structure" || item.id === "form"))
inletStructureHandler?.(inletStructureOf());
emit();
},
});
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4)
const inletSlot = pipeForm.slot("inlet");
const outletSlot = pipeForm.slot("outlet");
@@ -301,6 +289,26 @@ export function createFacilityOptionsForm(
: "auto";
};
// ── 세월교 = 사용자 구조물 표 04 명세(PLAN 52-4) — 세월교 · 유입 구조 · 유출 구조 ──
// 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`)
let fordFlow: number | null = null;
const fordNeed = (): number | null =>
fordHeightM(fordFlow, Number(fordForm.state().ford?.[0]?.width ?? ""));
const fordForm = createSpecForm(specLinks, {
hint: (c, item, state) =>
c.id === "ford" && item.id === "height"
? fordHeightHint(fordFlow, Number(state.ford?.[0]?.width ?? ""))
: "",
onChange: (c, item) => {
const need = c.id === "ford" ? fordNeed() : null;
const height = fordForm.state().ford?.[0]?.height ?? "";
const low = height !== "" && !(Number(height) >= (need ?? 0));
if (need !== null && (item.id === "width" || (item.id === "height" && low)))
fordForm.setValue("ford", "height", need.toFixed(2));
emit();
},
});
// ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자
// 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다
// (다단 벽은 폼에 자기 칸이 없다 — 높이·형태·구간값이 전부 조정 채널에 있다).
@@ -370,6 +378,8 @@ export function createFacilityOptionsForm(
);
const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS);
const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS);
const wingInHome = wingInFields.slot.parentElement!;
const wingOutHome = wingOutFields.slot.parentElement!;
const hiddenOptions = createHiddenOptions(root);
@@ -399,8 +409,8 @@ export function createFacilityOptionsForm(
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append(
saveNote,
pipeRow,
pipeForm.root,
fordForm.root,
ford.widthRow,
ford.slopeRow,
ford.countRow,
@@ -421,15 +431,18 @@ export function createFacilityOptionsForm(
if (current === null) return;
const isPipe = current === "pipe";
const isBox = current === "box_culvert";
pipeRow.hidden = current !== "ford_bridge";
const isBridge = current === "ford_bridge";
pipeForm.root.hidden = !isPipe;
fordForm.root.hidden = !isBridge;
boxWrap.hidden = !isBox;
// 날개벽은 BOX암거와 세월교가 같은 옵션 한 벌을 쓴다(2026-08-25 사용자 확정).
const hasWing = isBox || current === "ford_bridge";
// 날개벽 칸 = BOX암거 · 세월교는 유입 · 유출 구조 컨테이너(52-4) — B06 조정 행 자리만 그 컨테이너 끝으로
const hasWing = isBox;
wingInFields.root.hidden = !hasWing;
wingOutFields.root.hidden = !hasWing;
const isFord = current === "ford_pavement" || current === "ford_bridge";
ford.countRow.hidden = current !== "ford_bridge";
(isBridge ? fordForm.slot("inlet") : wingInHome).append(wingInFields.slot);
(isBridge ? fordForm.slot("outlet") : wingOutHome).append(wingOutFields.slot);
const isFord = current === "ford_pavement";
ford.countRow.hidden = true;
ford.widthRow.hidden = !isFord;
// 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다.
ford.slopeRow.hidden = current !== "ford_pavement";
@@ -462,8 +475,6 @@ export function createFacilityOptionsForm(
revetOutlet.fields.onChange(emit);
revetSide.addEventListener("change", emit);
[
pipeMaterial,
pipeDiameter,
boxSize,
boxWidth,
boxHeight,
@@ -500,8 +511,12 @@ export function createFacilityOptionsForm(
bindLinks(links) {
linkSource = links;
pipeForm.paintLinks();
fordForm.paintLinks();
},
paintLinks: () => {
pipeForm.paintLinks();
fordForm.paintLinks();
},
paintLinks: () => pipeForm.paintLinks(),
setChainage(chainageM) {
chainage = chainageM;
if (current === "pipe") pipeForm.refresh();
@@ -516,6 +531,7 @@ export function createFacilityOptionsForm(
current = facility;
hiddenOptions.remember(options);
ford.setDesignFlow(designFlow ?? null);
fordFlow = designFlow ?? null;
if (facility === null) {
syncVisibility();
return;
@@ -523,12 +539,11 @@ export function createFacilityOptionsForm(
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
pipeMaterial.value = text("pipe_kind");
pipeDiameter.value = text("pipe_diameter_mm");
if (facility === "pipe") {
const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name]));
const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name]));
if (facility === "pipe")
pipeForm.write(PIPE_SPEC, options, definition?.spec_names ?? {}, groups);
}
if (facility === "ford_bridge")
fordForm.write(FORD_SPEC, withLegacyGuards(options), definition?.spec_names ?? {}, groups);
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
// 값이 없으면 「안 정함」.
const savedWidth = Number.parseFloat(text("body_width_m"));
@@ -549,7 +564,7 @@ export function createFacilityOptionsForm(
boxHeight.value = hasSaved ? String(savedHeight) : "";
wingInFields.write(options);
wingOutFields.write(options);
if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options);
if (facility === "ford_pavement") ford.write(facility, options);
revetSide.value = text("side");
const spread = legacyRevetOptions(options);
revetInlet.fields.write(spread);
@@ -558,15 +573,13 @@ export function createFacilityOptionsForm(
},
setDesignFlow(designFlow) {
ford.setDesignFlow(designFlow ?? null);
fordFlow = designFlow ?? null;
if (current === "ford_bridge") fordForm.refresh(); // 높이 안내
syncVisibility();
},
readOptions() {
const options: Record<string, string | number> = {};
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
const putPipe = (): void => {
if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value);
if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value;
};
if (current === "pipe") {
Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1)
} else if (current === "box_culvert") {
@@ -578,10 +591,7 @@ export function createFacilityOptionsForm(
} else if (current === "ford_pavement") {
ford.read(current, options);
} else if (current === "ford_bridge") {
putPipe();
ford.read(current, options);
wingInFields.read(options);
wingOutFields.read(options);
Object.assign(options, fordForm.read()); // 표 04 명세 값 · 옛 관종 · 날개벽 치수는 병합이 이어 붙임(52-4)
} else if (current === "revetment") {
if (revetSide.value) options.side = revetSide.value;
// 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고
@@ -42,14 +42,16 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
],
box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")],
ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"],
// 세월교 = 표 04 명세 키(PLAN 52-4 · `design_structure_ford`) — 옛 관종 · 날개벽 치수는 폼에서 빠져 이어 붙임
ford_bridge: [
"pipe_kind",
"pipe_diameter_mm",
"pipe_count",
"ford_width_m",
"ford_height_m",
...wingKeys("in"),
...wingKeys("out"),
"inlet_type",
"outlet_type",
"wing_in",
"wing_out",
],
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
};
+19 -1
View File
@@ -3321,7 +3321,25 @@
"계곡통과시설",
"월류단면",
"날개벽"
]
],
"이름": {
"ford.diameter": "관경",
"ford.diameter=800": "800",
"ford.diameter=1000": "1000",
"ford.diameter=1200": "1200",
"ford.diameter=1500": "1500",
"ford.width": "월류 폭 (m)",
"ford.height": "월류 높이 (m)",
"ford.count": "수량 (련)",
"inlet.structure": "구조",
"inlet.structure=없음": "없음",
"inlet.structure=날개벽": "날개벽",
"inlet.structure=흙막이": "흙막이",
"outlet.structure": "구조",
"outlet.structure=없음": "없음",
"outlet.structure=날개벽": "날개벽",
"outlet.structure=흙막이": "흙막이"
}
},
"revetment": {
"동작": [
+86
View File
@@ -0,0 +1,86 @@
/* 세월교 명세(표 04) · 월류 폭 ↔ 높이 계산 단위검증 헬퍼(PLAN 52-4) — `design_structure_spec.ts` ·
* `design_structure_ford.ts` · `design_structure_ford_calc.ts`(+ `fordSection` 이 든 B05 칸 조각)를 그 자리에서
* 트랜스파일해 Node 로 돌림. 결과 = JSON 한 벌. 인자 `names` 면 세월교 이름 표(정의 `종류.ford_bridge.이름`)만 냄.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
/** TS 한 파일을 CommonJS 로 — 상대 import 는 그 파일 자리 기준 · `@config/` 는 config 폴더 · .json 은 그대로. */
const load = (file) => {
if (cache.has(file)) return cache.get(file);
if (file.endsWith(".json")) return JSON.parse(fs.readFileSync(file, "utf8"));
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(file, box.exports);
const near = (m) => {
const base = m.startsWith("@config/")
? path.join(ROOT, "config", m.slice(8))
: path.join(path.dirname(file), m);
return load(base.endsWith(".json") ? base : `${base}.ts`);
};
new Function("exports", "module", "require", js)(box.exports, box, near);
cache.set(file, box.exports);
return box.exports;
};
const D = (f) => path.join(ROOT, "A00_Common", "design", f);
const s = load(D("design_structure_spec.ts"));
const { FORD_SPEC, withLegacyGuards } = load(D("design_structure_ford.ts"));
const calc = load(D("design_structure_ford_calc.ts"));
if (process.argv[2] === "names") {
process.stdout.write(JSON.stringify(s.nameTable(FORD_SPEC)));
process.exit(0);
}
const old = {
pipe_kind: "파형강관",
pipe_diameter_mm: 1000,
pipe_count: 2,
ford_width_m: 10,
ford_height_m: 0.3,
wing_in: "있음",
wing_in_height_m: 1,
wing_out: "없음",
};
const legacy = withLegacyGuards(old);
const state = s.readState(FORD_SPEC, legacy);
const back = s.writeOptions(FORD_SPEC, state);
const soil = JSON.parse(JSON.stringify(state));
soil.inlet[0].structure = "흙막이";
soil.outlet[0].structure = "날개벽";
const soilOut = s.writeOptions(FORD_SPEC, soil);
const flows = [0.5, 2.54, 8];
const widths = [3, 5, 10, 20];
const table = flows.flatMap((q) => widths.map((b) => [q, b, calc.fordHeightM(q, b)]));
const out = {
containers: FORD_SPEC.containers.map((c) => [c.id, c.title, c.required]),
items: FORD_SPEC.containers.map((c) => c.items.map((i) => [i.id, i.kind, i.key ?? null])),
choices: FORD_SPEC.containers.map((c) =>
c.items.filter((i) => i.kind === "select").map((i) => s.choicesOf(i, {}).map((ch) => ch.label)),
),
legacy: { inlet: legacy.inlet_type, outlet: legacy.outlet_type },
keepSoil: withLegacyGuards({ inlet_type: "흙막이", wing_in: "없음" }).inlet_type,
b06Off: withLegacyGuards({ inlet_type: "날개벽", wing_in: "없음" }).inlet_type,
b06On: withLegacyGuards({ inlet_type: "없음", wing_in: "있음" }).inlet_type,
state,
back,
soilOut,
links: FORD_SPEC.containers.map((c) => s.linkOf(c, 1)),
keys: s.specKeys(FORD_SPEC),
retired: FORD_SPEC.retired,
table,
none: [calc.fordHeightM(null, 10), calc.fordHeightM(2.54, 0)],
hints: [
calc.fordHeightHint(2.54, 10),
calc.fordHeightHint(2.54, 0),
calc.fordHeightHint(null, 10),
],
};
process.stdout.write(JSON.stringify(out));
@@ -13,7 +13,8 @@ const load = (file, deps = {}) => {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, (m) => deps[m] ?? require(m));
const near = (m) => (m.startsWith("./") ? load(`${m.slice(2)}.ts`) : require(m)); // 같은 폴더 명세
new Function("exports", "module", "require", js)(box.exports, box, (m) => deps[m] ?? near(m));
return box.exports;
};
const s = load("design_structure_spec.ts");
@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""횡단배수 › 세월교(표 04) 명세 + 월류 폭 ↔ 높이 연동 — PLAN 52-4.
순수 규칙은 Node 헬퍼(`helper_52_4_ford.cjs`)가 돌림 · 화면은 ORCA `ui_checks/52-4_세월교_컨테이너.js`.
높이 거울 = 이 파일이 Manning(통수단면.md §2 · n 0.017 · S 0.10)을 따로 풀어 같은 값인지 봄.
"""
import json
import math
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
N, S = 0.017, 0.10
def _node(*args):
proc = subprocess.run(
["node", os.path.join(HERE, "helper_52_4_ford.cjs"), *args],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.fixture(scope="module")
def out():
return _node()
def test_컨테이너_셋_모두_필수(out):
assert out["containers"] == [
["ford", "세월교", True],
["inlet", "유입 구조", True],
["outlet", "유출 구조", True],
]
assert out["items"][0] == [
["diameter", "select", "pipe_diameter_mm"],
["width", "number", "ford_width_m"],
["height", "number", "ford_height_m"],
["count", "number", "pipe_count"],
]
assert out["choices"] == [
[["800", "1000", "1200", "1500"]],
[["없음", "날개벽", "흙막이"]],
[["없음", "날개벽", "흙막이"]],
]
def test_옛_날개벽_칸으로_구조를_세우고_같은_키로_씀(out):
assert out["legacy"] == {"inlet": "날개벽", "outlet": "없음"}
assert out["keepSoil"] == "흙막이" # 흙막이는 설치 칸 없음이어도 그대로
assert out["b06Off"] == "없음" and out["b06On"] == "날개벽" # B06 조정창 설치 칸을 따름
back = out["back"]
assert back["pipe_diameter_mm"] == 1000 and back["pipe_count"] == 2
assert back["ford_width_m"] == 10 and back["ford_height_m"] == 0.3
assert back["wing_in"] == "있음" and back["wing_out"] == "없음"
# 표 04 에 없는 옛 칸은 폼이 안 적음(병합이 이어 붙임 = 지난 정의 값)
assert "pipe_kind" not in back and "wing_in_height_m" not in back
assert "pipe_kind" in out["retired"]
def test_흙막이_날개벽_고르면_옛_설치_칸도_맞춰_씀(out):
s = out["soilOut"]
assert s["inlet_type"] == "흙막이" and s["wing_in"] == "없음"
assert s["outlet_type"] == "날개벽" and s["wing_out"] == "있음"
def test_잇기_키_컨테이너마다(out):
assert out["links"] == ["gford", "gfordin", "gfordout"]
def _manning_depth(q, b):
"""넓은 직사각 수로 Manning 을 h 에 대해 이분법으로 풂 — 헬퍼와 다른 풀이."""
def cap(h):
return b * h * (1 / N) * ((b * h) / (b + 2 * h)) ** (2 / 3) * math.sqrt(S)
lo, hi = 0.0, 5.0
for _ in range(100):
mid = (lo + hi) / 2
lo, hi = (mid, hi) if cap(mid) < q else (lo, mid)
return hi
def test_월류_높이는_폭과_연동_필요_수심_올림(out):
for q, b, h in out["table"]:
need = _manning_depth(q, b)
assert h == pytest.approx(math.ceil(need * 100 - 1e-9) / 100), (q, b)
# 폭이 넓을수록 높이가 낮아짐
row = [h for q, b, h in out["table"] if q == 2.54]
assert row == sorted(row, reverse=True) and len(set(row)) == len(row)
assert out["none"] == [None, None] # 설계유량 · 폭이 없으면 손 입력
assert out["hints"] == ["필요 수심 0.08 이상", "월류 폭을 넣으면 셈", "설계유량 없음 — 손 입력"]
def test_정의_파일에_세월교_이름_표가_올라_있음():
names = _node("names")
with open(
os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8"
) as f:
doc = json.load(f)
assert doc["종류"]["ford_bridge"]["이름"] == names
@@ -0,0 +1,181 @@
/* 52-4 — B05 · B06 좌측 「구조물 배치」 세월교 = 사용자 구조물 표 04 명세(컨테이너 셋)
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub6_검증) · 목록에 「세월교 1」
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m524 = "단계";` 를 붙여
* orca eval --expression "window.__m524='edit'; $(cat 이 파일)" → 고치고 [저장] (원래 관 지점은 sessionStorage 에 둠)
* (orca reload 로 다시 열기)
* orca eval --expression "window.__m524='verify'; $(cat 이 파일)" → 다시 연 값 · B06 같은 그리기
* orca eval --expression "window.__m524='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움
* 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀)
* 기대 수치(edit): 컨테이너 3(세월교 · 유입 구조 · 유출 구조 · 모두 필수) · 관경 800/1000/1200/1500 ·
* 구조 없음/날개벽/흙막이 · 옛 관종 · 날개벽 칸 안 보임 · 폭 8 → 높이 = 필요 수심(설계유량 있으면) ·
* 낮게 적으면 필요 수심으로 되돌림 · 수량 3 · 잇기 세월교 · 유입 지정 · 유출 지정 뒤 풂
* 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06)
*/
(async () => {
const MODE = window.__m524 || "edit";
const NAME = /세월교 1$/;
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 20000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
const projectId = localStorage.getItem("frd_current_project_id");
const modUrl = (part) =>
performance
.getEntriesByType("resource")
.map((e) => e.name)
.filter((n) => n.includes(part))
.pop();
const fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts"));
const spec = () =>
[...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null);
const box = (c) => spec().querySelector(`[data-spec-container="${c}"]`);
const ctl = (c, item) =>
box(c)?.querySelector(`[data-spec-item="${c}.${item}"]`)?.querySelector("select, input");
const set = async (c, item, v) => {
const el = ctl(c, item);
el.value = v;
el.dispatchEvent(new Event("change", { bubbles: true }));
await w(250);
};
const labels = (c, item) => [...ctl(c, item).options].filter((o) => o.value).map((o) => o.text);
const pickFord = async () => {
const find = () =>
[...document.querySelectorAll(".b05-route__irregular-list li")].find(
(l) => l.offsetParent !== null && NAME.test(l.textContent.trim()),
);
await until(() => !!find());
const li = find();
(li.querySelector("button") ?? li).click();
await w(800);
if (!spec()) (li.querySelector("button") ?? li).click();
await until(() => !!spec());
await w(500);
};
const shape = () =>
[...spec().querySelectorAll("[data-spec-container]")].map((b) => ({
c: b.dataset.specContainer,
legend: b.querySelector("legend").firstChild.textContent,
items: [...b.querySelectorAll("[data-spec-item]")].map((n) => [
n.dataset.specItem,
n.querySelector("select, input").value,
]),
link: b.querySelector(".b05-structure__summary-name")?.textContent,
}));
const assigned = (c) =>
box(c).querySelector(".b05-structure__summary-name").classList.contains("is-assigned");
const pickLink = async (c) => {
box(c).querySelector('[data-b05-summary="pick"]').click();
await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row"));
for (let k = 0; k < 8; k++) {
const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")];
const leaf = rows.find((r) => !r.classList.contains("is-branch"));
if (leaf) {
leaf.click();
break;
}
rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click();
await w(300);
}
await w(300);
[...document.querySelectorAll(".ui-item-picker__actions button")]
.find((b) => b.textContent.includes("적용"))
.click();
await w(500);
};
const visibleKeys = () =>
[...document.querySelectorAll(".b05-drainage__facility [data-option-keys]")]
.filter((n) => n.offsetParent !== null)
.map((n) => n.dataset.optionKeys);
const out = { mode: MODE };
if (MODE === "edit") {
const before = await fetchApi.fetchDetailPipePoints(projectId);
sessionStorage.setItem("__524_original", JSON.stringify(before.pipe_points));
await pickFord();
const s0 = shape();
out.containers = s0.map((b) => [b.c, b.legend]);
out.oldFieldsVisible = visibleKeys(); // 옛 관종 · 월류 · 날개벽 칸 — 빈 목록이어야
out.diameters = labels("ford", "diameter");
out.inletChoices = labels("inlet", "structure");
out.outletChoices = labels("outlet", "structure");
out.start = s0.map((b) => b.items);
await set("ford", "diameter", "1200");
await set("ford", "width", "8");
out.heightAfterWidth8 = ctl("ford", "height").value;
out.heightHint = ctl("ford", "height").placeholder;
await set("ford", "width", "4");
out.heightAfterWidth4 = ctl("ford", "height").value;
await set("ford", "height", "0.01");
out.heightLowBounced = ctl("ford", "height").value;
const raised = (Number(out.heightAfterWidth4 || 0) + 0.2).toFixed(2);
await set("ford", "height", raised);
out.heightRaisedKept = ctl("ford", "height").value === raised;
await set("ford", "count", "3");
await set("inlet", "structure", "흙막이");
await set("outlet", "structure", "날개벽");
await pickLink("ford");
await pickLink("inlet");
await pickLink("outlet");
out.linksPicked = ["ford", "inlet", "outlet"].map(assigned);
box("outlet").querySelector('[data-b05-summary="clear"]').click();
await w(300);
out.linkOutletCleared = !assigned("outlet");
out.edited = shape().map((b) => b.items);
document.querySelector('[data-dock-action="save"]').click();
await w(500);
out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000);
const after = await fetchApi.fetchDetailPipePoints(projectId);
const p = after.pipe_points.find((x) => x.chainage_m > 170 && x.chainage_m < 176); // 세월교 1 = 8+13
out.file = p && { options: p.options, group_items: Object.keys(p.group_items ?? {}) };
}
if (MODE === "verify") {
await pickFord();
const b05 = shape();
out.reopened = b05.map((b) => b.items);
out.links = ["ford", "inlet", "outlet"].map(assigned);
location.hash = "#/b06-section";
await until(() => location.hash.includes("b06"), 5000);
await w(6000);
await pickFord();
const b06 = shape();
out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06);
if (!out.sameDrawing) out.diff = { b05, b06 };
out.b06WingSlots = ["inlet", "outlet"].map(
(c) => box(c).querySelector(".b05-structure__adjust-slot")?.childElementCount ?? -1,
);
}
if (MODE === "restore") {
const original = JSON.parse(sessionStorage.getItem("__524_original") || "null");
if (!original) return JSON.stringify({ error: "원래 값 없음" });
const keys = [
"chainage_m",
"source",
"facility",
"start_m",
"end_m",
"options",
"summary_item",
"group_items",
];
const points = original.map((p) =>
Object.fromEntries(
keys.filter((k) => p[k] !== undefined && p[k] !== null).map((k) => [k, p[k]]),
),
);
await fetchApi.saveDetailPipePoints(projectId, points);
for (const k of Object.keys(sessionStorage))
if (k.startsWith("aislo:draft:pipes:") || k === "__524_original")
sessionStorage.removeItem(k);
const now = await fetchApi.fetchDetailPipePoints(projectId);
const pick = (list) =>
JSON.stringify(list.map((p) => [p.chainage_m, p.options ?? {}, p.group_items ?? {}]));
out.restored = pick(now.pipe_points) === pick(original);
}
return JSON.stringify(out);
})();