From 70bfddce1be99388765c2f06c7eebbff6180d9b1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 17 Aug 2026 17:36:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20BOX=EC=95=94=EA=B1=B0=20=EB=B3=B8?= =?UTF-8?q?=EC=B2=B4=20=EA=B7=9C=EA=B2=A9=20=ED=94=84=EB=A6=AC=EC=85=8B?= =?UTF-8?q?=C2=B7=EB=82=A0=EA=B0=9C=EB=B2=BD=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=A0=9C=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 본체 규격을 실무 관측 규격 프리셋(기본 2.0×2.0·3.0×3.0)으로 받고, 그 밖의 규격만 "사용자 지정"으로 폭·높이를 직접 입력한다. 저장 키는 어느 쪽이든 body_width_m·body_height_m 숫자 그대로 — 프리셋 전용 키를 만들지 않아야 하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다 - 날개벽 유입·유출을 createWingFields 한 조각으로 묶고(기존 기슭막이 createRevetmentFields와 같은 패턴) 설치 있음·짧은쪽 높이 1m·길이 2m· 각도 45°를 프리필. 설치 "없음"이면 제원 칸을 접는다 - setFacility 빈값 폴백을 기본값으로 — 빈 문자열 대입으로 select가 selectedIndex=-1(공백 표시)되던 문제도 함께 해소 - 레지스트리 정본에 위 기본값 10건 등재하고 required·phase:detail 해제. 이 값들은 B05 서브폼이 실제로 받으므로 detail 표기가 정본과 어긋나 있었다 - addAt의 managed_by 분기에 BOX암거 한정 defaultOptions 동봉(방어선 — 현재 우클릭 메뉴는 managed_by 타입을 제외하므로 도달하지 않는다) 기본값 전부 2026-08-17 사용자 확정. 검증: tmp/tests 101 passed·7 skipped, npm run typecheck 무오류, prettier·ruff format 변경 없음. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Structure_Types.json | 46 ++-- .../B05_Profile_UI_Drainage_Facility.ts | 228 +++++++++++++----- .../B05_Profile_UI_Structures_Panel.ts | 7 +- 3 files changed, 192 insertions(+), 89 deletions(-) diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 9ca7f80f..2c2f300d 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 docs/raw/PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재).", + "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 docs/raw/PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다.", "types": [ { "type_id": "pipe", @@ -188,25 +188,23 @@ "label": "본체 폭", "input": "number", "unit": "m", - "default": null, - "required": true, - "phase": "detail" + "default": 2.0, + "required": false }, { "key": "body_height_m", "label": "본체 높이", "input": "number", "unit": "m", - "default": null, - "required": true, - "phase": "detail" + "default": 2.0, + "required": false }, { "key": "wing_in", "label": "날개벽(유입)", "input": "select", "choices": ["있음", "없음"], - "default": null, + "default": "있음", "required": false }, { @@ -214,34 +212,31 @@ "label": "유입 날개벽 짧은쪽 높이", "input": "number", "unit": "m", - "default": null, - "required": true, - "phase": "detail" + "default": 1, + "required": false }, { "key": "wing_in_length_m", "label": "유입 날개벽 길이", "input": "number", "unit": "m", - "default": null, - "required": true, - "phase": "detail" + "default": 2, + "required": false }, { "key": "wing_in_angle_deg", "label": "유입 날개벽 각도", "input": "number", "unit": "°", - "default": null, - "required": true, - "phase": "detail" + "default": 45, + "required": false }, { "key": "wing_out", "label": "날개벽(유출)", "input": "select", "choices": ["있음", "없음"], - "default": null, + "default": "있음", "required": false }, { @@ -249,27 +244,24 @@ "label": "유출 날개벽 짧은쪽 높이", "input": "number", "unit": "m", - "default": null, - "required": true, - "phase": "detail" + "default": 1, + "required": false }, { "key": "wing_out_length_m", "label": "유출 날개벽 길이", "input": "number", "unit": "m", - "default": null, - "required": true, - "phase": "detail" + "default": 2, + "required": false }, { "key": "wing_out_angle_deg", "label": "유출 날개벽 각도", "input": "number", "unit": "°", - "default": null, - "required": true, - "phase": "detail" + "default": 45, + "required": false } ] }, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index c281ec60..ef4e4a1b 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -13,6 +13,12 @@ * 묶어 유입·유출·독립 구조물(D4 기슭막이)이 **같은 인터페이스**를 쓴다. * 유입·유출 길이가 정해지면 위치 칸(시작·종료 측점)을 기준 − 유입 ~ 기준 + 유출로 * 자동 채운다. 형식·치수 제원은 B06/B07 필수 승격 대상이라 보이되 비필수다. + * + * BOX암거(2026-08-17 사용자 확정): 본체는 실무 관측 규격 2.0×2.0·3.0×3.0을 프리셋 + * 셀렉트로 받고(기본 2.0×2.0), 그 밖의 규격만 "사용자 지정"으로 폭·높이를 직접 + * 받는다 — 저장 키는 어느 쪽이든 body_width_m·body_height_m 숫자 그대로다. + * 날개벽은 유입·유출이 같은 조각(createWingFields)을 쓰고 설치 있음·짧은쪽 높이 + * 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다. * ========================================================================== */ import type { @@ -119,6 +125,12 @@ function numberInput(step: string, min = "0", placeholder = ""): HTMLInputElemen return input; } +/** 양수만 옵션에 싣는다 — 빈칸·0·음수는 값이 없는 것으로 본다. */ +function putNumber(target: Record, key: string, raw: string): void { + const value = Number.parseFloat(raw); + if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1)); +} + /** 선택지 셀렉트 — 빈("미지정") 항목을 두지 않는다. 첫 항목이 곧 기본값이고, * 기본값은 구조물별로 따로 지정한다(2026-08-17 사용자 지시 1). */ function optionalSelect(_blankLabel: string, choices: ReadonlyArray): HTMLSelectElement { @@ -310,6 +322,101 @@ export function createRevetmentFields( }; } +/* ── BOX암거 ────────────────────────────────────────────────────────────── */ + +/** 본체 규격 프리셋 — 실무 관측 규격이다(2026-08-17 사용자 확정). 그 밖의 규격은 + * "사용자 지정"으로 폭·높이를 직접 받는다. 저장 키는 프리셋이든 자유 입력이든 + * body_width_m·body_height_m 숫자 그대로다 — 프리셋 전용 키를 만들지 않아야 + * 하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다. */ +const BOX_SIZE_PRESETS: ReadonlyArray = [ + ["2.0×2.0", 2, 2], + ["3.0×3.0", 3, 3], +]; +const BOX_SIZE_CUSTOM = "사용자 지정"; + +/** 날개벽 옵션 키 묶음 — 유입·유출이 같은 조각을 쓰되 키만 다르다. */ +interface WingKeys { + install: string; + height: string; + length: string; + angle: string; +} + +const WING_IN_KEYS: WingKeys = { + install: "wing_in", + height: "wing_in_height_m", + length: "wing_in_length_m", + angle: "wing_in_angle_deg", +}; + +const WING_OUT_KEYS: WingKeys = { + install: "wing_out", + height: "wing_out_height_m", + length: "wing_out_length_m", + 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; + inputs: Array; + syncVisibility: () => void; + write: (options: Record) => void; + read: (target: Record) => void; +} + +/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각. + * 설치 "없음"이면 제원 칸을 접는다(기슭막이 돌붙임과 같은 패턴). */ +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 heightField = labeled("짧은쪽 높이 (m)", height); + const dimsRow = grid(labeled("길이 (m)", length), labeled("각도 (°)", angle)); + const box = group(title); + box.body.append(grid(labeled("설치", install), heightField), dimsRow); + + function syncVisibility(): void { + const off = install.value === "없음"; + heightField.hidden = off; + dimsRow.hidden = off; + } + + // 만들자마자 기본값("있음") 상태를 맞춘다. + syncVisibility(); + + return { + root: box.root, + inputs: [install, height, length, angle], + syncVisibility, + 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; + syncVisibility(); + }, + read(target) { + 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); + }, + }; +} + interface FacilityOptionsFormCallbacks { /** 아무 옵션이나 바뀌었을 때 — 패널이 저장 흐름(수정 확정)을 안내하는 데 쓴다. */ onChange?: () => void; @@ -407,28 +514,30 @@ export function createFacilityOptionsForm( const standaloneWrap = document.createElement("div"); standaloneWrap.append(grid(standaloneRevet.formField), ...standaloneRevet.rows); - // ── BOX암거 — 본체·날개벽(유입·유출 개별) ────────────────────────────── + // ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ──── + // 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정). + const boxSize = optionalSelect("미지정", [ + ...BOX_SIZE_PRESETS.map(([label]) => label), + BOX_SIZE_CUSTOM, + ]); const boxWidth = numberInput("0.1"); const boxHeight = numberInput("0.1"); - const boxRow = grid(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight)); - const wingIn = optionalSelect("미지정", ["있음", "없음"]); - const wingInHeight = numberInput("0.1"); - const wingInLength = numberInput("0.1"); - const wingInAngle = numberInput("1"); - const wingOut = optionalSelect("미지정", ["있음", "없음"]); - const wingOutHeight = numberInput("0.1"); - const wingOutLength = numberInput("0.1"); - const wingOutAngle = numberInput("1"); - const wingInGroup = group("날개벽(유입)"); - wingInGroup.body.append( - grid(labeled("설치", wingIn), labeled("짧은쪽 높이 (m)", wingInHeight)), - grid(labeled("길이 (m)", wingInLength), labeled("각도 (°)", wingInAngle)), - ); - const wingOutGroup = group("날개벽(유출)"); - wingOutGroup.body.append( - grid(labeled("설치", wingOut), labeled("짧은쪽 높이 (m)", wingOutHeight)), - grid(labeled("길이 (m)", wingOutLength), labeled("각도 (°)", wingOutAngle)), - ); + const boxCustomRow = grid(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight)); + const boxWrap = document.createElement("div"); + boxWrap.append(grid(labeled("본체 규격 (m)", boxSize)), boxCustomRow); + const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS); + const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS); + + /** 고른 프리셋을 폭·높이 칸에 실어 둔다 — 저장도, "사용자 지정"으로 바꿨을 때의 + * 출발값도 이 두 칸이 정본이다. 사용자 지정 상태에서는 손대지 않는다. */ + function syncBoxSize(): void { + const preset = BOX_SIZE_PRESETS.find(([label]) => label === boxSize.value); + if (preset) { + boxWidth.value = String(preset[1]); + boxHeight.value = String(preset[2]); + } + boxCustomRow.hidden = !!preset; + } // ── 세월교 — 구체 내 배관은 배수관과 같은 관종·관경 칸을 쓰고(2026-08-17 사용자 // 지시) 수량만 따로 받는다. @@ -440,9 +549,9 @@ export function createFacilityOptionsForm( inletGroup.root, outletGroup.root, standaloneWrap, - boxRow, - wingInGroup.root, - wingOutGroup.root, + boxWrap, + wingInFields.root, + wingOutFields.root, fordRow, ); @@ -459,10 +568,15 @@ export function createFacilityOptionsForm( inletGroup.root.hidden = !isPipe; outletGroup.root.hidden = !isPipe; standaloneWrap.hidden = !isRevetment; - boxRow.hidden = !isBox; - wingInGroup.root.hidden = !isBox; - wingOutGroup.root.hidden = !isBox; + boxWrap.hidden = !isBox; + wingInFields.root.hidden = !isBox; + wingOutFields.root.hidden = !isBox; fordRow.hidden = current !== "ford_bridge"; + if (isBox) { + syncBoxSize(); + wingInFields.syncVisibility(); + wingOutFields.syncVisibility(); + } if (isPipe) { // 유입구 구조에 따라 집수정 칸과 기슭막이 칸을 갈아 끼운다(사용자 지시 6~8). const basin = inletType.value === "집수정"; @@ -489,24 +603,14 @@ export function createFacilityOptionsForm( [ pipeMaterial, pipeDiameter, + boxSize, boxWidth, boxHeight, - wingIn, - wingInHeight, - wingInLength, - wingInAngle, - wingOut, - wingOutHeight, - wingOutLength, - wingOutAngle, + ...wingInFields.inputs, + ...wingOutFields.inputs, fordCount, ].forEach((input) => input.addEventListener("change", emit)); - function putNumber(target: Record, key: string, raw: string): void { - const value = Number.parseFloat(raw); - if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1)); - } - return { root, setFacility(facility, options = {}) { @@ -527,16 +631,27 @@ export function createFacilityOptionsForm( inletRevet.write(options); outletRevet.write(options); standaloneRevet.write(options); - boxWidth.value = text("body_width_m"); - boxHeight.value = text("body_height_m"); - wingIn.value = text("wing_in"); - wingInHeight.value = text("wing_in_height_m"); - wingInLength.value = text("wing_in_length_m"); - wingInAngle.value = text("wing_in_angle_deg"); - wingOut.value = text("wing_out"); - wingOutHeight.value = text("wing_out_height_m"); - wingOutLength.value = text("wing_out_length_m"); - wingOutAngle.value = text("wing_out_angle_deg"); + // 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다. + // 값이 없으면 첫 프리셋(2.0×2.0)이 기본이다. + const [firstLabel, firstWidth, firstHeight] = BOX_SIZE_PRESETS[0]; + const savedWidth = Number.parseFloat(text("body_width_m")); + const savedHeight = Number.parseFloat(text("body_height_m")); + const hasSaved = + Number.isFinite(savedWidth) && + savedWidth > 0 && + Number.isFinite(savedHeight) && + savedHeight > 0; + const preset = hasSaved + ? BOX_SIZE_PRESETS.find( + ([, width, height]) => + 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); + wingInFields.write(options); + wingOutFields.write(options); fordCount.value = isFord ? text("pipe_count") : ""; syncVisibility(); }, @@ -557,20 +672,11 @@ export function createFacilityOptionsForm( } else if (current === "revetment") { standaloneRevet.read(options); } else if (current === "box_culvert") { + // 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다. putNumber(options, "body_width_m", boxWidth.value); putNumber(options, "body_height_m", boxHeight.value); - if (wingIn.value) options.wing_in = wingIn.value; - if (wingIn.value === "있음") { - putNumber(options, "wing_in_height_m", wingInHeight.value); - putNumber(options, "wing_in_length_m", wingInLength.value); - putNumber(options, "wing_in_angle_deg", wingInAngle.value); - } - if (wingOut.value) options.wing_out = wingOut.value; - if (wingOut.value === "있음") { - putNumber(options, "wing_out_height_m", wingOutHeight.value); - putNumber(options, "wing_out_length_m", wingOutLength.value); - putNumber(options, "wing_out_angle_deg", wingOutAngle.value); - } + wingInFields.read(options); + wingOutFields.read(options); } else if (current === "ford_bridge") { options.pipe_kind = pipeMaterial.value; options.pipe_diameter_mm = Number(pipeDiameter.value); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 3a0a3196..1c28f332 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -664,8 +664,13 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const type = typeMap().get(typeId); if (!type) return; // 계곡 통과 시설은 관 지점 정본으로 바로 보낸다(옵션은 추가 후 폼에서). + // BOX암거만 예외로 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 + // 함께 싣는다 — 폼을 열지 않고 넣어도 하류가 제원을 받는다(2026-08-17 사용자 + // 확정). 배관·물넘이·세월교는 부속을 설계자가 고를 몫이라 빈 채로 둔다. if (type.managed_by) { - callbacks.onPipeAdd(chainageM, { facility: typeId as PipeFacility }); + const attributes: FacilityAttributes = { facility: typeId as PipeFacility }; + if (typeId === "box_culvert") attributes.options = defaultOptions(type); + callbacks.onPipeAdd(chainageM, attributes); return; } const placement = placementOf(typeId);