diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index a909b640..a4f121fc 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -26,10 +26,6 @@ * 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다. * ========================================================================== */ -import { - FORD_BRIDGE_DEFAULT_WIDTH_M, - FORD_PAVEMENT_DEFAULT_WIDTH_M, -} from "@config/config_frontend"; import type { DetailPipeInput, PipeFacility, @@ -42,7 +38,6 @@ import { createRevetmentFields, createRevetSideGroup, createWingFields, - fordSection, grid, group, INLET_KINDS, @@ -60,6 +55,7 @@ import { WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets"; +import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford"; export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로 @@ -191,17 +187,13 @@ export function createFacilityOptionsForm( root.hidden = true; // ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ────────────── - // 관종 기본값 = 파형강관(2026-08-17 사용자 확정) — 빈 항목 없이 셋 중 하나. - const pipeMaterial = document.createElement("select"); - pipeMaterial.replaceChildren( - ...["흄관", "VR관", "파형강관"].map((value) => new Option(value, value)), - ); - pipeMaterial.value = "파형강관"; + // 관종 파형강관·관경 1000(사용자 확정 기본)은 **제안으로만** — 「안 정함」이 실제 상태(판정 ③). + const pipeMaterial = optionalSelect("— 안 정함 (제안 파형강관) —", ["흄관", "VR관", "파형강관"]); const pipeDiameter = document.createElement("select"); pipeDiameter.replaceChildren( + new Option("— 안 정함 (제안 Ø1000) —", ""), ...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)), ); - pipeDiameter.value = "1000"; // 별표2 원칙값 + 사용자 확정 기본 (2026-08-17) // 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다 // (2026-09-09, 계획서 4-13 · 소광리 원본 탭 다섯). 비우면 날개벽을 안 센다. const wingWall = optionalSelect("안 놓음", ["A-TYPE", "C-TYPE", "A-TYPE+집수정"]); @@ -210,11 +202,12 @@ export function createFacilityOptionsForm( // ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ───────────────── const inletGroup = group("유입구"); - // 빈값(미지정)을 두면 하위 칸이 전부 숨어 기본값이 안 보인다 — 기슭막이를 기본으로 - // 두고 저유량 지점에서 집수정으로 바꾼다(2026-08-17 사용자 지시). + // 「안 정함」이면 기슭막이 칸을 보이되(제안) 값은 안 실음 — 기본값을 몰래 확정으로 안 바꿈(판정 ③). const inletType = document.createElement("select"); - inletType.replaceChildren(...INLET_KINDS.map((kind) => new Option(kind.label, kind.label))); - inletType.value = INLET_KINDS[0].label; + inletType.replaceChildren( + new Option("— 안 정함 (제안 기슭막이) —", ""), + ...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)), + ); const basinForm = optionalSelect("선택", [ "동물이동형", "□형(기본형)", @@ -230,12 +223,12 @@ export function createFacilityOptionsForm( ]); const basinMaterialField = labeled("형태", basinMaterial); // 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자). - const basinLength = numberInput("0.1"); - basinLength.value = "2"; + const basinLength = numberInput("0.1", "0", "제안 2"); const basinRows = [ grid(labeled("형식", basinForm), labeled("길이 (m)", stepper(basinLength, 1))), ]; - const height = (): string => String(pipeWallMinHeightM(Number(pipeDiameter.value) / 1000)); + const height = (): string => + String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000)); const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(찰)", @@ -257,8 +250,7 @@ export function createFacilityOptionsForm( // ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ── const outletGroup = group("유출구"); - const outletType = document.createElement("select"); - outletType.replaceChildren(new Option("기슭막이", "기슭막이")); + const outletType = optionalSelect("— 안 정함 (제안 기슭막이) —", ["기슭막이"]); const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(메)", @@ -284,8 +276,7 @@ export function createFacilityOptionsForm( // 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다. // 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만 // 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다. - const revetSide = optionalSelect("— 안 정함 —", ["양쪽", "좌", "우"]); - revetSide.value = "양쪽"; + const revetSide = optionalSelect("— 안 정함 (제안 양쪽) —", ["양쪽", "좌", "우"]); const revetTopRow = grid(labeled("설치 측", revetSide)); const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)"); const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)"); @@ -323,12 +314,12 @@ export function createFacilityOptionsForm( // ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ──── // 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정). - const boxSize = optionalSelect("미지정", [ + const boxSize = optionalSelect(`— 안 정함 (제안 ${BOX_SIZE_PRESETS[0][0]}) —`, [ ...BOX_SIZE_PRESETS.map(([label]) => label), BOX_SIZE_CUSTOM, ]); - const boxWidth = numberInput("0.1"); - const boxHeight = numberInput("0.1"); + const boxWidth = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][1]}`); + const boxHeight = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][2]}`); const boxCustomRow = grid(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight)); const boxWrap = document.createElement("div"); boxWrap.append(grid(labeled("본체 규격 (m)", boxSize)), boxCustomRow); @@ -343,106 +334,43 @@ export function createFacilityOptionsForm( boxWidth.value = String(preset[1]); boxHeight.value = String(preset[2]); } - boxCustomRow.hidden = !!preset; + // 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로(제안은 빈 보기 이름). + boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM; } - // ── 세월교 — 구체 내 배관은 배수관과 같은 관종·관경 칸을 쓰고(2026-08-17 사용자 - // 지시) 수량만 따로 받는다. - const fordCount = numberInput("1", "1", "련"); - // 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음(2026-08-30 사용자 지시 3). - // 련은 정수라 소수 자릿수를 두지 않는다. - const fordRow = grid(labeled("수량 (련)", stepper(fordCount, 1, 0))); + // ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford` · 기본값은 제안으로만). + const ford = createFordFields(() => emit()); - // ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시). - // 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음). - // 높이는 설계유량·폭으로 되짚은 필요 수심을 자동으로 채우고, 사용자가 키울 수는 - // 있으나 계산값(필요 최소 수심) 미만은 되돌린다. - const fordWidth = numberInput("0.1"); - const fordHeight = numberInput("0.01"); - const fordWidthRow = grid( - labeled("월류 폭 (m)", stepper(fordWidth, 0.1)), - labeled("월류 높이 (m)", stepper(fordHeight, 0.1)), - ); - // 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정). - // 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다. - const fordSlope = numberInput("0.1"); - fordSlope.placeholder = "노면 기울기"; - // 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(2026-09-14 A3). - // 비우면 표가 안 서고 그 까닭이 뜸 — 지어내지 않는다. - const fordThickness = numberInput("1"); - const fordLength = numberInput("0.1"); - const fordSlopeRow = grid( - labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1)), - labeled("포장 두께 (㎝)", stepper(fordThickness, 1, 0)), - labeled("포장 길이 노폭 방향 (m)", stepper(fordLength, 0.1)), - ); - const fordSummary = document.createElement("p"); - fordSummary.className = "b05-drainage__facility-note"; - /** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */ - let designFlowM3s: number | null = null; - /** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */ - let fordMinDepthM: number | null = null; - /** 직전에 자동으로 채워 넣은 필요 수심(m) — 칸이 아직 그 값이면 "자동"으로 보고 - * 월류 폭이 바뀔 때마다 계산값을 계속 따라가게 한다. 사용자가 다른 값을 넣으면 - * 그때부터 그 값이 이긴다(2026-08-30 사용자: 폭을 바꿔도 높이가 안 따라온다). */ - let fordAutoDepthM: number | null = null; - - function syncFordSummary(): void { - const section = - designFlowM3s !== null - ? fordSection(designFlowM3s, Number.parseFloat(fordWidth.value)) - : null; - fordMinDepthM = section ? section.depthM : null; - if (section) { - // 표시 정밀도(0.01m)로 맞춘 최소값 — 비었거나, 그보다 작거나, 아직 직전 - // 자동값 그대로면 계산값으로 다시 채운다. - const min = Number(section.depthM.toFixed(2)); - const current = Number.parseFloat(fordHeight.value); - const untouched = fordAutoDepthM !== null && Math.abs(current - fordAutoDepthM) < 0.005; - if (!Number.isFinite(current) || current < min || untouched) - fordHeight.value = min.toFixed(2); - fordAutoDepthM = min; - } - if (designFlowM3s === null) { - fordSummary.textContent = - "담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다."; - return; - } - const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`; - if (!section) { - fordSummary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`; - return; - } - // 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다. - fordSummary.textContent = - `${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` + - `필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` + - `월류 높이는 필요 수심 이상만 입력됩니다.`; - } - - // 계산값 미만은 입력을 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다. - fordHeight.addEventListener("change", () => { - if (fordMinDepthM !== null) { - const min = Number(fordMinDepthM.toFixed(2)); - const value = Number.parseFloat(fordHeight.value); - if (!Number.isFinite(value) || value < min) { - fordHeight.value = min.toFixed(2); - fordHeight.classList.add("is-invalid"); - window.setTimeout(() => fordHeight.classList.remove("is-invalid"), 900); - } - } + // [제안값 넣기] — 누른 때만 빈 칸에 제안값(관종·관경·구조·집수정 길이·설치 측·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(); }); // 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) — // 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다. root.append( + grid(suggest), pipeRow, wingRow, - fordWidthRow, - fordSlopeRow, - fordRow, - fordSummary, + ford.widthRow, + ford.slopeRow, + ford.countRow, + ford.summary, inletGroup.root, outletGroup.root, extraGroup.root, @@ -473,24 +401,23 @@ export function createFacilityOptionsForm( wingInFields.root.hidden = !hasWing; wingOutFields.root.hidden = !hasWing; const isFord = current === "ford_pavement" || current === "ford_bridge"; - fordRow.hidden = current !== "ford_bridge"; - fordWidthRow.hidden = !isFord; + ford.countRow.hidden = current !== "ford_bridge"; + ford.widthRow.hidden = !isFord; // 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다. - fordSlopeRow.hidden = current !== "ford_pavement"; - fordSummary.hidden = !isFord; + ford.slopeRow.hidden = current !== "ford_pavement"; + ford.summary.hidden = !isFord; const isRevet = current === "revetment"; revetTopRow.hidden = !isRevet; - // 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다. - revetInlet.root.hidden = - !isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.inlet); - revetOutlet.root.hidden = - !isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.outlet); + // 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임). + const side = revetSide.value || "양쪽"; + revetInlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.inlet); + revetOutlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.outlet); revetInlet.legend.textContent = `기슭막이 (${revetSideLabels.inlet})`; revetOutlet.legend.textContent = `기슭막이 (${revetSideLabels.outlet})`; // 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고, // 선택이 풀리면 스스로 사라진다. extraGroup.root.hidden = extraSlot.childElementCount === 0; - if (isFord) syncFordSummary(); + if (isFord) ford.sync(); if (isBox) syncBoxSize(); if (hasWing) { wingInFields.syncVisibility(); @@ -509,12 +436,6 @@ export function createFacilityOptionsForm( } } - /** 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림). */ - function putFordHeight(options: Record): void { - const value = Number.parseFloat(fordHeight.value); - if (Number.isFinite(value) && value > 0) options.ford_height_m = Number(value.toFixed(2)); - } - function emit(): void { syncVisibility(); callbacks.onChange?.(); @@ -536,8 +457,7 @@ export function createFacilityOptionsForm( boxHeight, ...wingInFields.inputs, ...wingOutFields.inputs, - fordCount, - fordWidth, + ...ford.inputs, ].forEach((input) => input.addEventListener("change", emit)); /** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */ @@ -574,35 +494,36 @@ export function createFacilityOptionsForm( }, setFacility(facility, options = {}, designFlow = null) { current = facility; - designFlowM3s = designFlow ?? null; + ford.setDesignFlow(designFlow ?? null); if (facility === null) { syncVisibility(); return; } const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; - const isFord = facility === "ford_bridge"; - pipeMaterial.value = text("pipe_kind") || "파형강관"; - pipeDiameter.value = text("pipe_diameter_mm") || "1000"; + // ⚠ 저장된 값만 칸에 — 없으면 「안 정함」 · 제안은 빈 보기 이름·회색 글씨(2026-09-14 판정 ③). + pipeMaterial.value = text("pipe_kind"); + pipeDiameter.value = text("pipe_diameter_mm"); // 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다. - const savedType = text("inlet_type") || "기슭막이"; + const savedType = text("inlet_type"); const savedStructure = text("inlet_structure"); - inletType.value = ( - INLET_KINDS.find( - (kind) => - kind.type === savedType && (!savedStructure || kind.structure === savedStructure), - ) ?? inletKindOf(savedType) - ).label; - outletType.value = text("outlet_type") || "기슭막이"; + inletType.value = savedType + ? ( + INLET_KINDS.find( + (kind) => + kind.type === savedType && (!savedStructure || kind.structure === savedStructure), + ) ?? inletKindOf(savedType) + ).label + : ""; + outletType.value = text("outlet_type"); wingWall.value = text("wing_wall_type"); basinForm.value = text("inlet_basin_form"); basinMaterial.value = text("inlet_basin_material"); - basinLength.value = text("inlet_basin_length_m") || "2"; + basinLength.value = text("inlet_basin_length_m"); inletRevet.write(options); outletRevet.write(options); // 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다. - // 값이 없으면 첫 프리셋(2.0×2.0)이 기본이다. - const [firstLabel, firstWidth, firstHeight] = BOX_SIZE_PRESETS[0]; + // 값이 없으면 「안 정함」(첫 프리셋 2.0×2.0 은 제안으로만). const savedWidth = Number.parseFloat(text("body_width_m")); const savedHeight = Number.parseFloat(text("body_height_m")); const hasSaved = @@ -616,45 +537,38 @@ export function createFacilityOptionsForm( Math.abs(width - savedWidth) < 0.05 && Math.abs(height - savedHeight) < 0.05, ) : undefined; - boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : firstLabel; - boxWidth.value = String(hasSaved ? savedWidth : firstWidth); - boxHeight.value = String(hasSaved ? savedHeight : firstHeight); + boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : ""; + boxWidth.value = hasSaved ? String(savedWidth) : ""; + boxHeight.value = hasSaved ? String(savedHeight) : ""; wingInFields.write(options); wingOutFields.write(options); - fordCount.value = isFord ? text("pipe_count") : ""; - fordWidth.value = text("ford_width_m"); - fordHeight.value = text("ford_height_m"); - // 새 시설을 올리는 참이다 — 저장된 높이는 사용자 값으로 보고 자동 추적을 끊는다. - fordAutoDepthM = null; - fordSlope.value = text("ford_slope_pct"); - fordThickness.value = text("thickness_cm"); - fordLength.value = text("length_m"); - revetSide.value = text("side") || "양쪽"; + if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options); + revetSide.value = text("side"); const spread = legacyRevetOptions(options); revetInlet.fields.write(spread); revetOutlet.fields.write(spread); - // 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정, config 정의처). - if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) { - fordWidth.value = String( - facility === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M, - ); - } syncVisibility(); }, setDesignFlow(designFlow) { - designFlowM3s = designFlow ?? null; + ford.setDesignFlow(designFlow ?? null); syncVisibility(); }, readOptions() { const options: Record = {}; + // 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈). + const putPipe = (): void => { + if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value); + if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value; + }; if (current === "pipe") { - options.pipe_diameter_mm = Number(pipeDiameter.value); - options.pipe_kind = pipeMaterial.value; + putPipe(); const inletKind = inletKindOf(inletType.value); if (wingWall.value) options.wing_wall_type = wingWall.value; - options.inlet_type = inletKind.type; // B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29). - options.inlet_structure = inletKind.structure; + if (inletType.value) { + options.inlet_type = inletKind.type; + options.inlet_structure = inletKind.structure; + } if (inletKind.type === "집수정") { if (basinForm.value) options.inlet_basin_form = basinForm.value; if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value; @@ -662,7 +576,7 @@ export function createFacilityOptionsForm( } else { inletRevet.read(options); } - options.outlet_type = outletType.value; + if (outletType.value) options.outlet_type = outletType.value; outletRevet.read(options); } else if (current === "box_culvert") { // 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다. @@ -671,22 +585,14 @@ export function createFacilityOptionsForm( wingInFields.read(options); wingOutFields.read(options); } else if (current === "ford_pavement") { - putNumber(options, "ford_width_m", fordWidth.value); - putFordHeight(options); - putNumber(options, "ford_slope_pct", fordSlope.value); - putNumber(options, "thickness_cm", fordThickness.value); - putNumber(options, "length_m", fordLength.value); + ford.read(current, options); } else if (current === "ford_bridge") { - options.pipe_kind = pipeMaterial.value; - options.pipe_diameter_mm = Number(pipeDiameter.value); - const count = Number.parseInt(fordCount.value, 10); - if (Number.isFinite(count) && count > 0) options.pipe_count = count; - putNumber(options, "ford_width_m", fordWidth.value); - putFordHeight(options); + putPipe(); + ford.read(current, options); wingInFields.read(options); wingOutFields.read(options); } else if (current === "revetment") { - options.side = revetSide.value; + if (revetSide.value) options.side = revetSide.value; // 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고 // 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제). // 좌·우가 각자 값을 갖는다 — 저장 채널은 유입/유출 키 그대로다(2026-08-30). diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index 2116f882..db569500 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -450,19 +450,18 @@ interface WingFields { syncVisibility: () => void; write: (options: Record) => void; read: (target: Record) => void; + /** [제안값 넣기] — 빈 칸에만 기본 제원을 넣음. */ + fillSuggested: () => void; } /** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각. - * 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴). */ + * 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴). + * ⚠ 기본 제원(있음·1·2·45, 사용자 확정)은 **제안으로만** — 칸은 비워 둠(2026-09-14 판정 ③). */ export function createWingFields(title: string, keys: WingKeys): WingFields { - const install = optionalSelect("미지정", ["있음", "없음"]); - install.value = WING_DEFAULTS.install; - const height = numberInput("0.1"); - height.value = WING_DEFAULTS.height; - const length = numberInput("0.1"); - length.value = WING_DEFAULTS.length; - const angle = numberInput("1"); - angle.value = WING_DEFAULTS.angle; + const 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}`); // 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자). const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1)); @@ -481,7 +480,6 @@ export function createWingFields(title: string, keys: WingKeys): WingFields { dimsRow.hidden = off; } - // 만들자마자 기본값("있음") 상태를 맞춘다. syncVisibility(); return { @@ -492,18 +490,25 @@ export function createWingFields(title: string, keys: WingKeys): WingFields { write(options) { const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; - install.value = text(keys.install) || WING_DEFAULTS.install; - height.value = text(keys.height) || WING_DEFAULTS.height; - length.value = text(keys.length) || WING_DEFAULTS.length; - angle.value = text(keys.angle) || WING_DEFAULTS.angle; + install.value = text(keys.install); + height.value = text(keys.height); + length.value = text(keys.length); + angle.value = text(keys.angle); syncVisibility(); }, read(target) { - target[keys.install] = install.value; - if (install.value !== "있음") return; + if (install.value) target[keys.install] = install.value; + if (install.value === "없음") return; putNumber(target, keys.height, height.value); putNumber(target, keys.length, length.value); putNumber(target, keys.angle, angle.value); }, + 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(); + }, }; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts new file mode 100644 index 00000000..641479b5 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts @@ -0,0 +1,151 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Facility_Ford.ts + * 물넘이포장·세월교 칸 묶음 — 월류 폭·높이·바닥 경사·포장 두께·길이·련 수 + 개략 단면 줄. + * + * 서브폼 본체(`_Drainage_Facility.ts`)가 700줄에 닿아 떼어 냄(2026-09-14). + * ⚠ 기본값을 칸에 안 채움(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」): + * 월류 폭 세월교 10m·물넘이 5m(사용자 확정)는 **회색 제안**, 월류 높이는 설계유량·폭으로 되짚은 + * **필요 최소 수심을 회색 제안**으로만 — 사용자가 적거나 [제안값 넣기]를 누른 때만 값. + * 적은 높이가 필요 수심보다 낮으면 여전히 되돌림(계산값 미만은 안 받음). + * ========================================================================== */ + +import { + FORD_BRIDGE_DEFAULT_WIDTH_M, + FORD_PAVEMENT_DEFAULT_WIDTH_M, +} from "@config/config_frontend"; +import { + fordSection, + grid, + labeled, + numberInput, + putNumber, + stepper, +} from "./B05_Profile_UI_Drainage_Facility_Fields"; + +type FordKind = "ford_pavement" | "ford_bridge"; + +export interface FordFields { + widthRow: HTMLElement; + slopeRow: HTMLElement; + countRow: HTMLElement; + summary: HTMLElement; + /** 바뀌면 저장 흐름을 울릴 칸(높이는 자기 검사 뒤 스스로 울림). */ + inputs: HTMLInputElement[]; + setDesignFlow: (designFlow: number | null) => void; + sync: () => void; + write: (kind: FordKind, options: Record) => void; + read: (kind: FordKind, target: Record) => void; + fillSuggested: (kind: FordKind) => void; +} + +export function createFordFields(emit: () => void): FordFields { + // 세월교 — 구체 내 배관 수량(련). 련은 정수라 소수 자릿수를 두지 않는다. + const count = numberInput("1", "1", "련"); + const countRow = grid(labeled("수량 (련)", stepper(count, 1, 0))); + const width = numberInput("0.1"); + const height = numberInput("0.01"); + const widthRow = grid( + labeled("월류 폭 (m)", stepper(width, 0.1)), + labeled("월류 높이 (m)", stepper(height, 0.1)), + ); + // 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정). + // 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다. + const slope = numberInput("0.1"); + slope.placeholder = "노면 기울기"; + // 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(A3). + const thickness = numberInput("1"); + const length = numberInput("0.1"); + const slopeRow = grid( + labeled("바닥 경사 유입→유출 (%)", stepper(slope, 0.1)), + labeled("포장 두께 (㎝)", stepper(thickness, 1, 0)), + labeled("포장 길이 노폭 방향 (m)", stepper(length, 0.1)), + ); + const summary = document.createElement("p"); + summary.className = "b05-drainage__facility-note"; + let designFlowM3s: number | null = null; + /** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */ + let minDepthM: number | null = null; + + const suggestedWidth = (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 section = designFlowM3s !== null ? fordSection(designFlowM3s, widthM) : null; + minDepthM = section ? Number(section.depthM.toFixed(2)) : null; + height.placeholder = minDepthM !== null ? `제안 필요 수심 ${minDepthM.toFixed(2)}` : ""; + if (designFlowM3s === null) { + summary.textContent = + "담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다."; + return; + } + const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`; + if (!section) { + summary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`; + return; + } + // 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다. + summary.textContent = + `${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` + + `필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` + + `월류 높이는 필요 수심 이상만 입력됩니다.`; + } + + // 적은 값이 계산값 미만이면 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다(빈 칸은 그대로 빈 칸). + height.addEventListener("change", () => { + const value = Number.parseFloat(height.value); + if ( + minDepthM !== null && + height.value !== "" && + (!Number.isFinite(value) || value < minDepthM) + ) { + height.value = minDepthM.toFixed(2); + height.classList.add("is-invalid"); + window.setTimeout(() => height.classList.remove("is-invalid"), 900); + } + emit(); + }); + + return { + widthRow, + slopeRow, + countRow, + summary, + inputs: [count, width], + setDesignFlow(designFlow) { + designFlowM3s = designFlow ?? null; + }, + sync, + write(kind, options) { + const text = (key: string): string => + 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)}`; + height.value = text("ford_height_m"); + slope.value = text("ford_slope_pct"); + thickness.value = text("thickness_cm"); + length.value = text("length_m"); + }, + read(kind, target) { + putNumber(target, "ford_width_m", width.value); + // 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림). + const value = Number.parseFloat(height.value); + if (Number.isFinite(value) && value > 0) target.ford_height_m = Number(value.toFixed(2)); + if (kind === "ford_pavement") { + putNumber(target, "ford_slope_pct", slope.value); + putNumber(target, "thickness_cm", thickness.value); + putNumber(target, "length_m", length.value); + return; + } + 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); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 4950c574..5e695ecf 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -32,6 +32,64 @@ export function select(options: ReadonlyArray<[string, string]>): HTMLSelectElem return element; } +/** 등록부 옵션 한 칸의 모양 — `StructureOptionField` 에서 이 칸이 쓰는 몫만. */ +interface OptionShape { + input: "select" | "number" | "text"; + choices: string[]; + default: string | number | null; + required?: boolean; +} + +/** + * 등록부 옵션 입력 한 칸 — **저장된 값만 칸에 넣고, 등록부 기본값은 제안으로만** 보임 + * (숫자·글 칸은 회색 글씨, 고르기 칸은 빈 보기 이름). 2026-09-14 브레인 판정 「기본값을 몰래 + * 확정으로 바꾸지 않는다」 — 칸에 채워 두면 저장이 그 값을 적어 사용자가 한 일 없이 확정이 됨. + * 고르기 칸은 늘 빈 보기를 둠 — 첫 보기를 미리 고르지 않음. + */ +export function optionControl( + option: OptionShape, + stored: string | number | undefined, +): HTMLInputElement | HTMLSelectElement { + const value = stored === undefined || stored === null ? "" : String(stored); + const suggested = option.default === null || option.default === "" ? "" : String(option.default); + if (option.input === "select") { + const blank = suggested + ? `— 안 정함 (제안 ${suggested}) —` + : option.required + ? "— 선택 —" + : "— 안 정함 —"; + const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]); + element.value = value; + return element; + } + const element = + 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 ? "필수 입력" : ""; + 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; +} + /** 측점번호 + 잔여거리 두 칸 묶음. */ export interface StationFields { wrap: HTMLElement; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 023e352b..7d10dd28 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -14,7 +14,6 @@ * ========================================================================== */ import { - defaultOptions, isB05Option, structureAnchorM, type StructureInstance, @@ -24,7 +23,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, numberInput, select } from "./B05_Profile_UI_Structures_Fields"; +import { field, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields"; import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form"; import { bindStructuresEvents, @@ -316,32 +315,18 @@ export function createStructuresSection( return; } optionRow.hidden = false; + const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> = + []; visible.forEach((option) => { - const preset = values[option.key] ?? option.default ?? ""; - let input: HTMLInputElement | HTMLSelectElement; - if (option.input === "select") { - const choices = option.choices.map((choice) => [choice, choice] as [string, string]); - // ⭐ 기본값 없는 고르기 칸은 **「안 정함」이 실제 상태** — 첫 보기를 미리 고르지 않음 - // (2026-09-14 브레인 판정, 옛 2026-08-17 「빈 보기 두지 않음」을 걷음). 사용자가 고른 적 - // 없는 첫 값이 저장돼 확정으로 굳는 뿌리였음(2026-09-13 `fill_concrete_mpa` 180 박힘도 - // 같은 병). 저장에는 빈 값이 가고 표가 사유를 보임. - if ((option.default ?? "") === "") { - choices.unshift(["", option.required === true ? "— 선택 —" : "— 안 정함 —"]); - } - input = select(choices); - input.value = String(preset); - } else if (option.input === "number") { - input = numberInput("0.1", "0"); - input.value = String(preset ?? ""); - if (option.required) (input as HTMLInputElement).placeholder = "필수 입력"; - } else { - input = document.createElement("input"); - (input as HTMLInputElement).type = "text"; - input.value = String(preset ?? ""); - } + // ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은 + // 첫 보기를 미리 안 고름(2026-09-14 브레인 판정 ①②, `optionControl` 주석). + const input = optionControl(option, values[option.key]); + suggestions.push({ input, suggested: String(option.default ?? "") }); const label = option.unit ? `${option.label} (${option.unit})` : option.label; - // `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다. + // `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다 + // (고를 수 없는 칸이라 제안이 아니라 프로그램 값). const locked = option.enabled === false; + if (locked && !input.value) input.value = String(option.default ?? ""); if (locked) { input.disabled = true; input.classList.add("is-locked"); @@ -386,6 +371,9 @@ export function createStructuresSection( input.addEventListener("change", () => refresh(true)); } }); + if (suggestions.some((entry) => entry.suggested)) { + optionRow.append(suggestButton(suggestions)); + } syncOptionLock(); syncRangeDisplay(); } @@ -673,10 +661,11 @@ export function createStructuresSection( endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step); memoField.value = ""; syncPlacementFields(); - renderOptionFields(type.managed_by ? undefined : defaultOptions(type)); - // BOX암거는 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 실어 폼을 - // 연다 — [추가]만 눌러도 하류가 제원을 받는다(2026-08-17 사용자 확정 유지). - syncFacilityForm(typeId === "box_culvert" ? defaultOptions(type) : {}); + // 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②). + renderOptionFields(); + // BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음). + // 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함. + syncFacilityForm({}); // 임시 배치도 즉시 진행(A군). if (type.managed_by) commitTempPipe(); syncButtons(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index f9fd343e..7ef4bc87 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -231,6 +231,13 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis ctx.optionInputs.find((entry) => entry.key === "length_m")?.input.focus(); return; } + // 높이도 비면 안 놓음 — 칸이 비면 횡단도 벽이 조용히 안 서고 수량도 안 섬. 적거나 + // [제안값 넣기]를 누를 것(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 안 바꿈」). + const height = ctx.optionInputs.find((entry) => entry.key === "height_m"); + if (height?.isEmpty()) { + height.input.focus(); + return; + } const before = ctx.readBeforeM(); if (anchor - before < -0.005) { const beforeInput = ctx.optionInputs.find((entry) => entry.key === "before_m")?.input; diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 621d7c6f..b4f5f165 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -166,6 +166,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (!owner || !culvert) return null; const spec = role === "outlet" ? culvert.outlet : culvert.inlet; const adjust = stationControls.revetOffset.adjustFor(owner, role); + // 조작(조정창 높이)이 있을 때만 — 없으면 그려진 높이는 관경 기준 제안이라 옵션에 안 적음 + // (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」). + if (adjust?.h == null) return null; return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m) .pureHeight; }, @@ -180,6 +183,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (!owner || !culvert) return null; const spec = role === "outlet" ? culvert.outlet : culvert.inlet; const adjust = stationControls.revetOffset.adjustFor(owner, role); + if (!adjust?.m) return null; // 조작한 형태만 — 기본 형태는 제안(판정 ④) return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form; }, // 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다. diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 4f53a3de..af64050f 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -31,6 +31,7 @@ import { writeStructurePick, } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { showToast } from "@ui/ui_template_elements"; +import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend"; import { adjustDockRoot, refreshAdjustSlots, @@ -125,105 +126,153 @@ function coversChainage(structure: StructureInstance, chainageM: number): boolea return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M; } +type Merged = Record; + +/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */ +function registryDefaults(types: StructureType[], typeId: string): Record { + const options = types.find((type) => type.type_id === typeId)?.options ?? []; + return Object.fromEntries(options.map((option) => [option.key, option.default])); +} + /** - * 세월교 폼 옵션을 **지금 그려진 스펙**(`section.ford`)으로 덮는다 — 조정창 조작이 - * 이 스펙을 제자리에서 고치므로, 폼이 저장된 옵션을 붙들면 두 창이 갈린다 - * (2026-08-30 사용자: 수량(련) 칸이 비어 있고 조정창은 1련이었다). + * 스펙 값 한 칸을 옵션에 싣는 규칙 — **저장돼 있던 칸이거나, 조작해 기본값과 달라진 칸만.** + * 기본값에서 온 값을 실으면 페이지를 열기만 해도 임시 저장·[저장]에 기본값이 확정으로 굳음 + * (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 바꾸지 않는다」). 폼은 빈 칸에 제안을 보임. + */ +function keepSpec( + merged: Merged, + stored: Merged | undefined, + key: string, + spec: string | number | null | undefined, + fromDefault: unknown, +): void { + if (spec === null || spec === undefined) return; + if (stored?.[key] !== undefined || String(spec) !== String(fromDefault ?? "")) merged[key] = spec; +} + +type WingSpec = { + installed: boolean; + height_m: number | null; + length_m: number | null; + angle_deg: number | null; +}; + +function keepWings( + merged: Merged, + stored: Merged | undefined, + defaults: Record, + wings: ReadonlyArray, +): void { + for (const [wing, prefix] of wings) { + // 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45. + const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음"; + keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install); + keepSpec( + merged, + stored, + `${prefix}_height_m`, + wing.height_m, + defaults[`${prefix}_height_m`] ?? 0, + ); + keepSpec( + merged, + stored, + `${prefix}_length_m`, + wing.length_m, + defaults[`${prefix}_length_m`] ?? 0, + ); + keepSpec( + merged, + stored, + `${prefix}_angle_deg`, + wing.angle_deg, + defaults[`${prefix}_angle_deg`] ?? 45, + ); + } +} + +/** + * 세월교 폼 옵션 — **지금 그려진 스펙**(`section.ford`) 중 조작·저장분만 싣는다. 조정창 조작이 + * 스펙을 제자리에서 고치므로 그 값은 폼에 보여야 함(2026-08-30 사용자: 수량(련) 칸이 비고 조정창은 + * 1련) — 다만 1련이 **기본값에서 온 값이면** 제안으로만(판정 ④). */ function withFordSpec( - options: Record | undefined, + options: Merged | undefined, ford: NonNullable, -): Record { - const merged: Record = { ...(options ?? {}) }; - if (ford.pipe_kind) merged.pipe_kind = ford.pipe_kind; - merged.pipe_diameter_mm = Math.round(ford.diameter_m * 1000); - merged.pipe_count = ford.pipe_count; - merged.ford_width_m = ford.span_m; - for (const [wing, prefix] of [ + defaults: Record, +): Merged { + const merged: Merged = { ...(options ?? {}) }; + keepSpec(merged, options, "pipe_kind", ford.pipe_kind, defaults.pipe_kind); + const diameter = Math.round(ford.diameter_m * 1000); + keepSpec(merged, options, "pipe_diameter_mm", diameter, defaults.pipe_diameter_mm ?? 1000); + const count = Number(defaults.pipe_count ?? 0); + keepSpec( + merged, + options, + "pipe_count", + ford.pipe_count, + count > 0 ? Math.max(Math.trunc(count), 1) : 1, + ); + keepSpec( + merged, + options, + "ford_width_m", + ford.span_m, + defaults.ford_width_m ?? FORD_BRIDGE_DEFAULT_WIDTH_M, + ); + keepWings(merged, options, defaults, [ [ford.wing_in, "wing_in"], [ford.wing_out, "wing_out"], - ] as const) { - merged[prefix] = wing.installed ? "있음" : "없음"; - if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m; - if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m; - if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg; - } + ]); return merged; } -/** - * BOX암거 폼 옵션을 **지금 그려진 스펙**(`section.box`)으로 덮는다 — 세월교와 같은 - * 규칙이다(2026-08-30 사용자). 본체 규격은 내공 폭·높이가 정본이고, 구체 길이·표고는 - * 조정 채널이라 여기 오지 않는다. - */ +/** BOX암거 폼 옵션 — 세월교와 같은 규칙(조작·저장분만). 구체 길이·표고는 조정 채널이라 여기 안 옴. */ function withBoxSpec( - options: Record | undefined, + options: Merged | undefined, box: NonNullable, -): Record { - const merged: Record = { ...(options ?? {}) }; - merged.body_width_m = box.inner_width_m; - merged.body_height_m = box.inner_height_m; - for (const [wing, prefix] of [ + defaults: Record, +): Merged { + const merged: Merged = { ...(options ?? {}) }; + keepSpec(merged, options, "body_width_m", box.inner_width_m, defaults.body_width_m ?? 2); + keepSpec(merged, options, "body_height_m", box.inner_height_m, defaults.body_height_m ?? 2); + keepWings(merged, options, defaults, [ [box.wing_in, "wing_in"], [box.wing_out, "wing_out"], - ] as const) { - merged[prefix] = wing.installed ? "있음" : "없음"; - if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m; - if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m; - if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg; - } + ]); return merged; } /** - * 관 지점 옵션에 **횡단 스펙 값**을 채워 넣는다 — 조정창이 보여주던 값과 폼이 - * 어긋나지 않게 한다(2026-08-29 사용자). 옵션에 이미 값이 있으면 그대로 두고, - * 비어 있을 때만 스펙(서버 계산·저장분)에서 가져온다. + * 관 지점 옵션에 **조정창에서 조작한 값**만 싣는다 — 조정창과 폼이 어긋나지 않게(2026-08-29 사용자). + * ⚠ 스펙의 기본값(관종·관경·구조·벽 길이·전/후·집수정 길이)은 **안 실음** — 페이지를 열기만 해도 + * 임시 저장에 기본값이 확정으로 굳던 자리(2026-09-14 브레인 판정 ④). 폼 빈 칸은 제안을 보임. */ function withSpecDefaults( - options: Record | undefined, + options: Merged | undefined, detail: SectionDetailResponse | null, chainageM: number, + types: StructureType[], wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null, wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null, -): Record | undefined { +): Merged | undefined { const owner = detail?.cross_sections.find( (section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M, ); - // 세월교는 폼과 조정창이 **같은 캐시**(`section.ford`)를 본다 — 어느 쪽에서 만졌든 - // 그 스펙이 지금 그려진 값이라 저장된 옵션보다 앞선다(2026-08-30 사용자: 프론트는 - // 구조물 배치 상세 UI, 로직·값은 조정창 것). - if (owner?.ford) return withFordSpec(options, owner.ford); - if (owner?.box) return withBoxSpec(options, owner.box); + // 세월교·BOX는 폼과 조정창이 **같은 캐시**를 봄 — 조작한 값은 저장된 옵션보다 앞섬(2026-08-30). + if (owner?.ford) return withFordSpec(options, owner.ford, registryDefaults(types, "ford_bridge")); + if (owner?.box) return withBoxSpec(options, owner.box, registryDefaults(types, "box_culvert")); const culvert = owner?.culvert; if (!culvert) return options; - const merged: Record = { ...(options ?? {}) }; - const put = (key: string, value: string | number | null | undefined): void => { - if (merged[key] === undefined && value !== null && value !== undefined) merged[key] = value; - }; - put("pipe_kind", culvert.pipe_kind); - put("pipe_diameter_mm", Math.round(culvert.diameter_m * 1000)); - for (const [side, prefix] of [ - [culvert.inlet, "inlet"], - [culvert.outlet, "outlet"], - ] as const) { - put(`${prefix}_type`, side.structure); - // 형태·높이는 **지금 그려진 값**이 이긴다 — 조정값(조작)이 저장된 옵션보다 - // 앞서기 때문이다. 저장값을 그대로 두면 폼과 그림이 갈린다(2026-08-30 사용자: - // 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다). + const merged: Merged = { ...(options ?? {}) }; + for (const prefix of ["inlet", "outlet"] as const) { + // 형태·높이는 **조작한 값**이 이긴다 — 조정값이 저장된 옵션보다 앞서기 때문(2026-08-30 사용자: + // 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다). 조작이 없으면 부르는 쪽이 null 을 줌. const drawnForm = wallForm?.(chainageM, prefix); if (drawnForm) merged[`${prefix}_revet_form`] = drawnForm; - else put(`${prefix}_revet_form`, side.revet_form); const drawnHeight = wallHeight?.(chainageM, prefix); if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1)); - else put(`${prefix}_revet_height_m`, side.revet_height_m); - put(`${prefix}_revet_length_m`, side.revet_length_m); - put(`${prefix}_revet_before_m`, side.revet_before_m); - put(`${prefix}_revet_after_m`, side.revet_after_m); } - put("inlet_basin_length_m", culvert.inlet.basin_length_m); - put("inlet_basin_before_m", culvert.inlet.basin_before_m); - put("inlet_basin_after_m", culvert.inlet.basin_after_m); return merged; } @@ -374,6 +423,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc pipe.options, detail, pipe.chainage_m, + types, deps.wallHeight, deps.wallForm, ), @@ -418,6 +468,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc hit.options, deps.detail?.() ?? null, hit.chainage_m, + markTypes, deps.wallHeight, deps.wallForm, ); diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py index 8a981f6d..725b961a 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py @@ -23,6 +23,8 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( back_length_default_note, face_slope_ratio, fill_concrete_mpa, + STONE_SUPPLY_DEFAULT_NOTE, + STONE_SUPPLY_KEYS, is_collected_stone, load_stone_kind_table, stone_coefficients, @@ -487,6 +489,9 @@ def stone_masonry( # 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.** # ⚠ 밑수가 확정 5차로 바뀌었다 — 면석 몸통은 이제 **석적**(정면적×뒷길이×0.77)이고 # 막자갈은 **뒷채움 사다리꼴**이다. 옛 「입적 − 몸통 − 고임돌」 몫이 아니다. + if all(options.get(key) in (None, "") for key in STONE_SUPPLY_KEYS): + # 폼이 기본값을 값으로 안 보내게 된 뒤(2026-09-14) 빈 조달이 조용히 「채집」이 되지 않게. + notes.append(STONE_SUPPLY_DEFAULT_NOTE) if is_collected_stone(options): wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 collected = max(stone_pile + wedge + rubble, 0.0) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py index 05ecf4db..8e19a929 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py @@ -279,6 +279,8 @@ STONE_MASONRY = { #: ⚠ 구조물마다 바꿀 수 있다 — 저장 제원에 「구입」이라 적힌 구조물만 공제에서 빠진다. STONE_SUPPLY_KEYS = ("stone_supply", "stone_source") STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchased", "buy"} +#: 조달을 안 정한 구조물의 사유 — 셈은 확정 ② 「기본은 캔다」대로 채집이되 **안 정한 사실**을 보임. +STONE_SUPPLY_DEFAULT_NOTE = "돌 조달(채집·구입)을 안 정해 「채집」으로 셈 — 사용자 확정 ② 기본 · 구조물 상세에서 고르면 바뀜" #: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (2026-09-09 세 창 확정 · 랩탑 메인의 통로에도 #: 같은 문장이 박혀 있다 — 두 곳이 같은 말이라야 나중에 누가 봐도 안 갈린다). diff --git a/resources/tester/test_b05_empty_means_select.py b/resources/tester/test_b05_empty_means_select.py index 54a0a7e6..99e781a7 100644 --- a/resources/tester/test_b05_empty_means_select.py +++ b/resources/tester/test_b05_empty_means_select.py @@ -24,6 +24,9 @@ if str(ROOT) not in sys.path: from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 PANEL = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text(encoding="utf-8") +#: 2026-09-14 칸 만들기가 `_Structures_Fields.optionControl` 로 옮겨감(브레인 판정 ①② — 고르기 칸은 +#: 늘 빈 보기 · 기본값은 제안으로만). +FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8") API = (ROOT / "B05_Profile" / "B05_Profile_Api_Structures.ts").read_text(encoding="utf-8") COMMIT = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel_Commit.ts").read_text( encoding="utf-8" @@ -39,22 +42,24 @@ def _registry_options(type_id: str) -> dict[str, dict]: def test_폼이_empty_means_를_보고_빈_보기를_둔다() -> None: - """소스 검사 — 빈 보기와 초기값이 **같은 판정**(`keepEmpty`)을 쓴다.""" - assert "option.empty_means" in PANEL - assert "const keepEmpty = mustPick || meansEmpty;" in PANEL - assert 'if (keepEmpty) choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"]);' in PANEL - # 빈 보기만 넣고 초기값을 첫 보기로 두면 화면은 「안 정함」인데 값은 첫 보기가 된다. - assert 'String(preset || (keepEmpty ? "" : (option.choices[0] ?? "")))' in PANEL + """소스 검사 — 고르기 칸은 **늘** 빈 보기를 두고, 초기값은 저장된 값뿐(첫 보기를 안 박음).""" + assert "optionControl(option, values[option.key])" in PANEL + assert 'select([["", blank], ...option.choices' in FIELDS + # 초기값은 저장된 값만 — 첫 보기·기본값으로 채우지 않음. + assert "element.value = value;" in FIELDS + assert "option.choices[0]" not in PANEL # 클라이언트 옵션 타입에 칸이 있어야 서버 응답의 `empty_means` 가 살아 온다. assert "empty_means?: string | null;" in API # 빈 칸은 아예 안 실린다 — 이 규칙이 있어야 「안 정함」이 정본에 안 박힌다. assert "if (input.isEmpty()) return;" in COMMIT -def test_기본값_있는_칸은_빈_보기를_안_둔다() -> None: - """`empty_means` 가 적혀 있어도 **기본값이 있으면** 그 값이 뜬다(2026-08-17 지시 유지).""" +def test_기본값_있는_칸도_빈_보기에_제안으로만_보인다() -> None: + """⭐ 2026-09-14 브레인 판정으로 뒤집힘 — 기본값이 **있어도** 칸에 채우지 않고 빈 보기 이름에 + 「제안 …」으로만 보임(옛 2026-08-17 「첫 보기가 곧 기본값」 걷음). 저장 한 번에 기본값이 + 확정으로 굳던 뿌리였음.""" options = _registry_options("masonry_wet") - # 버림 콘크리트 — empty_means 가 있으나 기본이 「넣음」이다. + # 버림 콘크리트 — empty_means 가 있고 기본이 「넣음」. assert options["blinding_concrete"]["empty_means"] assert options["blinding_concrete"]["default"] == "넣음" # 채움 강도·야면석 계수 — 기본이 없어 빈 보기가 서야 하는 칸. @@ -62,7 +67,7 @@ def test_기본값_있는_칸은_빈_보기를_안_둔다() -> None: assert options[key]["empty_means"], key assert options[key]["default"] is None, key assert options[key]["required"] is False, key - assert '(option.default ?? "") === ""' in PANEL # 기본값 있으면 빈 보기를 안 둠 + assert "`— 안 정함 (제안 ${suggested}) —`" in FIELDS def _wall(**options) -> dict: diff --git a/resources/tester/test_b06_detail_options.py b/resources/tester/test_b06_detail_options.py index 29fb04a1..9de4c45f 100644 --- a/resources/tester/test_b06_detail_options.py +++ b/resources/tester/test_b06_detail_options.py @@ -39,10 +39,13 @@ def test_B05_는_종전대로_유무_종류_위치만(): def test_기본값_없는_필수_항목은_빈_칸으로_연다(): """첫 항목을 슬쩍 고르면 **근거 없는 값**(뒷길이 25㎝ 같은)이 수량·단가로 흘러간다.""" panel = _PANEL.read_text(encoding="utf-8") - # 2026-09-13 — 빈 보기를 두는 까닭이 둘이 되어(필수 + `empty_means`) 한 줄로 합쳐졌다. + fields = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text( + encoding="utf-8" + ) + # 2026-09-14 — 칸 만들기가 `optionControl` 로 옮겨감 · 고르기 칸은 **늘** 빈 보기(브레인 판정 ①). # 필수 칸의 글자는 그대로 「— 선택 —」이다. - assert 'choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"])' in panel - assert 'option.required === true && (option.default ?? "") === ""' in panel + assert "optionControl(option, values[option.key])" in panel + assert '? "— 선택 —"' in fields and 'select([["", blank]' in fields def test_큰돌쌓기에_메_찰_구분이_있다():