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_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 21b86c87..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, @@ -664,9 +663,9 @@ export function createStructuresSection( syncPlacementFields(); // 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②). renderOptionFields(); - // BOX암거는 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 실어 폼을 - // 연다 — [추가]만 눌러도 하류가 제원을 받는다(2026-08-17 사용자 확정 유지). - syncFacilityForm(typeId === "box_culvert" ? defaultOptions(type) : {}); + // BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음). + // 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함. + syncFacilityForm({}); // 임시 배치도 즉시 진행(A군). if (type.managed_by) commitTempPipe(); syncButtons();