Merge remote-tracking branch 'origin/dev' into sub_laptop_5

This commit is contained in:
2026-10-05 23:53:23 +09:00
34 changed files with 8443 additions and 107 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")],
};
@@ -17,6 +17,7 @@ import {
} from "./B05_Profile_UI_IrregularStations";
import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures";
import { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel";
import { HIDDEN_PICK_GROUPS } from "./B05_Profile_UI_Structures_Panel_Types";
/** 우클릭 메뉴용 최소 타입 정보 — 사이드 「구조물 배치」 종류 목록과 같은 원천. */
export interface StructureMenuType {
@@ -32,7 +33,9 @@ export function structureGroupMenuItems(
types: ReadonlyArray<StructureMenuType>,
onPick: (typeId: string) => void,
): MapContextMenuItem[] {
const groups = [...new Set(types.map((type) => type.group))];
const groups = [...new Set(types.map((type) => type.group))].filter(
(group) => !HIDDEN_PICK_GROUPS.has(group),
);
return groups.map((group) => ({
label: GROUP_LABELS[group] ?? group,
children: types
@@ -78,7 +78,9 @@ export function createFacilityDefinition(root: HTMLElement): FacilityDefinition
root
.querySelectorAll(".is-rule-hidden")
.forEach((node) => node.classList.remove("is-rule-hidden"));
root.querySelectorAll<HTMLOptionElement>("option[hidden]").forEach((o) => (o.hidden = false));
root
.querySelectorAll<HTMLOptionElement>("option[hidden]:not([data-pick-hidden])")
.forEach((o) => (o.hidden = false));
paint();
},
};
@@ -24,7 +24,7 @@ import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import {
hasSpecContainers,
hidesTypeSummary,
paintStale,
renderOptionFields as renderOptionFieldsInto,
} from "./B05_Profile_UI_Structures_Panel_Options";
@@ -54,6 +54,7 @@ export type { PipeFacilityItem, StructuresSection } from "./B05_Profile_UI_Struc
import {
DEFAULT_INTERVAL_LENGTH_M,
GROUP_LABELS,
groupOption,
hasComputedRange,
readBeforeOption,
readLengthOption,
@@ -227,7 +228,7 @@ export function createStructuresSection(
!!type && !managed && type.placement === "interval" && !hasComputedRange(type);
positionRow.hidden = !type;
positionDivider.hidden = !type;
summary.root.hidden = !type || hasSpecContainers(type); // 컨테이너 종류는 컨테이너마다 잇기(52-1)
summary.root.hidden = !type || hidesTypeSummary(type); // 컨테이너 종류는 컨테이너마다 잇기(52-1)
startFields.wrap.hidden = !isInterval;
endFields.wrap.hidden = !isInterval;
primary.disabled = !type;
@@ -544,7 +545,7 @@ export function createStructuresSection(
// 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. 이름은 정의(48) 먼저.
groupSelect.replaceChildren(
new Option("— 선택 —", ""),
...[...groups].map(([g, name]) => new Option(name ?? GROUP_LABELS[g] ?? g, g)),
...[...groups].map(([g, name]) => groupOption(g, name ?? GROUP_LABELS[g] ?? g)),
);
groupSelect.value = "";
syncTypeOptions();
@@ -23,9 +23,10 @@ import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import { specKeys } from "../A00_Common/design/design_structure_spec";
/** 구조물 명세 컨테이너로 그리는 종류인지 — 그때 종류 단위 「구조물 조합」 줄은 가림(PLAN 52-1). */
export const hasSpecContainers = (type: StructureType): boolean =>
!!STRUCTURE_SPECS[type.type_id]?.containers.length;
/** 종류 단위 「구조물 조합」 줄을 가릴 종류인지 — 구조물 명세 컨테이너 · 정의 그룹 상자(그룹마다 잇기 줄)로
* 그리는 종류(PLAN 52-1 · 52-6b) · 종단배수(B군 · 조합 잇기 없음 · 표 07). */
export const hidesTypeSummary = (type: StructureType): boolean =>
!!STRUCTURE_SPECS[type.type_id]?.containers.length || !!type.groups?.length || type.group === "B";
/** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */
export interface OptionRenderContext {
@@ -34,6 +34,18 @@ export const GROUP_LABELS: Record<string, string> = {
호환: "기타",
};
/** 새로 놓을 때 고르기 목록(군 드롭다운 · 우클릭 메뉴)에서 뺄 군 — 종단배수·계류사방(표 07).
* 옛 값 · 도면 · 집계는 그대로이고 데이터도 지우지 않는다. */
export const HIDDEN_PICK_GROUPS: ReadonlySet<string> = new Set(["B", "D"]);
/** 군 드롭다운 항목 — 뺄 군은 가림(옛 항목을 열 때는 값으로 그대로 고를 수 있음). */
export function groupOption(group: string, name: string): HTMLOptionElement {
const option = new Option(name, group);
option.hidden = HIDDEN_PICK_GROUPS.has(group);
if (option.hidden) option.dataset.pickHidden = ""; // 정의 폼의 「가림 풀기」가 건드리지 않게
return option;
}
/** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */
export const DEFAULT_INTERVAL_LENGTH_M = 15;
+26 -14
View File
@@ -26,6 +26,8 @@ import {
} from "./M02_SO_Def_Model";
import { buildOptionsBlock } from "./M02_SO_Def_Options";
import { buildRulesBlock } from "./M02_SO_Def_Rules";
import { buildSpecBlock } from "./M02_SO_Def_Spec";
import { STRUCTURE_SPECS } from "../../A00_Common/design/design_structure_pipe";
import { checkBox, textBox } from "./M02_SO_Def_Ui";
import { buildGroupBlock } from "./M02_SO_Groups";
import { to } from "./M02_SO_Text";
@@ -182,6 +184,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
}
const edit = canEdit();
const extra = (): NonNullable<StructDef["종류"][string]> => (d.종류[type.type_id] ??= {});
// 명세가 있는 종류(PLAN 52) = 이름만 고침 — 옛 편집기(옵션 · 배치 · 그룹 · 규칙 · 기준 측점)는 숨김
const spec = STRUCTURE_SPECS[type.type_id];
detail.replaceChildren(
el("div", {
className: "m02-sod__kindhead",
@@ -196,16 +200,20 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
edit,
attr: "data-sod-kind-off",
}),
checkBox(
to("BaseStationShow"),
baseShown(d, type.type_id),
(v) => {
if (v) delete extra().기준측점;
else extra().기준측점 = false;
changed();
},
{ edit, attr: "data-sod-base" },
),
...(spec
? []
: [
checkBox(
to("BaseStationShow"),
baseShown(d, type.type_id),
(v) => {
if (v) delete extra().기준측점;
else extra().기준측점 = false;
changed();
},
{ edit, attr: "data-sod-base" },
),
]),
...(d.종류[type.type_id]?.동작 ?? []).map((a) => {
const tag = createTag(a, "neutral");
tag.title = d.동작[a] ?? "";
@@ -213,10 +221,14 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
}),
],
}),
buildOptionsBlock({ def: d, type, edit, open, changed }),
buildLayoutBlock({ def: d, type, edit, changed }),
buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }),
buildRulesBlock({ def: d, type, edit, changed }),
...(spec
? [buildSpecBlock({ def: d, type, spec, edit, changed })]
: [
buildOptionsBlock({ def: d, type, edit, open, changed }),
buildLayoutBlock({ def: d, type, edit, changed }),
buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }),
buildRulesBlock({ def: d, type, edit, changed }),
]),
);
};
@@ -57,7 +57,8 @@ export interface StructDef {
구조물군: Record<string, string>;
동작: Record<string, string>;
types: TypeDef[];
종류: Record<string, { 기준측점?: boolean; 동작?: string[] }>;
/** 이름 = 구조물 명세 이름 바꿈 표(PLAN 52 · 키 `{컨테이너}[.{항목}[={값}]]`) */
종류: Record<string, { 기준측점?: boolean; 동작?: string[]; 이름?: Record<string, string> }>;
표시: Record<string, Record<string, false>>;
그룹: Record<string, GroupDef[]>;
배치: Record<string, Record<string, Spot>>;
@@ -257,6 +258,10 @@ export function problems(def: StructDef): string[] {
if (!o.choices?.length) out.push(`${t.name}.${o.label} 고르기 꼴은 선택지가 있어야 함`);
}
const grp = badNames((def.그룹[t.type_id] ?? []).map((g) => g.이름));
const spec = Object.entries(def.종류[t.type_id]?.이름 ?? {})
.filter(([, v]) => !v.trim() || v.length > NAME_MAX)
.map(([k]) => k);
if (spec.length) out.push(`${t.name} 명세 이름: ${spec.join(", ")}`);
if (grp.length) out.push(`${t.name} 그룹 이름: ${grp.join(", ")}`);
const lap = overlaps(placeAll(t.options, def.배치[t.type_id]));
if (lap.length) out.push(`${t.name} 배치 겹침: ${lap.join(", ")}`);
@@ -0,0 +1,67 @@
/* =============================================================================
* M02_SO_Def_Spec.ts
* 구조물 명세가 있는 종류(PLAN 52 · `STRUCTURE_SPECS`)의 이름 고치기 칸(52-11) — 나무 꼴
* 컨테이너 제목 › 항목 이름 › 값 이름 · 이름만 고침 · 키 · 필수/선택 · 꼴은 읽기만 ·
* 아래 미리보기 = B05 · B06 이 그리는 같은 명세 폼(`createSpecForm`)에 고친 이름을 얹음.
* 구조물군 · 종류 이름은 왼쪽 목록 · 머리 칸이 그대로 고침.
* ========================================================================== */
import { createTag, el } from "@ui/ui_template_elements";
import { createSpecForm } from "../../A00_Common/design/design_structure_form";
import type { StructureSpec } from "../../A00_Common/design/design_structure_spec";
import type { StructDef, TypeDef } from "./M02_SO_Def_Model";
import { setSpecName, specNameRows, type SpecNameRow } from "./M02_SO_Def_Spec_Model";
import { head, textBox } from "./M02_SO_Def_Ui";
import { to, type OptionsTextKey } from "./M02_SO_Text";
const TAG: Record<NonNullable<SpecNameRow["tag"]>, OptionsTextKey> = {
required: "SpecRequired",
optional: "SpecOptional",
select: "FormSelect",
number: "FormNumber",
auto: "FormAuto",
};
export function buildSpecBlock(opt: {
def: StructDef;
type: TypeDef;
spec: StructureSpec;
edit: boolean;
changed: () => void;
}): HTMLElement {
const { def, type, spec, edit, changed } = opt;
const rows = specNameRows(def, spec).map((row) =>
el("div", {
className: `m02-sod__specrow is-level-${row.level}`,
attrs: { "data-sod-spec": row.nameKey },
children: [
textBox(row.label, (v) => setSpecName(def, type.type_id, row, v), changed, {
edit,
attr: "data-sod-spec-name",
cls: "m02-sod__name",
}),
el("small", { className: "m02-opts__key", text: row.key }),
...(row.tag ? [createTag(to(TAG[row.tag]), "neutral")] : []),
],
}),
);
// 미리보기 — 값 없는 새 구조물 폼 · 잇기 줄 없음(조합은 프로젝트 몫)
const preview = createSpecForm(null);
const groups = Object.fromEntries((def.그룹[type.type_id] ?? []).map((g) => [g.id, g.이름]));
preview.write(spec, {}, def.종류[type.type_id]?.이름 ?? {}, groups);
preview.root.inert = true;
return el("div", {
className: "m02-sod__block",
attrs: { "data-sod-spec-block": "" },
children: [
...head(to("SpecHead"), to("SpecNote")),
...rows,
...head(to("SpecPreview")),
el("div", {
className: "m02-sod__specpreview",
attrs: { "data-sod-spec-preview": "" },
children: [preview.root],
}),
],
});
}
@@ -0,0 +1,91 @@
/* =============================================================================
* M02_SO_Def_Spec_Model.ts
* 구조물 명세(PLAN 52 · `design_structure_spec`) 이름 고치기(52-11)의 자료 손질 — 화면 그리기 없음.
*
* · 명세 = 구조 정본(키 · 컨테이너 · 필수/선택 · 꼴) — 여기서는 **이름만** 고침
* · 이름 자리 = 정의 `종류.{종류}.이름`(키 `{컨테이너}` · `{컨테이너}.{항목}` · `{컨테이너}.{항목}={값}`)
* · 필수 컨테이너 제목 = 이름 표에 없으면 정의 그룹(`그룹.{종류}` 의 `id = link`) 이름 — B05 그리기
* (`containerTitle`)와 같은 차례로 읽고 그 자리에 씀
* import 는 꼴만 — 시험이 이 파일만 옮겨 돌림.
* ========================================================================== */
import type { StructureSpec } from "../../A00_Common/design/design_structure_spec";
import type { StructDef } from "./M02_SO_Def_Model";
export interface SpecNameRow {
/** 0 = 컨테이너 · 1 = 항목 · 2 = 값 */
level: 0 | 1 | 2;
/** 이름 표 키 */
nameKey: string;
/** 읽기만 표시 — 컨테이너 id · 항목 id · 값 */
key: string;
/** 지금 이름 */
label: string;
/** 읽기만 표시 — 필수/선택 · 꼴 */
tag: "required" | "optional" | "select" | "number" | "auto" | null;
/** 필수 컨테이너 제목이 정의 그룹 이름에서 옴 — 고치면 그 그룹 이름에 씀 */
group: string | null;
}
/** 명세 차례대로 이름 줄 — 같은 이름 키(갈래마다 겹친 값)는 한 줄. */
export function specNameRows(def: StructDef, spec: StructureSpec): SpecNameRow[] {
const names = def.종류[spec.typeId]?.이름 ?? {};
const groups = def.그룹[spec.typeId] ?? [];
const rows: SpecNameRow[] = [];
const seen = new Set<string>();
const push = (row: SpecNameRow): void => {
if (seen.has(row.nameKey)) return;
seen.add(row.nameKey);
rows.push(row);
};
for (const c of spec.containers) {
const group =
c.required && names[c.id] === undefined ? groups.find((g) => g.id === c.link) : undefined;
push({
level: 0,
nameKey: c.id,
key: c.id,
label: names[c.id] ?? group?.이름 ?? c.title,
tag: c.required ? "required" : "optional",
group: group ? group.id : null,
});
for (const item of c.items) {
const itemKey = `${c.id}.${item.id}`;
push({
level: 1,
nameKey: itemKey,
key: item.id,
label: names[itemKey] ?? item.label,
tag: item.kind,
group: null,
});
const choices = [
...(item.choices ?? []),
...Object.values(item.by?.map ?? {}).flatMap((b) => b.choices ?? []),
];
for (const ch of choices) {
const choiceKey = `${itemKey}=${ch.value}`;
push({
level: 2,
nameKey: choiceKey,
key: ch.value,
label: names[choiceKey] ?? ch.label,
tag: null,
group: null,
});
}
}
}
return rows;
}
/** 이름 하나를 사본에 씀 — 그룹에서 온 제목은 그 그룹 이름 · 나머지는 이름 표. */
export function setSpecName(def: StructDef, typeId: string, row: SpecNameRow, value: string): void {
const group = row.group ? def.그룹[typeId]?.find((g) => g.id === row.group) : undefined;
if (group) {
group.이름 = value;
return;
}
const kind = (def.종류[typeId] ??= {});
(kind.이름 ??= {})[row.nameKey] = value;
}
@@ -332,3 +332,28 @@
margin: 0;
color: var(--color-danger);
}
/* 구조물 명세 이름 고치기(PLAN 52-11) — 컨테이너 › 항목 › 값 들여쓰기 */
.m02-sod__specrow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-sod__specrow.is-level-0 {
margin-top: var(--spacing-8);
font-weight: 600;
}
.m02-sod__specrow.is-level-1 {
padding-left: 1.5rem;
}
.m02-sod__specrow.is-level-2 {
padding-left: 3rem;
}
.m02-sod__specpreview {
max-width: 28rem;
}
@@ -45,6 +45,15 @@ const TEXT = {
FormNumber: ["숫자", "Number"],
FormText: ["글", "Text"],
FormBool: ["예/아니오", "Yes/No"],
FormAuto: ["자동", "Auto"],
SpecHead: ["구조물 명세 이름", "Structure spec names"],
SpecNote: [
"컨테이너 제목 › 항목 이름 › 값 이름만 고침 — 키(작은 글) · 필수/선택 · 꼴 · 구조는 구조물 명세가 정함 · [저장]하면 B05 · B06 이 이 이름으로 그림",
"Rename containers › items › values only — keys, required/optional, form and structure come from the spec",
],
SpecRequired: ["필수", "Required"],
SpecOptional: ["선택", "Optional"],
SpecPreview: ["미리보기 — B05 · B06 폼", "Preview — B05 · B06 form"],
Yes: ["예", "Yes"],
No: ["아니오", "No"],
Unit: ["단위", "Unit"],
@@ -0,0 +1,17 @@
# 구조물 표 08 — 안전부대용지 사토장 · 토취장 · 노면공 콘크리트 포장 · 기타 (사용자 10-5 · 원문 그대로)
구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목 … 상세 설계 필수 선택
안전부대용지 유용토운반작업장(사토장) 측점 No, + 측점거리 (필수)컨테이너 제목 : 상세 내용
사토장 부피 수기입력
안전부대용지 토사채취장(토취장) 측점 No, + 측점거리 (필수)컨테이너 제목 : 상세 내용
토취장 부피 수기입력
노면공 콘크리트 포장 측점 No, + 측점거리 (필수)컨테이너 제목 : 상세 내용
포장사양 콘크리트, XXX
높이 수기입력
기타 수기입력 측점 No, + 측점거리 (선택)컨테이너 제목 : 상세 내용 (상세 설계) 도수로(or 돌붙임)
비고 수기 입력 (상세 설계) 도수로(or 돌붙임) 관보호공, 옹벽, 돌쌓기, 흙막이, 큰톨쌓기 가 같은 형상으로 보임.
(가드레일은 표 07 덧과 같음)
@@ -119,3 +119,6 @@
추가로 구조물 조합의 일위대가 상세페이지의 로직 테이블의 순서 열에서 존재하는 버튼을 2행 구조로 변경
단가 승격에도 옵션 버튼이있어야할것같고. 일위조합의 경우에는 바로 옵션으로 지정할수 없으니 수정버튼(모달)도 잇어할것 같은데?
맹암거처럼 cad연결이나 횡단도에 그릴지 말지는 이번 수정 후에 작업할거야. 너는 B05/B06 좌측 패널의 생성과 기존 로직 연결에만 신경쓸것.
나는 이제 자러 갈거니 너가 다른 워크트리들을 잘 제어해서 계획서의 내용을 반영하고 최종검증을 진행 후 아티팩트로 결과를 보여주는데 만약 개선이 필요하다고 판단되는 부분이 있다면 계획서에 내용을 추가해서 개선할것.
+60 -18
View File
@@ -1,5 +1,5 @@
{
"판": 6,
"판": 8,
"schema_version": 1,
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
"구조물군": {
@@ -676,28 +676,41 @@
"cross_section",
"quantity"
],
"options": []
},
{
"type_id": "cross_underdrain",
"group": "A",
"name": "맹암거",
"placement": "point",
"style": {
"color": "#3aa0a8",
"abbr": "횡맹"
},
"drawing_views": [
"plan",
"profile",
"cross_section",
"quantity"
],
"options": [
{
"key": "ditch_spec",
"label": "규격",
"input": "select",
"choices": [
"콘크리트 개거 150×200",
"L형수로 H=0.2"
],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎"
},
{
"key": "length_m",
"label": "연장",
"key": "culvert_height_m",
"label": "암거 높이",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)"
"empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)"
},
{
"key": "culvert_slope_pct",
"label": "암거 경사",
"input": "number",
"unit": "%",
"default": null,
"required": false,
"empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)"
}
]
},
@@ -3308,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": {
"동작": [
@@ -3352,6 +3383,17 @@
"옵션": [],
"형상": {}
}
],
"cross_underdrain": [
{
"id": "gcrossud",
"이름": "맹암거",
"옵션": [
"culvert_height_m",
"culvert_slope_pct"
],
"형상": {}
}
]
},
"배치": {},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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,64 @@
/* M02 구조물 명세 이름 고치기(`M02_SO_Def_Spec_Model.ts` · PLAN 52-11) 단위검증 헬퍼 —
* TS 를 그 자리에서 트랜스파일해 Node 로 돌리고 결과를 JSON 으로 냄(pytest 가 판정).
* 정의는 진짜 `구조물정의.json` 을 읽어 메모리 사본만 고침(파일 쓰기 없음). */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const load = (...parts) => {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, ...parts), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
return box.exports;
};
const m = load("M02_MasterTemplete", "StructOptions", "M02_SO_Def_Spec_Model.ts");
const { PIPE_SPEC } = load("A00_Common", "design", "design_structure_pipe.ts");
const real = () =>
JSON.parse(
fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"),
);
const out = {};
const def = real();
const rows = m.specNameRows(def, PIPE_SPEC);
out.levels = [0, 1, 2].map((lv) => rows.filter((r) => r.level === lv).length);
out.unique = new Set(rows.map((r) => r.nameKey)).size === rows.length;
out.pipeTitle = rows.find((r) => r.nameKey === "pipe");
out.optional = rows.find((r) => r.nameKey === "soil_add");
out.basinForm = rows.find((r) => r.nameKey === "inlet.form=I");
// 이름 고침 — 그룹 제목 · 항목 · 값 · 선택 컨테이너
const work = real();
const pick = (k) => m.specNameRows(work, PIPE_SPEC).find((r) => r.nameKey === k);
m.setSpecName(work, "pipe", pick("pipe"), "관 본체");
m.setSpecName(work, "pipe", pick("pipe.diameter"), "관 지름");
m.setSpecName(work, "pipe", pick("pipe.diameter=800"), "Ø800");
m.setSpecName(work, "pipe", pick("soil_add"), "흙막이 더");
out.groupName = work.그룹.pipe.find((g) => g.id === "grv6lny").이름;
out.names = ["pipe", "pipe.diameter", "pipe.diameter=800", "soil_add"].map(
(k) => work.종류.pipe.이름[k] ?? null,
);
out.reread = ["pipe", "pipe.diameter", "pipe.diameter=800", "soil_add"].map((k) => pick(k).label);
// 구조는 그대로 — types · 동작 · 다른 종류 · 이름 표 키 수(새 키 없음)
const before = real();
out.sameTypes = JSON.stringify(work.types) === JSON.stringify(before.types);
out.sameKeys =
Object.keys(work.종류.pipe.이름).length === Object.keys(before.종류.pipe.이름).length;
// 이름 표가 빈 종류 — 명세 글로 읽고 고치면 이름 표가 생김
const bare = real();
delete bare.종류.pipe.이름;
const bareRows = m.specNameRows(bare, PIPE_SPEC);
out.bareLabel = bareRows.find((r) => r.nameKey === "inlet.structure=기슭막이").label;
m.setSpecName(
bare,
"pipe",
bareRows.find((r) => r.nameKey === "inlet"),
"유입",
);
out.bareGroup = bare.그룹.pipe.find((g) => g.id === "gnbf9m9").이름;
process.stdout.write(JSON.stringify(out));
@@ -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,91 @@
# -*- coding: utf-8 -*-
"""52-6 횡단배수 › 맹암거(표 06 · 새 종류) — 정의 · 컨테이너 · 서버 저장 왕복.
새 키 `cross_underdrain`(횡단배수 · 점형) — 종단배수 맹암거(`underdrain`)와 따로.
컨테이너 = 정의 그룹 「맹암거」(필수 · 늘 한 벌) — 암거 높이(m) · 암거 경사(%) 손 입력(비워 둘 수 있음) ·
그룹 잇기(`group_items`)로 구조물 조합 항목. 저장은 임시 storage 에서만.
"""
import pytest
from B05_Profile.B05_Profile_Structures_Schema import (
StructureInstance,
is_station_planting_type,
load_definition,
structure_type_map,
)
KEY = "cross_underdrain"
GROUP = "gcrossud"
def test_새_종류는_횡단배수_점형_종단배수_맹암거와_따로():
types = structure_type_map()
new, old = types[KEY], types["underdrain"]
assert (new.group, new.placement, new.name, new.enabled) == ("A", "point", "맹암거", True)
assert (old.group, old.placement) == ("B", "interval")
assert new.style["abbr"] != old.style["abbr"] and new.style["color"] != old.style["color"]
assert is_station_planting_type(new) # 횡단배수 = 그 측점에 횡단도
def test_컨테이너_맹암거_높이_경사_손_입력():
opts = {o.key: o for o in structure_type_map()[KEY].options}
assert list(opts) == ["culvert_height_m", "culvert_slope_pct"]
for key, label, unit in (
("culvert_height_m", "암거 높이", "m"),
("culvert_slope_pct", "암거 경사", "%"),
):
o = opts[key]
assert (o.label, o.input, o.unit, o.default, o.required) == (
label,
"number",
unit,
None,
False,
)
assert o.empty_means # 손 입력 · 기본값 근거 없음 — 비워 둘 수 있음(B05 단계 필수 금지)
groups = load_definition()["그룹"][KEY]
assert groups == [
{"id": GROUP, "이름": "맹암거", "옵션": list(opts), "형상": {}},
]
def test_정의_검사_통과():
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
assert definition.check(load_definition()) == []
def _item(**options):
return StructureInstance(
type_id=KEY,
placement="point",
chainage_m=63.5,
options=options,
group_items={
GROUP: {"layer": "system", "owner": None, "key": "SS000001", "name": "맹암거"}
},
)
def test_저장_다시_읽기_그대로(tmp_path):
from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures
item = _item(culvert_height_m=0.6, culvert_slope_pct=2.5)
save_structures(str(tmp_path), [item], base_revision=0)
_, loaded = load_structures(str(tmp_path))
assert [(s.type_id, s.chainage_m, s.options) for s in loaded] == [
(KEY, 63.5, {"culvert_height_m": 0.6, "culvert_slope_pct": 2.5})
]
assert loaded[0].group_items == item.group_items
@pytest.mark.parametrize(
"options",
[{"culvert_height_m": -1}, {"culvert_slope_pct": -0.5}],
)
def test_음수는_서버가_막음(tmp_path, options):
from B05_Profile.B05_Profile_Structures_Repository import save_structures
with pytest.raises(ValueError):
save_structures(str(tmp_path), [_item(**options)], base_revision=0)
@@ -74,7 +74,7 @@ def test_정의_덧칸_얹기(out):
def test_판1_정의는_지금_화면과_같음(out):
assert out["v1Types"] == 37
assert out["v1Types"] == 38 # 52-6 횡단배수 맹암거 더함
assert out["v1Same"] is True
assert out["v1Columns"] == [2]
assert out["v1Anchor"] is True
@@ -189,7 +189,7 @@ def test_quick_add_types_exclude_pipe_family():
def test_all_enabled_non_managed_types_are_quick_addable():
"""잔존 B05 선택지 = A군 2(노출형·개거) + C군 5 + D군 3 + E군 10 + G군 1(콘크리트 포장) + 기타 1.
"""잔존 B05 선택지 = A군 3(노출형·개거·맹암거) + C군 5 + D군 3 + E군 10 + G군 1(콘크리트 포장) + 기타 1.
콘크리트 포장은 2026-08-28 사용자 지시로 되살렸다 — 포장 구간을 기준측점+길이+전/후로
받는 정본이 됐다(종단경사 자동 포장 적용을 대체).
@@ -202,7 +202,7 @@ def test_all_enabled_non_managed_types_are_quick_addable():
그래서 C군 구간형 시설과 같은 자리에 선다. (측구쪽 `ditch_berm` 은 우클릭 추가 대상이
아니라 여기 수에 안 잡힌다.)
"""
assert len(_quick_add_types()) == 23
assert len(_quick_add_types()) == 24
def test_removed_types_are_absent():
@@ -366,6 +366,7 @@ def test_a6_a7_types_exist_for_manual_placement():
item = by_id[type_id]
assert item.group == "A" and item.managed_by is None
assert item.placement == "point"
assert not item.options, f"{type_id} 옵션 없음 — 기준 측점 + 종류 단위 잇기만(PLAN 52-5)"
def test_ford_bridge_shares_pipe_kind_and_diameter():
+58
View File
@@ -0,0 +1,58 @@
"""M02 구조물 명세 이름 고치기(`M02_SO_Def_Spec_Model.ts` · PLAN 52-11) 단위검증.
Node 헬퍼(`helper_m02_so_def_spec.cjs`)가 TS 를 트랜스파일해 진짜 정의 사본(메모리)으로 돌리고
pytest 는 그 결과(JSON)를 판정.
못박는 것
- 배관 명세 나무: 컨테이너 5 · 항목 · 값 줄 · 이름 키 겹침 없음 · 필수/선택 · 꼴 표시.
- 필수 컨테이너 제목은 정의 그룹 이름에 씀 · 항목 · 값 · 선택 컨테이너는 이름 표에 씀.
- 이름만 바뀜 — types(옵션 · 키) 그대로 · 이름 표에 없는 키를 새로 만들지 않음.
- 이름 표가 빈 종류는 명세 글로 읽음.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
@pytest.fixture(scope="module")
def r() -> dict:
proc = subprocess.run(
["node", str(HERE / "helper_m02_so_def_spec.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_배관_나무(r) -> None:
assert r["levels"][0] == 5
assert r["levels"][1] > 5 and r["levels"][2] > 10
assert r["unique"] is True
assert r["pipeTitle"]["tag"] == "required" and r["pipeTitle"]["group"] == "grv6lny"
assert r["optional"]["tag"] == "optional" and r["optional"]["group"] is None
assert r["basinForm"]["label"] == "I형" and r["basinForm"]["key"] == "I"
def test_이름_고침_자리(r) -> None:
assert r["groupName"] == "관 본체"
assert r["names"] == [None, "관 지름", "Ø800", "흙막이 더"]
assert r["reread"] == ["관 본체", "관 지름", "Ø800", "흙막이 더"]
def test_구조는_그대로(r) -> None:
assert r["sameTypes"] is True and r["sameKeys"] is True
def test_이름_표_없는_종류(r) -> None:
assert r["bareLabel"] == "흙막이"
assert r["bareGroup"] == "유입"
@@ -0,0 +1,155 @@
/* 52-11 — M02 「구조물 옵션 정의」 = 구조물 명세 이름 고치기(명세 있는 종류) · B05 가 그 이름으로 그림
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5211 = "단계";` 를 붙여
* M02 마스터 층(#/m02-master-template?root=master) · 시스템 관리자:
* orca eval --expression "window.__m5211='m02'; $(cat 이 파일)"
* B05 종단설계(#/b05-profile) · 목록에 「배관 1」:
* orca eval --expression "window.__m5211='b05'; $(cat 이 파일)"
* 쓰는 것: 없음 — M02 는 화면 사본만 고치고 [저장] 안 누름 · 되돌린 뒤 판 그대로를 GET 으로 봄 ·
* B05 는 받아 둔 종류 객체(메모리)만 고침 · 끝나면 orca reload 로 사본을 버림
* 기대 수치(m02):
* - spec = 배관 나무 줄(컨테이너 5) · old = 옛 편집기 0(옵션 · 배치 · 그룹 · 규칙 · 기준 측점) · other = 명세 없는 종류는 옛 편집기
* - 이름 칸만 고침: inputs = 줄 수 · 키 · 꼴은 글(입력 칸 아님)
* - 컨테이너 제목 · 항목 이름 · 값 이름 고침 → 미리보기 legend · 칸 이름 · 선택지 글 따라감 · [저장] 켜짐
* - 빈 이름 → issues 「명세 이름」 · [저장] 꺼짐 · 되돌리면 이름 그대로 · 판 그대로
* 기대 수치(b05): 사본 이름(컨테이너 · 항목 · 값)이 배관 폼에 그대로 → named.* = true
*/
(async () => {
const MODE = window.__m5211 || "m02";
const W = (ms) => new Promise((r) => setTimeout(r, ms));
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
const until = async (fn, ms = 10000) => {
for (let t = 0; t < ms; t += 150) {
const v = fn();
if (v) return v;
await W(150);
}
return null;
};
const put = (box, value) => {
box.value = value;
box.dispatchEvent(new Event("input", { bubbles: true }));
box.dispatchEvent(new Event("change", { bubbles: true }));
};
const NEW = { pipe: "관 본체X", "pipe.diameter": "관 지름X", "pipe.diameter=800": "Ø800X" };
const out = { mode: MODE };
if (MODE === "m02") {
const getDef = async () =>
(await fetch("/api/m02/structure-definition", { credentials: "include" })).json();
const first = await getDef();
out.판 = first.판;
const row = await until(() => $("[data-options-row]"));
if (!row) return JSON.stringify({ error: "정의 줄 없음" });
row.click();
if (!(await until(() => $$("[data-sod-kind]").length)))
return JSON.stringify({ error: "종류 목록 없음" });
// 명세 없는 종류 — 옛 편집기 그대로
$('[data-sod-kind="retaining_wall"]').click();
await W(200);
out.other = { base: !!$("[data-sod-base]"), spec: !!$("[data-sod-spec-block]") };
// 배관 — 명세 이름 나무
$('[data-sod-kind="pipe"]').click();
await W(200);
const block = $("[data-sod-spec-block]");
const detail = $("[data-sod-detail]");
const rows = $$("[data-sod-spec]", block);
out.spec = {
rows: rows.length,
containers: $$(".is-level-0", block).length,
inputs: $$("input, select, textarea", block).filter(
(n) => !n.closest("[data-sod-spec-preview]"),
).length,
nameInputs: $$("[data-sod-spec-name]", block).length,
keysReadOnly: rows.every((r) => r.querySelector("small.m02-opts__key")),
};
out.old = {
base: !!$("[data-sod-base]", detail),
blocks: detail.children.length, // 머리 칸 + 명세 칸 = 2
};
const save = $("[data-sod-save]");
out.saveBefore = save.disabled;
const box = (k) => $(`[data-sod-spec="${k}"] [data-sod-spec-name]`);
const orig = Object.fromEntries(Object.keys(NEW).map((k) => [k, box(k).value]));
for (const [k, v] of Object.entries(NEW)) {
put(box(k), v);
await W(150);
}
const preview = $("[data-sod-spec-preview]");
const pipeBox = $('[data-spec-container="pipe"]', preview);
out.preview = {
title: pipeBox.querySelector("legend").textContent.includes(NEW.pipe),
item: pipeBox
.querySelector('[data-spec-item="pipe.diameter"]')
.textContent.includes(NEW["pipe.diameter"]),
choice: [...pipeBox.querySelectorAll('[data-spec-item="pipe.diameter"] option')].some(
(o) => o.textContent === NEW["pipe.diameter=800"],
),
};
out.saveAfterEdit = $("[data-sod-save]").disabled;
// 빈 이름 → 저장 막힘
put(box("pipe.diameter"), "");
await W(150);
out.blank = {
issue: $("[data-sod-issues]").textContent.includes("명세 이름"),
save: $("[data-sod-save]").disabled,
};
// 되돌림
for (const [k, v] of Object.entries(orig)) {
put(box(k), v);
await W(150);
}
out.restored = Object.entries(orig).every(([k, v]) => box(k).value === v);
out.판After = (await getDef()).판;
out.ok =
out.other.base &&
!out.other.spec &&
out.spec.containers === 5 &&
out.spec.inputs === out.spec.rows &&
out.spec.nameInputs === out.spec.rows &&
out.spec.keysReadOnly &&
!out.old.base &&
out.old.blocks === 2 &&
out.preview.title &&
out.preview.item &&
out.preview.choice &&
!out.saveAfterEdit &&
out.blank.issue &&
out.blank.save &&
out.restored &&
out.판After === out.판;
} else {
const sec = $(".b05-structure-section");
if (!sec) return JSON.stringify({ error: "구조물 배치 칸 없음" });
const url = performance
.getEntriesByType("resource")
.map((e) => e.name)
.filter((n) => n.includes("/B05_Profile/B05_Profile_Api_Structures.ts"))
.pop();
const api = await import(url);
const pipe = (await api.fetchStructureTypes(true)).find((t) => t.type_id === "pipe");
pipe.spec_names = { ...(pipe.spec_names ?? {}), ...NEW };
await until(() => $$(".b05-route__irregular-list li").length);
const li = $$(".b05-route__irregular-list li").find((l) =>
/배관 1$/.test(l.textContent.trim()),
);
const spec = () => $(".b05-structure__spec", sec);
(li.querySelector("button") ?? li).click();
await W(800);
if (!(spec() && spec().offsetParent !== null)) (li.querySelector("button") ?? li).click();
await until(() => spec() && spec().offsetParent !== null);
const pipeBox = $('[data-spec-container="pipe"]', spec());
out.named = {
title: pipeBox.querySelector("legend").textContent.includes(NEW.pipe),
item: pipeBox
.querySelector('[data-spec-item="pipe.diameter"]')
.textContent.includes(NEW["pipe.diameter"]),
choice: [...pipeBox.querySelectorAll('[data-spec-item="pipe.diameter"] option')].some(
(o) => o.textContent === NEW["pipe.diameter=800"],
),
};
(li.querySelector("button") ?? li).click(); // 재선택 = 해제
out.ok = Object.values(out.named).every(Boolean);
}
return JSON.stringify(out);
})();
@@ -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);
})();
@@ -0,0 +1,158 @@
/* 52-5 — B05 · B06 좌측 「구조물 배치」 노출형 횡단수로 · 개거(겉도랑) = 사용자 구조물 표 05
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(랩탑_보조) · 폼 비어 있음 · 로그인 상태
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m525 = "단계";` 를 붙여
* orca eval --expression "window.__m525='edit'; $(cat 이 파일)" → 두 종류 모양 · 추가 · 잇기 · [저장]
* (orca reload 로 다시 열기)
* orca eval --expression "window.__m525='verify'; $(cat 이 파일)" → 저장 값 그대로 · 다시 고름
* (B06 으로 옮겨 — 같은 모양)
* orca eval --expression "window.__m525='verify'; $(cat 이 파일)" → B06 에서도 같은 값 · 같은 모양
* orca eval --expression "window.__m525='restore'; $(cat 이 파일)" → 추가한 둘 삭제 · [저장]
* 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 지움)
* 기대 수치:
* shape(종류마다) — optionInputs 0 · containers 0 · summaryRows 1 · summaryVisible true · summaryRightAfterPosition true ·
* anchor true · stale false
* edit — added 둘 · 잇기 이름 값 있음 · 저장 뒤 목록 2 늘어남
* verify — 같은 모양 · 잇기 이름 그대로 · 옵션 값 없음
*/
(async () => {
const MODE = window.__m525 || "edit";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 15000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
const vis = (n) => !!n && n.offsetParent !== null;
const sec = () => document.querySelector(".b05-structure-section");
const body = () => sec().querySelector(".b05-route__panel-body");
const selects = () => sec().querySelectorAll(".b05-structure__grid select");
const fire = (el, value) => {
el.value = value;
el.dispatchEvent(new Event("change", { bubbles: true }));
};
const listItems = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
const NAMES = { cross_drain_exposed: "노출형 횡단수로", open_ditch: "개거(겉도랑)" };
const AT = { cross_drain_exposed: [2, 7], open_ditch: [6, 3] };
const shape = (typeId) => {
const kids = [...body().children];
const position = kids.find((c) => c.classList.contains("b05-structure__position-row"));
const summary = kids.find((c) => c.classList.contains("b05-structure__summary"));
const optionRow = kids.filter((c) => c.classList.contains("b05-structure__grid"))[1];
const after = kids.slice(kids.indexOf(position) + 1).find((c) => vis(c));
const anchorWrap = position.querySelectorAll(":scope > label")[2];
return {
typeId,
optionInputs: vis(optionRow) ? optionRow.querySelectorAll("input, select").length : 0,
containers: [...sec().querySelectorAll("[data-spec-container]")].filter(vis).length,
summaryRows: [...sec().querySelectorAll("[data-b05-summary=row]")].filter(vis).length,
summaryVisible: vis(summary),
summaryRightAfterPosition: after === summary,
anchor: vis(anchorWrap),
stale: vis(sec().querySelector(".b05-structure__stale")),
linked: summary.querySelector(".b05-structure__summary-name")?.textContent ?? "",
};
};
const pickType = async (typeId) => {
const [group, type] = selects();
fire(group, "A");
await w(300);
fire(type, typeId);
await w(500);
};
const linkFirstLeaf = async () => {
sec().querySelector("[data-b05-summary=pick]").click();
await until(() => document.querySelector(".ui-item-picker"));
await w(600);
const modal = document.querySelector(".ui-item-picker");
// 끝 항목 = ▸ · ▾ 로 안 시작하고 모달 조작 단추가 아닌 첫 단추
const ctl = ["닫기", "개인용으로 복사", "관리 화면에서 고치기", "해제", "적용"];
const leaf = [...modal.querySelectorAll("button")].find((b) => {
const t = b.textContent.trim();
return t && !/^[▸▾]/.test(t) && !ctl.includes(t);
});
leaf?.click();
await w(300);
[...modal.querySelectorAll("button")].find((b) => b.textContent.trim() === "적용")?.click();
await w(500);
return !!leaf;
};
const clickBtn = async (text) => {
[...document.querySelectorAll("button")]
.find((b) => b.textContent.trim() === text && vis(b))
?.click();
await w(1500);
};
const out = { mode: MODE, items: [] };
if (MODE === "edit") {
await until(() => listItems().length > 0);
out.before = listItems().length;
for (const typeId of Object.keys(NAMES)) {
await pickType(typeId);
const row = { ...shape(typeId), phase: "edit" };
const [no, dist] = sec()
.querySelectorAll(".b05-structure__position-row > label")[2]
.querySelectorAll("input");
fire(no, String(AT[typeId][0]));
fire(dist, String(AT[typeId][1]));
await w(200);
row.picked = await linkFirstLeaf();
row.linked = shape(typeId).linked;
sec().querySelector(".is-primary").click(); // 추가
await w(800);
out.items.push(row);
}
out.after = listItems().length;
await clickBtn("저장");
out.listed = listItems()
.map((l) => l.textContent.trim())
.filter((t) => /횡단수로|개거/.test(t));
} else if (MODE === "verify") {
await until(() => listItems().length > 0);
for (const typeId of Object.keys(NAMES)) {
const li = listItems().find((l) => l.textContent.includes(NAMES[typeId]));
if (!li) {
out.items.push({ typeId, missing: true });
continue;
}
(li.querySelector("button") ?? li).click();
await w(800);
// B06 은 다른 항목이 골라져 있으면 첫 눌림이 풀기만 함 — 안 불러와졌으면 한 번 더
if (selects()[1].value !== typeId) {
(li.querySelector("button") ?? li).click();
await w(800);
}
out.items.push({ ...shape(typeId), phase: "verify", title: li.title || "" });
(li.querySelector("button") ?? li).click(); // 재선택 = 해제
await w(300);
}
} else if (MODE === "restore") {
await until(() => listItems().length > 0);
for (const typeId of Object.keys(NAMES)) {
const li = listItems().find((l) => l.textContent.includes(NAMES[typeId]));
if (!li) continue;
(li.querySelector("button") ?? li).click();
await w(600);
await clickBtn("삭제");
}
await clickBtn("저장");
out.left = listItems().length;
}
const rows = out.items.filter((r) => !r.missing);
out.shapeOk =
rows.length === 2 &&
rows.every(
(r) =>
r.optionInputs === 0 &&
r.containers === 0 &&
r.summaryRows === 1 &&
r.summaryVisible &&
r.summaryRightAfterPosition &&
r.anchor &&
!r.stale,
);
return JSON.stringify(out);
})();
@@ -0,0 +1,217 @@
/* 52-6 — 횡단배수 › 맹암거(표 06 · 새 종류 `cross_underdrain`) · B05 · B06 「구조물 배치」
* 시작 화면: B05 종단설계(#/b05-profile) · 아무것도 안 고름 · 측점 3+7 이 비어 있음
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m526 = "단계";` 를 붙여
* orca eval --expression "window.__m526='edit'; $(cat 이 파일)" → 종류 목록 · 컨테이너 · 값 · 잇기 · [추가] · 다시 열기
* orca eval --expression "window.__m526='b06'; $(cat 이 파일)" → B06 에서 같은 항목 다시 열기 · B05 와 같은 그리기
* orca eval --expression "window.__m526='save'; $(cat 이 파일)" → [저장] 요청 가로챔(몸통만 봄)
* orca eval --expression "window.__m526='restore'; $(cat 이 파일)" → sessionStorage 를 처음 그대로 · 뒤에 orca reload
* 쓰는 것: 없음 — [저장] 은 fetch 를 가로채 서버로 안 보냄(공유 storage) · 서버 저장 왕복은 test_52_6_cross_underdrain
* 기대 수치(edit): b05Types 에 cross_underdrain:맹암거 · underdrain 없음 · box 맹암거 · keys 높이 · 경사 ·
* 잇기 줄 1(맹암거 상자 안만 · 종류 단위 줄 가림 · 52-6b) · pending 높이 0.6 · 경사 2.5 · 잇기 gcrossud 지정 · reopened 같음
* 기대 수치(b06): b06Types A 에 맹암거 · B 에 종단배수 맹암거 따로 · sameDrawing · 값 같음
* 기대 수치(save): saved = PUT 몸통에 같은 값(67 m · 0.6 · 2.5 · gcrossud)
*/
(async () => {
const MODE = window.__m526 || "edit";
const KEY = "cross_underdrain";
const GROUP = "gcrossud";
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 vis = (n) => n && n.offsetParent !== null;
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 api = await import(modUrl("/B05_Profile/B05_Profile_Api_Structures.ts"));
const sec = () => document.querySelector(".b05-structure-section");
const sels = () => sec().querySelectorAll(".b05-structure__grid select");
const choose = async (sel, value) => {
sel.value = value;
sel.dispatchEvent(new Event("change", { bubbles: true }));
await w(300);
};
const typesOf = async (group) => {
await choose(sels()[0], group);
return [...sels()[1].options].filter((o) => o.value).map((o) => `${o.value}:${o.text}`);
};
const box = () => sec().querySelector(`[data-b05-group="${GROUP}"]`);
const input = (k) => box()?.querySelector(`[data-option-keys="${k}"] input`);
const shape = () => {
const b = box();
return {
legend: b?.querySelector("legend").textContent,
keys: [...(b?.querySelectorAll("[data-option-keys]") ?? [])].map(
(n) => `${n.dataset.optionKeys}|${n.querySelector("span").textContent}`,
),
values: ["culvert_height_m", "culvert_slope_pct"].map((k) => input(k)?.value),
link: b?.querySelector(".b05-structure__summary-name").textContent,
linked: b?.querySelector(".b05-structure__summary-name").classList.contains("is-assigned"),
linkRows: [...sec().querySelectorAll("[data-b05-summary=row]")].filter(vis).length,
};
};
const item = () =>
[...document.querySelectorAll(".b05-route__irregular-list li")].find((l) =>
/^3\+7.*맹암거/.test(l.textContent.trim()),
);
const openItem = async () => {
await until(() => item());
const li = item();
(li.querySelector("button") ?? li).click();
await w(600);
// 이미 고른 채로 열렸으면 누르면 풀림 — 풀렸으면 한 번 더(재선택 = 해제)
if (sels()[1].value !== KEY) (li.querySelector("button") ?? li).click();
await until(() => vis(box()));
await w(300);
};
const pickLink = async () => {
box().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 pendingMine = () =>
(api.readPendingStructures(projectId) ?? []).filter((s) => s.type_id === KEY);
const out = { mode: MODE };
if (MODE === "edit") {
sessionStorage.setItem("__526_session", JSON.stringify({ ...sessionStorage }));
out.b05Types = await typesOf("A");
out.b05HasLongUnderdrain = (await typesOf("B")).some((t) => t.startsWith("underdrain:"));
await typesOf("A");
await choose(sels()[1], KEY);
out.boxBeforeStation = { shown: vis(box()), locked: !!box()?.querySelector(".is-locked") };
const pos = [...sec().querySelectorAll(".b05-structure__position-row input")].filter(vis);
for (const [el, v] of [
[pos[0], "3"],
[pos[1], "7"],
]) {
el.value = v;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
await w(300);
for (const [k, v] of [
["culvert_height_m", "0.6"],
["culvert_slope_pct", "2.5"],
]) {
input(k).value = v;
input(k).dispatchEvent(new Event("input", { bubbles: true }));
input(k).dispatchEvent(new Event("change", { bubbles: true }));
}
await w(200);
await pickLink();
out.form = shape();
sec().querySelector(".is-primary").click(); // [추가]
await w(800);
const mine = pendingMine();
out.pending = mine.map((s) => ({
chainage_m: s.chainage_m,
options: s.options,
groups: Object.keys(s.group_items ?? {}),
}));
// 종단 알약 — 약자 · 색이 종단배수 맹암거(맹암거 · #1f7fb8)와 따로
const types = await api.fetchStructureTypes(true);
const ids = new Set(mine.map((s) => s.structure_id));
if (!document.querySelector(".b05-structure__mark")) {
[...document.querySelectorAll("button")]
.find((b) => b.textContent.trim() === "▲종단")
?.click();
await until(() => document.querySelector(".b05-structure__mark"), 8000);
}
out.marks = [...document.querySelectorAll(".b05-structure__mark")]
.filter((m) => ids.has(m.dataset.structureId))
.map((m) => `${m.textContent}|${m.style.color}`);
out.longStyle = types.find((t) => t.type_id === "underdrain")?.style;
// 다시 열기 — 폼을 비우고 목록에서 다시 고름
const li = item();
(li.querySelector("button") ?? li).click(); // 재선택 = 해제
await w(400);
await openItem();
out.reopened = shape();
out.reopenSame = JSON.stringify(out.reopened) === JSON.stringify(out.form);
}
if (MODE === "save") {
// [저장] — 요청을 가로채 몸통만 봄(서버 · storage 안 감) · 뒤에 화면이 서버 목록을 다시 읽어 항목이 사라짐
const real = window.fetch;
const sent = [];
window.fetch = async (url, init = {}) => {
const method = (init.method || "GET").toUpperCase();
if (method !== "GET" && String(url).includes(`/projects/${projectId}/`)) {
sent.push({ url: String(url), method, body: init.body ? String(init.body) : "" });
return new Response(JSON.stringify({ status: "success", revision: 0, count: 0 }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return real(url, init);
};
try {
document.querySelector('[data-dock-action="save"]').click();
await w(3000);
await until(() => !document.querySelector(".ui-overlay.is-active"), 20000);
} finally {
window.fetch = real;
}
const put = sent.find((r) => r.url.includes("/route/structures") && r.method === "PUT");
const body = put ? JSON.parse(put.body) : null;
const saved = body?.structures.filter((s) => s.type_id === KEY) ?? [];
out.saved = saved.map((s) => ({
chainage_m: s.chainage_m,
options: s.options,
groups: Object.keys(s.group_items ?? {}),
}));
out.sent = sent.map((r) => `${r.method} ${r.url.split("/api/")[1] ?? r.url}`);
}
if (MODE === "b06") {
await openItem();
const b05 = shape();
location.hash = "#/b06-section";
await until(() => location.hash.includes("b06"), 5000);
await w(6000);
await openItem();
const b06 = shape();
out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06);
out.b06 = b06;
sels()[1].value = "";
const li = item();
(li.querySelector("button") ?? li).click(); // 고름 풂
await w(300);
out.b06TypesA = await typesOf("A");
out.b06TypesB = await typesOf("B");
}
if (MODE === "restore") {
const raw = sessionStorage.getItem("__526_session");
if (!raw) return JSON.stringify({ error: "처음 sessionStorage 없음" });
const before = JSON.parse(raw);
sessionStorage.clear();
for (const [k, v] of Object.entries(before)) sessionStorage.setItem(k, v);
out.restored = JSON.stringify({ ...sessionStorage }) === JSON.stringify(before);
out.mine = pendingMine().length;
}
return JSON.stringify(out);
})();
@@ -0,0 +1,99 @@
/* 52-8 — B05 · B06 좌측 「구조물 배치」 종단배수(B) · 계류사방(D) 고르기 목록에서 숨김
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub2_검증)
* 부르는 법: 앞에 `window.__m528 = "단계";` 를 붙여
* orca eval --expression "window.__m528='seed'; $(cat 이 파일)" → 옛 값 둘(골막이 D · 측구 B)을 세션 초안에만 둠
* (orca reload)
* orca eval --expression "window.__m528='verify'; $(cat 이 파일)" → 군 목록 · 우클릭 메뉴 · 옛 값 열기 (B05)
* (#/b06-section 으로 가서 reload 후 verify 한 번 더)
* orca eval --expression "window.__m528='clear'; $(cat 이 파일)" → 세션 초안 비움(서버 저장 안 함)
* 기대 수치(verify): visibleGroups 에 B · D 없음(0) · hiddenGroups 가림만 · menuHasBD 0 ·
* 옛 값 목록 둘 그대로 · 옛 값을 열면 군 = D / B 로 열림(editGroup) · 잇기 칸 pickLinks 골막이(D) 1 · 측구(B) 0
*/
(async () => {
const MODE = window.__m528 || "verify";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
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 api = await import(modUrl("/B05_Profile/B05_Profile_Api_Structures.ts"));
const out = { mode: MODE, hash: location.hash };
if (MODE === "seed") {
const base = {
placement_source: "manual",
status: "draft",
revision: 0,
geometry: null,
memo: "",
options: {},
};
api.writePendingStructures(projectId, [
{
...base,
structure_id: "m528d",
type_id: "erosion_check",
placement: "point",
chainage_m: 40,
},
{
...base,
structure_id: "m528b",
type_id: "ditch_side",
placement: "interval",
start_m: 60,
end_m: 80,
},
]);
out.seeded = api.readPendingStructures(projectId).length;
return JSON.stringify(out);
}
if (MODE === "clear") {
api.writePendingStructures(projectId, null);
out.cleared = api.readPendingStructures(projectId) === null;
return JSON.stringify(out);
}
const groupSel = [...document.querySelectorAll("select")].find((s) =>
[...s.options].some((o) => o.text.includes("A 횡단배수")),
);
const isBD = (o) => /^(B|D) /.test(o.text);
out.visibleGroups = [...groupSel.options].filter((o) => !o.hidden && o.value).map((o) => o.text);
out.visibleBD = [...groupSel.options].filter((o) => !o.hidden && isBD(o)).length;
out.hiddenGroups = [...groupSel.options].filter((o) => o.hidden).map((o) => o.text);
// 우클릭 메뉴 목록(공용 만들기) — 종류 전부를 줘도 B · D 군이 안 나옴
const menuMod = await import(modUrl("/B05_Profile/B05_Profile_UI_Profile_Structures.ts"));
const types = (await api.fetchStructureTypes(true)).map((t) => ({
type_id: t.type_id,
group: t.group,
name: t.name,
}));
const menu = menuMod.structureGroupMenuItems(types, () => {});
out.menuGroups = menu.map((m) => m.label);
out.menuHasBD = menu.filter((m) => /^(B|D) /.test(m.label)).length;
// 옛 값 — 목록에 그대로 · 열면 그 군으로 열림
const rows = [...document.querySelectorAll(".b05-route__irregular-list li")];
out.rows = rows.map((r) => r.textContent.trim().replace(/\s+/g, " ").slice(0, 40));
const old = rows.find((r) =>
(window.__m528row ? new RegExp(window.__m528row) : /골막이/).test(r.textContent),
);
if (old) {
(old.querySelector("button") ?? old).click();
await w(1500);
const sels = [...document.querySelectorAll("select")].filter((s) =>
[...s.options].some((o) => o.text.includes("A 횡단배수")),
);
// 다시 열면 고른 것이 복원돼 있음 — 누르면 풀리므로 풀렸으면 한 번 더
if (!sels[0]?.value) {
(old.querySelector("button") ?? old).click();
await w(1500);
}
out.editGroup = sels[0]?.value;
// 종단배수(B)는 조합 잇기 칸이 없음(0) · 계류사방(D)은 옛 그대로(1)
out.pickLinks = [...document.querySelectorAll('[data-b05-summary="pick"]')].filter(
(n) => n.offsetParent,
).length;
}
return JSON.stringify(out);
})();