From 1165c657e781cc86f6107fc3976c39e87a83cd60 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 19:05:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05,B06):=20=EB=8F=85=EB=A6=BD=20=EA=B8=B0?= =?UTF-8?q?=EC=8A=AD=EB=A7=89=EC=9D=B4=EB=A5=BC=20=EC=A2=8C=C2=B7=EC=9A=B0?= =?UTF-8?q?=20=EC=B9=B8=EC=9C=BC=EB=A1=9C=20=EB=82=98=EB=88=84=EA=B3=A0=20?= =?UTF-8?q?=EB=8B=A8=20=EC=88=98=EB=A5=BC=20=EB=8F=84=EB=A9=B4=EC=97=90=20?= =?UTF-8?q?=EC=9E=87=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설치 측·단 수를 컨테이너 밖 한 행으로 빼고, 벽 제원은 [기슭막이 (좌)]· [기슭막이 (우)] 두 칸으로 나눈다. 설치 측이 한쪽이면 그 칸만 뜬다. 좌·우 이름표는 그 측점 오르막 방향(uphill_side)이 정한다. - 두 칸은 배관 유출구와 같은 양식이다 — [구조][형태] + 길이·높이·전/후. 구조 선택지는 기슭막이 하나뿐이지만 레이아웃을 맞추려 그대로 둔다. - 값은 유입/유출 키로 좌·우 따로 저장한다(옛 한 벌 저장분은 양쪽으로 펼쳐 읽는다). - 단 수에 [-][값][+] 조정칸을 붙이고, 폼 단 수를 다단 채널(extra·bextra)로 보낸다 — 배수관 측점처럼 횡단도에 단이 선다. Co-Authored-By: Claude Opus 5 (1M context) --- .../B05_Profile_UI_Drainage_Facility.ts | 100 +++++++++++------- ...B05_Profile_UI_Drainage_Facility_Fields.ts | 17 +++ B06_Section/B06_Section_UI_Page.ts | 16 +++ .../B06_Section_UI_Page_Structures_Panel.ts | 15 +++ 4 files changed, 108 insertions(+), 40 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 9425eb2b..b7677dae 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -40,6 +40,7 @@ import { BOX_SIZE_CUSTOM, BOX_SIZE_PRESETS, createRevetmentFields, + createRevetSideGroup, createWingFields, fordSection, grid, @@ -186,6 +187,8 @@ export interface FacilityOptionsForm { /** 유입구 "구조"에 합쳐진 B06 형식 값(2026-08-29 지시 5). B06이 조정창 값과 맞춘다. */ inletStructure: () => InletStructureKind; setInletStructure: (value: InletStructureKind) => void; + /** 독립 기슭막이 좌·우 칸의 이름표를 그 측점 방향으로 맞춘다(B06 전용). */ + setRevetSideLabels: (labels: { inlet: "좌" | "우"; outlet: "좌" | "우" }) => void; /** 구조가 바뀔 때 B06 유입측 형식 제어에 알린다(B05는 등록하지 않는다). */ onInletStructureChange: (handler: (value: InletStructureKind) => void) => void; } @@ -281,42 +284,51 @@ export function createFacilityOptionsForm( extraGroup.root.hidden = true; // ── 독립 기슭막이(2026-08-28 이관: 배관처럼 pipe_points 관리) ───────────── - // 배관 없는 기슭막이 — 설치 측(양쪽/좌/우) + 형태·높이·길이·전/후(배관 유입·유출과 - // 같은 조각) + 다단 단 수. B06 횡단도·3D·조정창이 배관 기슭막이 경로를 그대로 탄다. - const revetGroup = group("기슭막이"); + // 설치 측·단 수는 **컨테이너 밖**(배관의 관종·관경과 같은 층)에 두고, 벽 제원은 + // 좌·우 두 칸으로 나눈다(2026-08-30 사용자 지시 1). 설치 측이 한쪽이면 그 칸만 뜬다. + // 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만 + // 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다. const revetSide = optionalSelect("양쪽", ["양쪽", "좌", "우"]); revetSide.value = "양쪽"; const revetTiers = numberInput("1", "1"); revetTiers.value = "1"; - // 옵션 키는 배관 유출 기슭막이와 **같은 한 벌**을 쓴다 — B06 조정창의 구간값도 이 키로 - // 저장되므로 원천이 하나여야 한다(2026-08-28: 전용 키 `length_m`을 쓰던 동안 조정창 - // 값이 매번 되돌아갔다). 읽을 때만 옛 전용 키를 폴백으로 본다. - const standaloneRevet = createRevetmentFields(OUTLET_REVET_KEYS, { - ...REVET_COMMON_DEFAULTS, - form: "돌쌓기(메)", - }); - /** 옛 전용 키(`form`·`length_m`…)로 저장된 값을 배관 키 자리로 옮겨 읽는다. */ + const revetTopRow = grid( + labeled("설치 측", revetSide), + labeled("단 수(다단)", stepper(revetTiers, 1)), + ); + const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)"); + const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)"); + /** 좌·우 이름표 — B06이 그 측점의 벽 방향을 알려 주면 바뀐다. 기본은 유입=좌. */ + let revetSideLabels: { inlet: "좌" | "우"; outlet: "좌" | "우" } = { + inlet: "좌", + outlet: "우", + }; + /** 옛 전용 키(`form`·`length_m`…)와 한 벌 저장분을 **양쪽 칸**으로 펼쳐 읽는다. */ const legacyRevetOptions = ( options: Record, ): Record => { const merged = { ...options }; - const pairs: Array<[string, string]> = [ - ["outlet_revet_form", "form"], - ["outlet_revet_length_m", "length_m"], - ["outlet_revet_height_m", "height_m"], - ["outlet_revet_before_m", "before_m"], - ["outlet_revet_after_m", "after_m"], + const pairs: Array<[string, string, string]> = [ + ["outlet_revet_form", "inlet_revet_form", "form"], + ["outlet_revet_length_m", "inlet_revet_length_m", "length_m"], + ["outlet_revet_height_m", "inlet_revet_height_m", "height_m"], + ["outlet_revet_before_m", "inlet_revet_before_m", "before_m"], + ["outlet_revet_after_m", "inlet_revet_after_m", "after_m"], ]; - for (const [key, legacy] of pairs) { - if (merged[key] === undefined && options[legacy] !== undefined) merged[key] = options[legacy]; + for (const [outletKey, inletKey, legacy] of pairs) { + if (merged[outletKey] === undefined && options[legacy] !== undefined) { + merged[outletKey] = options[legacy]; + } + // 한쪽만 저장된 옛 자료 — 반대쪽 칸도 같은 값으로 세운다. + if (merged[inletKey] === undefined && merged[outletKey] !== undefined) { + merged[inletKey] = merged[outletKey]; + } + if (merged[outletKey] === undefined && merged[inletKey] !== undefined) { + merged[outletKey] = merged[inletKey]; + } } return merged; }; - revetGroup.body.append( - grid(labeled("설치 측", revetSide), standaloneRevet.formField), - ...standaloneRevet.rows, - grid(labeled("단 수(다단)", revetTiers)), - ); // ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ──── // 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정). @@ -418,7 +430,9 @@ export function createFacilityOptionsForm( inletGroup.root, outletGroup.root, extraGroup.root, - revetGroup.root, + revetTopRow, + revetInlet.root, + revetOutlet.root, boxWrap, wingInFields.root, wingOutFields.root, @@ -450,7 +464,15 @@ export function createFacilityOptionsForm( // 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다. fordSlopeRow.hidden = current !== "ford_pavement"; fordSummary.hidden = !isFord; - revetGroup.root.hidden = current !== "revetment"; + 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); + revetInlet.legend.textContent = `기슭막이 (${revetSideLabels.inlet})`; + revetOutlet.legend.textContent = `기슭막이 (${revetSideLabels.outlet})`; // 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고, // 선택이 풀리면 스스로 사라진다. extraGroup.root.hidden = extraSlot.childElementCount === 0; @@ -489,7 +511,8 @@ export function createFacilityOptionsForm( ); inletRevet.onChange(emit); outletRevet.onChange(emit); - standaloneRevet.onChange(emit); + revetInlet.fields.onChange(emit); + revetOutlet.fields.onChange(emit); [revetSide, revetTiers].forEach((input) => input.addEventListener("change", emit)); [ pipeMaterial, @@ -514,6 +537,10 @@ export function createFacilityOptionsForm( inletSlot, outletSlot, extraSlot, + setRevetSideLabels(labels) { + revetSideLabels = labels; + syncVisibility(); + }, inletStructure: () => inletKindOf(inletType.value).structure, setInletStructure(value) { // 지금 고른 정본 종류(기슭막이/집수정)는 지키고 형식만 맞춘다. @@ -581,7 +608,9 @@ export function createFacilityOptionsForm( fordSlope.value = text("ford_slope_pct"); revetSide.value = text("side") || "양쪽"; revetTiers.value = text("tiers") || "1"; - standaloneRevet.write(legacyRevetOptions(options)); + 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( @@ -635,18 +664,9 @@ export function createFacilityOptionsForm( options.side = revetSide.value; const tiers = Number.parseInt(revetTiers.value, 10); if (Number.isFinite(tiers) && tiers >= 1) options.tiers = tiers; - standaloneRevet.read(options); - // 양쪽 벽은 같은 한 벌이 정본 — 유입 키에도 같은 값을 실어 벽 둘이 갈리지 않게 한다. - for (const [inletKey, outletKey] of [ - [INLET_REVET_KEYS.form, OUTLET_REVET_KEYS.form], - [INLET_REVET_KEYS.length, OUTLET_REVET_KEYS.length], - [INLET_REVET_KEYS.height, OUTLET_REVET_KEYS.height], - [INLET_REVET_KEYS.before, OUTLET_REVET_KEYS.before], - [INLET_REVET_KEYS.after, OUTLET_REVET_KEYS.after], - ] as Array<[string | undefined, string | undefined]>) { - if (inletKey && outletKey && options[outletKey] !== undefined) - options[inletKey] = options[outletKey]; - } + // 좌·우가 각자 값을 갖는다 — 저장 채널은 유입/유출 키 그대로다(2026-08-30). + revetInlet.fields.read(options); + revetOutlet.fields.read(options); } return options; }, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index c330d03c..8ce83e2c 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -205,6 +205,23 @@ export const OUTLET_REVET_KEYS: RevetmentKeys = { after: "outlet_revet_after_m", }; +/** 독립 기슭막이 한쪽 칸 — 배관 유출구와 **같은 양식**([구조][형태] + 길이·높이·전/후). + * 구조 선택지는 기슭막이 하나뿐이지만 레이아웃을 맞추려 그대로 둔다(2026-08-30 사용자). */ +export interface RevetSideGroup { + root: HTMLFieldSetElement; + legend: HTMLLegendElement; + fields: RevetmentFields; +} + +export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup { + const box = group("기슭막이"); + const kind = document.createElement("select"); + kind.replaceChildren(new Option("기슭막이", "기슭막이")); + const fields = createRevetmentFields(keys, { ...REVET_COMMON_DEFAULTS, form: formDefault }); + box.body.append(grid(labeled("구조", kind), fields.formField), ...fields.rows); + return { root: box.root, legend: box.root.querySelector("legend")!, fields }; +} + export interface RevetmentFields { /** 형태 칸 — 유입·유출 그룹에서 "구조" 옆 2열째에 놓는다(2026-08-17 지시 3·4). */ formField: HTMLLabelElement; diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index bce1e037..862398fb 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -164,6 +164,22 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const adjust = stationControls.revetOffset.adjustFor(owner, role); return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form; }, + // 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다. + wallSideLabels: (chainageM) => { + const owner = sectionDetail?.cross_sections.find( + (section) => Math.abs(section.chainage_m - chainageM) < 0.51, + ); + if (!owner) return null; + const inletLeft = (owner.uphill_side ?? "left") === "left"; + return inletLeft ? { inlet: "좌", outlet: "우" } : { inlet: "우", outlet: "좌" }; + }, + // 폼 단 수 → 다단 채널(유출 = extra, 유입·계류측 = bextra). 독립 기슭막이는 좌·우 + // 두 벽이 같은 단 수를 쓰므로 두 채널에 같이 넣는다. + setTierCount: (chainageM, tiers) => { + const extras = Math.max(0, Math.round(tiers) - 1); + stationControls.extraWalls.setCount(chainageM, extras, "outlet"); + stationControls.extraWalls.setCount(chainageM, extras, "basin"); + }, stationInterval: () => stationInterval ?? 20, focusChainage: focusStationAt, queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch), diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 9c904a92..31b90a53 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -45,6 +45,10 @@ export interface B06StructuresPanelDeps { wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null; /** 지금 그려진 벽 형태 — 조정값(m)이 있으면 그쪽이 정본이라 폼도 이 값을 보인다. */ wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null; + /** 그 측점에서 유입·유출 벽이 각각 좌·우 어느 쪽인가(독립 기슭막이 칸 이름표). */ + wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null; + /** 폼의 단 수를 다단 채널에 반영한다 — 조정창에서 세우던 것과 같은 자리. */ + setTierCount?: (chainageM: number, tiers: number) => void; /** 측점 간격(m) — 측점번호+잔여거리 환산용. */ stationInterval: () => number; /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */ @@ -193,6 +197,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc showToast(PIPE_ADD_GUIDE, "error"); return; } + // 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야 + // 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게). + const tiers = Number(patch.tiers); + if (Number.isFinite(tiers)) deps.setTierCount?.(fromChainageM, tiers); // 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다. deps.queuePipeOptions(fromChainageM, patch as Record); deps.applyPipeOptions?.(fromChainageM, patch as Record); @@ -266,12 +274,19 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } } + /** 폼에 올리기 직전에 좌·우 이름표를 그 측점 방향으로 맞춘다(독립 기슭막이 칸). */ + function syncSideLabels(chainageM: number): void { + const labels = deps.wallSideLabels?.(chainageM); + if (labels) section.facility.setRevetSideLabels(labels); + } + /** 폼에 올리기 직전에 그 관의 옵션을 최신 스펙으로 보강한다(빈 칸만 채운다). */ function refreshSpecDefaults(chainageM: number): void { const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M, ); if (!hit) return; + syncSideLabels(hit.chainage_m); hit.options = withSpecDefaults( hit.options, deps.detail?.() ?? null,