auto: 2026-10-05 13:49 (EOMSANGDON-HOME)

This commit is contained in:
2026-10-05 13:49:14 +09:00
parent 15e08b28cb
commit 79ca220823
11 changed files with 203 additions and 190 deletions
@@ -23,6 +23,7 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from common_util.common_util_drainage_context import DrainageContext, load_drainage_context
from common_util.common_util_drainage_detail import DrainageDetail, build_detail
from common_util.common_util_drainage_pipes import (
@@ -30,6 +31,7 @@ from common_util.common_util_drainage_pipes import (
PIPE_SOURCE_USER,
PipePoint,
carry_facility_attributes,
carry_indices,
clear_pipe_points,
load_pipe_points,
parse_pipe_points,
@@ -64,14 +66,40 @@ def _build(
if points:
_retag(detail.pipes, points)
rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes]
# 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관
fresh = [
pick is None
for pick in carry_indices(
[point.chainage_m for point in rebuilt], [point.chainage_m for point in points or []]
)
]
# 계산기는 chainage만 다뤄 시설 종류·구간·옵션이 사라진다 — 원본에서 되붙인다
# (2026-08-17 계곡 통과 시설: 배관/BOX암거/물넘이/세월교).
carried = carry_facility_attributes(rebuilt, points or [])
# 되붙인 뒤에 추천을 얹는다 — 순서가 뒤바뀌면 승계된 사용자 값이 추천에 덮인다.
_apply_recommendations(detail, carried)
_apply_new_defaults([point for point, new in zip(carried, fresh) if new])
return detail, carried
def _apply_new_defaults(points: list[PipePoint]) -> None:
"""새로 놓인 관에만 등록부 기본값을 **빈 키에** 채움(PLAN 46-7 · 추천 뒤 — 추천 관경이 이김).
시설이 생길 때 기본값을 칸에 실제 값으로 넣어 두고 사용자가 고침 — 이미 있던 관의 빈 칸은 그대로
(옛 프로젝트 빈칸 = 미확정 유지 · 사용자 결정 2026-10-05).
"""
types = structure_type_map()
for point in points:
definition = types.get(point.facility)
if definition is None:
continue
options = dict(point.options or {})
for field in definition.options:
if field.key not in options and field.default not in (None, ""):
options[field.key] = field.default
point.options = options or None
def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None:
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다."""
by_chainage = {round(point.chainage_m, 2): point.source for point in points}
+8 -6
View File
@@ -382,9 +382,10 @@
"label": "월류 폭",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
"default": 5,
"required": false,
"phase": "detail",
"default_basis": "물넘이 월류 폭 기본 5m — 사용자 확정값(엔진 FORD_PAVEMENT_DEFAULT_WIDTH_M 과 같음)"
},
{
"key": "ford_slope_pct",
@@ -458,9 +459,10 @@
"label": "월류 폭",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
"default": 10,
"required": false,
"phase": "detail",
"default_basis": "세월교 월류 폭 기본 10m — 사용자 확정값(엔진 FORD_DEFAULT_WIDTH_M 과 같음)"
},
{
"key": "wing_in",
+20 -49
View File
@@ -53,7 +53,6 @@ import {
optionalSelect,
OUTLET_REVET_KEYS,
putNumber,
REVET_COMMON_DEFAULTS,
stepper,
WING_IN_KEYS,
WING_OUT_KEYS,
@@ -246,11 +245,11 @@ export function createFacilityOptionsForm(
root.hidden = true;
// ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ──────────────
// 관종 파형강관·관경 1000(사용자 확정 기본)은 **제안으로만** — 「안 정함」이 실제 상태(판정 ③).
const pipeMaterial = optionalSelect("— 안 정함 (제안 파형강관) —", ["흄관", "VR관", "파형강관"]);
// 기본값(관종 파형강관 · 관경 1000 · 등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴(PLAN 46-7).
const pipeMaterial = optionalSelect("— 안 정함 —", ["흄관", "VR관", "파형강관"]);
const pipeDiameter = document.createElement("select");
pipeDiameter.replaceChildren(
new Option("— 안 정함 (제안 Ø1000) —", ""),
new Option("— 안 정함 —", ""),
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
);
// 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다
@@ -264,10 +263,10 @@ export function createFacilityOptionsForm(
// ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ─────────────────
const inletGroup = group("유입구");
// 「안 정함」이면 기슭막이 칸을 보이되(제안) 값은 안 실음 — 기본값을 몰래 확정으로 안 바꿈(판정 ③).
// 「안 정함」이면 기슭막이 칸을 보이되 값은 안 실음.
const inletType = document.createElement("select");
inletType.replaceChildren(
new Option("— 안 정함 (제안 기슭막이) —", ""),
new Option("— 안 정함 —", ""),
...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)),
);
const basinForm = optionalSelect("선택", [
@@ -285,7 +284,7 @@ export function createFacilityOptionsForm(
]);
const basinMaterialField = labeled("형태", basinMaterial, "inlet_basin_material");
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
const basinLength = numberInput("0.1", "0", "제안 2");
const basinLength = numberInput("0.1", "0");
// 기준측점 전 · 후 — 옛 횡단 조정창에만 있던 칸을 폼으로(계획서 24-4a).
const basinSplit = createBasinSplitFields(basinLength);
basinSplit.onChange(() => emit());
@@ -298,11 +297,7 @@ export function createFacilityOptionsForm(
];
const height = (): string =>
String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000));
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(찰)",
height,
});
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { height });
// 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다
// (2026-08-17 사용자 지시 3).
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리(2026-08-29 사용자 지시 4).
@@ -323,12 +318,8 @@ export function createFacilityOptionsForm(
// ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ──
const outletGroup = group("유출구");
const outletType = optionalSelect("— 안 정함 (제안 기슭막이) —", ["기슭막이"]);
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(메)",
height,
});
const outletType = optionalSelect("— 안 정함 —", ["기슭막이"]);
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { height });
outletGroup.body.append(
grid(labeled("구조", outletType, "outlet_type"), outletRevet.formField),
...outletRevet.rows,
@@ -349,10 +340,10 @@ export function createFacilityOptionsForm(
// 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다.
// 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만
// 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다.
const revetSide = optionalSelect("— 안 정함 (제안 양쪽) —", ["양쪽", "좌", "우"]);
const revetSide = optionalSelect("— 안 정함 —", ["양쪽", "좌", "우"]);
const revetTopRow = grid(labeled("설치 측", revetSide, "side"));
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)");
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)");
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS);
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS);
/** 좌·우 이름표 — B06이 그 측점의 벽 방향을 알려 주면 바뀐다. 기본은 유입=좌. */
let revetSideLabels: { inlet: "좌" | "우"; outlet: "좌" | "우" } = {
inlet: "좌",
@@ -387,12 +378,12 @@ export function createFacilityOptionsForm(
// ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정).
const boxSize = optionalSelect(`— 안 정함 (제안 ${BOX_SIZE_PRESETS[0][0]}) —`, [
const boxSize = optionalSelect("— 안 정함 —", [
...BOX_SIZE_PRESETS.map(([label]) => label),
BOX_SIZE_CUSTOM,
]);
const boxWidth = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][1]}`);
const boxHeight = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][2]}`);
const boxWidth = numberInput("0.1", "0");
const boxHeight = numberInput("0.1", "0");
const boxCustomRow = grid(
labeled("본체 폭 (m)", boxWidth, "body_width_m"),
labeled("본체 높이 (m)", boxHeight, "body_height_m"),
@@ -415,33 +406,14 @@ export function createFacilityOptionsForm(
boxWidth.value = String(preset[1]);
boxHeight.value = String(preset[2]);
}
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로(제안은 빈 보기 이름).
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로.
boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM;
}
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford` · 기본값은 제안으로만).
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford`).
// 기본값(등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴 — 제안값 단추 없음(PLAN 46-7).
const ford = createFordFields(() => emit());
// [제안값 넣기] — 누른 때만 빈 칸에 제안값(관종·관경·구조·집수정 길이·설치 측·BOX 규격·날개벽·월류).
// 벽 칸(기슭막이)은 벽마다 단추가 따로 있음. 기본값을 몰래 확정으로 바꾸지 않는다(2026-09-14 판정 ③).
const suggest = document.createElement("button");
suggest.type = "button";
suggest.className = "b05-structure__suggest";
suggest.textContent = "제안값 넣기";
suggest.addEventListener("click", () => {
if (!pipeMaterial.value) pipeMaterial.value = "파형강관";
if (!pipeDiameter.value) pipeDiameter.value = "1000";
if (!inletType.value) inletType.value = INLET_KINDS[0].label;
if (!outletType.value) outletType.value = "기슭막이";
if (!basinLength.value) basinLength.value = "2";
if (!revetSide.value) revetSide.value = "양쪽";
if (current === "box_culvert" && !boxSize.value) boxSize.value = BOX_SIZE_PRESETS[0][0];
wingInFields.fillSuggested();
wingOutFields.fillSuggested();
if (current === "ford_pavement" || current === "ford_bridge") ford.fillSuggested(current);
emit();
});
// 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정).
const saveNote = document.createElement("p");
saveNote.className = "b05-structure__owner-note";
@@ -451,7 +423,6 @@ export function createFacilityOptionsForm(
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append(
grid(suggest),
saveNote,
pipeRow,
wingRow,
@@ -593,7 +564,7 @@ export function createFacilityOptionsForm(
}
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
// ⚠ 저장된 값만 칸에 — 없으면 「안 정함」 · 제안은 빈 보기 이름·회색 글씨(2026-09-14 판정 ③).
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
pipeMaterial.value = text("pipe_kind");
pipeDiameter.value = text("pipe_diameter_mm");
// 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다.
@@ -616,7 +587,7 @@ export function createFacilityOptionsForm(
inletRevet.write(options);
outletRevet.write(options);
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
// 값이 없으면 「안 정함」(첫 프리셋 2.0×2.0 은 제안으로만).
// 값이 없으면 「안 정함」.
const savedWidth = Number.parseFloat(text("body_width_m"));
const savedHeight = Number.parseFloat(text("body_height_m"));
const hasSaved =
@@ -212,27 +212,13 @@ export interface RevetmentKeys {
after?: string;
}
/** 부위별 **제안값** — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m. 관 벽 높이는 관경 기준
* (함수로 받아 볼 때마다 셈).
* ⚠ 제안값은 **칸에 채우지 않고 회색 글씨로만** 보임 — 사용자가 적거나 [제안값 넣기]를 누른 때만
* 값이 됨(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」). 칸에 채워 두면 저장이
* 그 값을 적어 **미확정이 저장 한 번에 확정으로 바뀜** — 사용자가 한 일이 없는데 금액이 섬. */
/** 높이 칸 안내 — 관 벽은 비우면 관경 기준 최소 높이(엔진 `pipe_wall_min_height_m` · 함수로 받아
* 볼 때마다 셈) · 빈 글이면 「설계자 입력」. 형태 · 길이 · 전후 기본값은 등록부가 시설이 생길 때
* 칸에 실제 값으로 넣음(PLAN 46-7 · 제안값 단추 없음). */
export interface RevetmentDefaults {
form: string;
length: string;
height: string | (() => string);
before: string;
after: string;
}
/** 부위와 무관한 공통 제안 치수 — 길이 10m(전 5·후 5)·높이 2.5m(독립 기슭막이 · 관 벽은 관경 기준). */
export const REVET_COMMON_DEFAULTS = {
length: "10",
height: "2.5",
before: "5",
after: "5",
} as const;
export const INLET_REVET_KEYS: RevetmentKeys = {
form: "inlet_revet_form",
length: "inlet_revet_length_m",
@@ -273,16 +259,12 @@ function sabangBasisLines(): string[] {
];
}
export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup {
export function createRevetSideGroup(keys: RevetmentKeys): RevetSideGroup {
const box = group("기슭막이");
const kind = document.createElement("select");
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
// 높이는 제안 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). 옛 2.5 는 근거 없어 걷음.
const fields = createRevetmentFields(keys, {
...REVET_COMMON_DEFAULTS,
form: formDefault,
height: "",
});
// 높이는 기본값 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정).
const fields = createRevetmentFields(keys, { height: "" });
const basis = document.createElement("p");
basis.className = "b05-route__note";
basis.replaceChildren(
@@ -326,24 +308,24 @@ export function createRevetmentFields(
keys: RevetmentKeys,
defaults: RevetmentDefaults,
): RevetmentFields {
// ⚠ 제안값은 **회색 글씨(placeholder)로만** — 칸은 비워 둠(`RevetmentDefaults` 주석).
const suggestHeight = (): string =>
const wallFloor = (): string =>
typeof defaults.height === "function" ? defaults.height() : defaults.height;
const form = document.createElement("select");
form.replaceChildren(
new Option(`제안 ${defaults.form}`, ""),
new Option("— 안 정함 —", ""),
...REVET_FORMS.map((value) => new Option(value, value)),
);
const length = keys.length ? numberInput("0.1", "0", `제안 ${defaults.length}`) : null;
// 제안이 없는 칸(독립 기슭막이 높이)은 「설계자 입력」 — 값을 지어내지 않음.
const heightHint = (): string => (suggestHeight() ? `제안 ${suggestHeight()}` : "설계자 입력");
const length = keys.length ? numberInput("0.1", "0") : null;
// 관 벽은 비우면 관경 기준 최소 높이로 섬(엔진) · 독립 기슭막이 높이는 「설계자 입력」.
const heightHint = (): string =>
wallFloor() ? `비우면 관경 기준 ${wallFloor()}` : "설계자 입력";
const height = numberInput("0.1", "0", heightHint());
// 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈.
// 관 벽 기준 높이는 관경을 따라감 — 칸을 볼 때마다 다시 셈.
height.addEventListener("focus", () => (height.placeholder = heightHint()));
// 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동:
// 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다.
const before = keys.before && length ? numberInput("0.1", "0", `제안 ${defaults.before}`) : null;
const after = keys.after && length ? numberInput("0.1", "0", `제안 ${defaults.after}`) : null;
const before = keys.before && length ? numberInput("0.1", "0") : null;
const after = keys.after && length ? numberInput("0.1", "0") : null;
let lastSplitEdit: "before" | "after" = "before";
const readNumber = (input: HTMLInputElement | null): number => {
const value = input ? Number.parseFloat(input.value) : Number.NaN;
@@ -367,25 +349,10 @@ export function createRevetmentFields(
const beforeField = before ? labeled("기준측점 전 (m)", stepper(before, 0.5), keys.before) : null;
const afterField = after ? labeled("기준측점 후 (m)", stepper(after, 0.5), keys.after) : null;
// [제안값 넣기] — **누른 때만** 빈 칸에 제안값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양.
const fill = document.createElement("button");
fill.type = "button";
fill.className = "b05-structure__suggest";
fill.textContent = "제안값 넣기";
fill.addEventListener("click", () => {
if (!form.value) form.value = defaults.form;
if (length && !length.value) length.value = defaults.length;
if (!height.value) height.value = suggestHeight();
if (before && !before.value) before.value = defaults.before;
if (after && !after.value) after.value = defaults.after;
form.dispatchEvent(new Event("change"));
});
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
const rows = [
grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)),
...(beforeField && afterField ? [grid(beforeField, afterField)] : []),
grid(fill),
];
return {
@@ -393,7 +360,7 @@ export function createRevetmentFields(
rows,
lengthInput: length,
write(options) {
// 저장된 값만 칸에 — 없으면 비워 두고 제안값은 회색 글씨로(몰래 채우지 않음).
// 받은 값만 칸에 — 없으면 비워 둠(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
form.value = text(keys.form);
@@ -447,8 +414,8 @@ export function createBasinSplitFields(length: HTMLInputElement): {
read: (target: Record<string, string | number>) => void;
onChange: (handler: () => void) => void;
} {
const before = numberInput("0.1", "0", "제안 1");
const after = numberInput("0.1", "0", "제안 1");
const before = numberInput("0.1", "0");
const after = numberInput("0.1", "0");
let last: HTMLInputElement = before;
const sync = (): void => {
const total = Number.parseFloat(length.value);
@@ -528,10 +495,6 @@ export const WING_OUT_KEYS: WingKeys = {
angle: "wing_out_angle_deg",
};
/** 날개벽 기본 제원 — 설치 있음·짧은쪽 높이 1m·길이 2m·각도 45°
* (2026-08-17 사용자 확정). 유입·유출이 같은 값을 쓴다. */
const WING_DEFAULTS = { install: "있음", height: "1", length: "2", angle: "45" } as const;
interface WingFields {
root: HTMLFieldSetElement;
/** 횡단도에서 그 측 날개벽·측벽을 고를 때 칸을 세우는 자리(B06 전용, 2026-08-30).
@@ -541,18 +504,16 @@ interface WingFields {
syncVisibility: () => void;
write: (options: Record<string, string | number>) => void;
read: (target: Record<string, string | number>) => void;
/** [제안값 넣기] — 빈 칸에만 기본 제원을 넣음. */
fillSuggested: () => void;
}
/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각.
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴).
* ⚠ 기본 제원(있음·1·2·45, 사용자 확정)은 **제안으로만** — 칸은 비워 둠(2026-09-14 판정 ③). */
* 기본 제원(있음·1·2·45, 사용자 확정)은 등록부 기본값 — 시설이 생길 때 칸에 실제 값(PLAN 46-7). */
export function createWingFields(title: string, keys: WingKeys): WingFields {
const install = optionalSelect(`— 안 정함 (제안 ${WING_DEFAULTS.install}) —`, ["있음", "없음"]);
const height = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.height}`);
const length = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.length}`);
const angle = numberInput("1", "0", `제안 ${WING_DEFAULTS.angle}`);
const install = optionalSelect("— 안 정함 —", ["있음", "없음"]);
const height = numberInput("0.1", "0");
const length = numberInput("0.1", "0");
const angle = numberInput("1", "0");
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자).
const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1), keys.height);
@@ -594,12 +555,5 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
putNumber(target, keys.length, length.value);
putNumber(target, keys.angle, angle.value);
},
fillSuggested() {
if (!install.value) install.value = WING_DEFAULTS.install;
if (!height.value) height.value = WING_DEFAULTS.height;
if (!length.value) length.value = WING_DEFAULTS.length;
if (!angle.value) angle.value = WING_DEFAULTS.angle;
syncVisibility();
},
};
}
@@ -3,10 +3,9 @@
* 물넘이포장·세월교 칸 묶음 — 월류 폭·높이·바닥 경사·포장 두께·길이·련 수 + 개략 단면 줄.
*
* 서브폼 본체(`_Drainage_Facility.ts`)가 700줄에 닿아 떼어 냄(2026-09-14).
* ⚠ 기본값을 칸에 안 채움(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」):
* 월류 폭 세월교 10m·물넘이 5m(사용자 확정)는 **회색 제안**, 월류 높이는 설계유량·폭으로 되짚은
* **필요 최소 수심을 회색 제안**으로만 — 사용자가 적거나 [제안값 넣기]를 누른 때만 값.
* 적은 높이가 필요 수심보다 낮으면 여전히 되돌림(계산값 미만은 안 받음).
* 월류 폭 기본값(세월교 10m · 물넘이 5m · 등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴
* (PLAN 46-7 · 제안값 단추 없음). 빈 칸 안내 = 엔진이 비운 칸을 읽는 값(폭 기본 · 필요 수심).
* 적은 높이가 필요 수심보다 낮으면 되돌림(계산값 미만은 안 받음).
* ========================================================================== */
import {
@@ -35,7 +34,6 @@ export interface FordFields {
sync: () => void;
write: (kind: FordKind, options: Record<string, string | number>) => void;
read: (kind: FordKind, target: Record<string, string | number>) => void;
fillSuggested: (kind: FordKind) => void;
}
export function createFordFields(emit: () => void): FordFields {
@@ -66,14 +64,15 @@ export function createFordFields(emit: () => void): FordFields {
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
let minDepthM: number | null = null;
const suggestedWidth = (kind: FordKind): number =>
/** 폭을 비우면 엔진이 쓰는 폭(`B06_Section_Engine_Culvert.FORD_*_DEFAULT_WIDTH_M` 짝). */
const emptyWidth = (kind: FordKind): number =>
kind === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M;
function sync(): void {
const widthM = Number.parseFloat(width.value || width.dataset.suggested || "");
const widthM = Number.parseFloat(width.value || width.dataset.empty || "");
const section = designFlowM3s !== null ? fordSection(designFlowM3s, widthM) : null;
minDepthM = section ? Number(section.depthM.toFixed(2)) : null;
height.placeholder = minDepthM !== null ? `제안 필요 수심 ${minDepthM.toFixed(2)}` : "";
height.placeholder = minDepthM !== null ? `필요 수심 ${minDepthM.toFixed(2)} 이상` : "";
if (designFlowM3s === null) {
summary.textContent =
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
@@ -121,8 +120,8 @@ export function createFordFields(emit: () => void): FordFields {
options[key] !== undefined ? String(options[key]) : "";
count.value = kind === "ford_bridge" ? text("pipe_count") : "";
width.value = text("ford_width_m");
width.dataset.suggested = String(suggestedWidth(kind));
width.placeholder = `제안 ${suggestedWidth(kind)}`;
width.dataset.empty = String(emptyWidth(kind));
width.placeholder = `비우면 ${emptyWidth(kind)}`;
height.value = text("ford_height_m");
slope.value = text("ford_slope_pct");
thickness.value = text("thickness_cm");
@@ -142,10 +141,5 @@ export function createFordFields(emit: () => void): FordFields {
const pipes = Number.parseInt(count.value, 10);
if (Number.isFinite(pipes) && pipes > 0) target.pipe_count = pipes;
},
fillSuggested(kind) {
if (!width.value) width.value = String(suggestedWidth(kind));
sync();
if (!height.value && minDepthM !== null) height.value = minDepthM.toFixed(2);
},
};
}
+13 -28
View File
@@ -61,9 +61,8 @@ interface OptionShape {
}
/**
* 등록부 옵션 입력 한 칸 — **저장된 값만 칸에 넣고, 등록부 기본값은 제안으로만** 보임
* (숫자·글 칸은 회색 글씨, 고르기 칸은 빈 보기 이름). 2026-09-14 브레인 판정 「기본값을 몰래
* 확정으로 바꾸지 않는다」 — 칸에 채워 두면 저장이 그 값을 적어 사용자가 한 일 없이 확정이 됨.
* 등록부 옵션 입력 한 칸 — **받은 값만 칸에 넣음**. 새 구조물은 부르는 쪽이 등록부 기본값을 값으로
* 실어 줌(PLAN 46-7 · 제안값 단추 없음) · 옛 기록의 빈 칸은 빈 칸 그대로(미확정).
* 고르기 칸은 늘 빈 보기를 둠 — 첫 보기를 미리 고르지 않음.
*/
export function optionControl(
@@ -71,15 +70,10 @@ export function optionControl(
stored: string | number | undefined,
): HTMLInputElement | HTMLSelectElement {
const value = stored === undefined || stored === null ? "" : String(stored);
const suggested = option.default === null || option.default === "" ? "" : String(option.default);
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
const tip = option.not_in_table ?? option.empty_means ?? "";
if (option.input === "select") {
const blank = suggested
? `— 안 정함 (제안 ${suggested}) —`
: option.required
? "— 선택 —"
: "— 안 정함 —";
const blank = option.required ? "— 선택 —" : "— 안 정함 —";
const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]);
element.value = value;
if (tip) element.title = tip;
@@ -89,7 +83,7 @@ export function optionControl(
option.input === "number" ? numberInput("0.1", "0") : document.createElement("input");
if (option.input !== "number") element.type = "text";
element.value = value;
element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : "";
element.placeholder = option.required ? "필수 입력" : "";
if (tip) element.title = tip;
const limit = option.warn_above;
if (typeof limit === "number") {
@@ -105,24 +99,15 @@ export function optionControl(
return element;
}
/** [제안값 넣기] — **누른 때만** 빈 칸에 등록부 기본값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양. */
export function suggestButton(
entries: ReadonlyArray<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }>,
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b05-structure__suggest";
button.textContent = "제안값 넣기";
button.addEventListener("click", () => {
const filled = entries.filter((entry) => entry.suggested && !entry.input.value);
filled.forEach((entry) => {
entry.input.value = entry.suggested;
entry.input.dispatchEvent(new Event("input", { bubbles: true }));
});
// 반영은 한 번만 — 칸마다 저장 흐름이 돌지 않게 마지막 칸만 change 를 울림.
filled.at(-1)?.input.dispatchEvent(new Event("change", { bubbles: true }));
});
return button;
/** 새 구조물 칸에 실을 등록부 기본값(PLAN 46-7) — 기본값이 있는 칸만 · 없는 칸은 빈 칸(미확정). */
export function optionDefaults(
options: ReadonlyArray<{ key: string; default: string | number | null }>,
): Record<string, string | number> {
return Object.fromEntries(
options
.filter((option) => option.default !== null && option.default !== "")
.map((option) => [option.key, option.default as string | number]),
);
}
/** 측점번호 + 잔여거리 두 칸 묶음. */
+12 -9
View File
@@ -22,7 +22,7 @@ import {
} from "./B05_Profile_Api_Structures";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { field } from "./B05_Profile_UI_Structures_Fields";
import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import { renderOptionFields as renderOptionFieldsInto } from "./B05_Profile_UI_Structures_Panel_Options";
import { bindSummaryPickers } from "./B05_Profile_UI_Structures_Groups";
import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form";
@@ -239,11 +239,11 @@ export function createStructuresSection(
/** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설·독립 기슭막이, 값은 인자로). */
function syncFacilityForm(
options: Record<string, string | number> = {},
options?: Record<string, string | number>,
designFlow: number | null = null,
): void {
const kind = facilityFormKind(currentType());
facilityOptions.setFacility(kind, options, designFlow);
facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow);
// 「구조물 옵션 보이기」에서 끈 옵션은 시설 폼에서도 가림(값은 옛 값 그대로 저장)
facilityOptions.setHiddenOptions(
kind,
@@ -318,11 +318,16 @@ export function createStructuresSection(
}
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */
function renderOptionFields(values: Record<string, string | number> = {}): void {
renderOptionFieldsInto(optionContext, values);
function renderOptionFields(values?: Record<string, string | number>): void {
renderOptionFieldsInto(optionContext, values ?? newDefaults());
regroup();
}
/** 값 없이 그리면 새 구조물 — 등록부 기본값을 칸에 실제 값으로(PLAN 46-7 · 옛 기록은 받은 값만). */
function newDefaults(): Record<string, string | number> {
return optionDefaults(currentType()?.options ?? []);
}
/** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도
* 포함한다(추가 시 관 지점 정본으로 간다, 2026-08-17 통합). 첫 항목은 빈칸 —
* 초기·리셋 상태를 "안 고름"으로 드러낸다. */
@@ -639,11 +644,9 @@ export function createStructuresSection(
endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step);
memoField.value = "";
syncPlacementFields();
// 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②).
// 새 구조물 — 등록부 기본값을 칸에 실제 값으로(PLAN 46-7 · 사용자가 고치면 됨).
renderOptionFields();
// BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음).
// 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함.
syncFacilityForm({});
syncFacilityForm();
// 임시 배치도 즉시 진행(A군).
if (type.managed_by) commitTempPipe();
syncButtons();
@@ -10,7 +10,7 @@
import { isB05Option, type StructureType } from "./B05_Profile_Api_Structures";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { field, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields";
import { field, optionControl } from "./B05_Profile_UI_Structures_Fields";
import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
/** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */
@@ -56,12 +56,9 @@ export function renderOptionFields(
return;
}
optionRow.hidden = false;
const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> = [];
shown.forEach((option) => {
// ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은
// 첫 보기를 미리 안 고름(2026-09-14 브레인 판정 ①②, `optionControl` 주석).
// 받은 값만 칸에 — 새 구조물은 부르는 쪽(패널)이 등록부 기본값을 `values` 로 실어 줌(PLAN 46-7).
const input = optionControl(option, values[option.key]);
suggestions.push({ input, suggested: String(option.default ?? "") });
const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`;
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다
// (고를 수 없는 칸이라 제안이 아니라 프로그램 값).
@@ -113,9 +110,6 @@ export function renderOptionFields(
input.addEventListener("change", () => refresh(true));
}
});
if (suggestions.some((entry) => entry.suggested)) {
optionRow.append(suggestButton(suggestions));
}
ctx.syncOptionLock();
ctx.syncRangeDisplay();
}
@@ -221,11 +221,6 @@
line-height: 1.3;
}
/* 근거 줄로 옆 칸이 길어져도 [제안값 넣기]가 세로로 늘지 않게. */
.b05-structure__suggest {
align-self: start;
}
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
* (2026-08-17 사용자 지시 2). */
.b05-structure__position-row {
@@ -0,0 +1,86 @@
"""새 구조물 기본값 — 「제안값 넣기」 걷음 · 생길 때 등록부 기본값을 칸에 실제 값으로 (PLAN 46-7).
못박는 것
- 자동 배치로 새로 놓인 관 = 등록부 기본값을 빈 키에 채움 · 추천 관경이 기본값을 이김.
- 이미 있던 관(원본에 있던 자리)의 빈 칸은 그대로(옛 프로젝트 빈칸 = 미확정 유지).
- 월류 폭 기본값(세월교 10 · 물넘이 5)은 등록부에 · 엔진 상수와 같음.
- 화면: 제안값 단추 · 「제안 …」 글이 없음 · 값 없이 그리면 등록부 기본값(`newDefaults`).
"""
from __future__ import annotations
from pathlib import Path
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import (
FORD_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
)
from common_util.common_util_drainage_pipes import PipePoint
ROOT = Path(__file__).resolve().parents[2]
def _defaults(type_id: str) -> dict:
return {
o.key: o.default
for o in structure_type_map()[type_id].options
if o.default not in (None, "")
}
def test_새_관만_기본값_빈_키에만():
fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 800})
basins._apply_new_defaults([fresh])
expected = {**_defaults("pipe"), "pipe_diameter_mm": 800} # 추천(먼저 들어간 값)이 이김
assert fresh.options == expected
assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "기슭막이"
box = PipePoint(chainage_m=20.0, source="stream", facility="box_culvert")
basins._apply_new_defaults([box])
assert box.options["body_width_m"] == 2.0 and box.options["wing_in"] == "있음"
def test_원본에_있던_관은_빈칸_그대로(monkeypatch):
"""`_build` — 원본 목록에 있던 자리는 기본값을 안 받고, 새 자리만 받음."""
class Pipe:
def __init__(self, chainage_m: float, reason: str) -> None:
self.chainage_m, self.reason = chainage_m, reason
class Detail:
pipes = [Pipe(100.0, "stream"), Pipe(300.0, "spacing")]
basins: list = []
monkeypatch.setattr(basins, "build_detail", lambda *_: Detail())
monkeypatch.setattr(basins, "drainage_dir", lambda _: "x")
class Ctx:
vertices: list = []
old = PipePoint(chainage_m=100.0, source="stream") # 옛 관 — 옵션 없음
_, points = basins._build("p", Ctx(), [old])
by = {p.chainage_m: p for p in points}
assert by[100.0].options is None # 옛 빈칸 그대로
assert by[300.0].options == _defaults("pipe") # 새로 보충된 관 = 기본값
def test_월류_폭_기본값은_등록부_엔진과_같음():
assert _defaults("ford_bridge")["ford_width_m"] == FORD_DEFAULT_WIDTH_M
assert _defaults("ford_pavement")["ford_width_m"] == FORD_PAVEMENT_DEFAULT_WIDTH_M
def test_화면에_제안값_단추_제안_글이_없음():
files = [
"B05_Profile/B05_Profile_UI_Drainage_Facility.ts",
"B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts",
"B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts",
"B05_Profile/B05_Profile_UI_Structures_Fields.ts",
"B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts",
]
for name in files:
text = (ROOT / name).read_text(encoding="utf-8")
assert "제안값 넣기" not in text and "b05-structure__suggest" not in text, name
assert "`제안 " not in text and '"제안 ' not in text and "(제안 " not in text, name
panel = (ROOT / "B05_Profile/B05_Profile_UI_Structures_Panel.ts").read_text(encoding="utf-8")
assert "values ?? newDefaults()" in panel and "options ?? newDefaults()" in panel
@@ -42,5 +42,6 @@ def test_TS_가_쓰는_칸이_파일에_있다() -> None:
def test_B05_높이_칸은_제안_없이_설계자_입력() -> None:
assert 'height: "",' in FIELDS and '"설계자 입력"' in FIELDS
# 독립 기슭막이 높이 = 기본값 없음 · 「설계자 입력」 안내(제안값 단추 걷음 · PLAN 46-7)
assert 'createRevetmentFields(keys, { height: "" })' in FIELDS and '"설계자 입력"' in FIELDS
assert "기슭막이 높이 없음 — 근입만 그림" in CHROME