/* ============================================================================= * B06_Section_UI_Cross_Structure_Panel.ts * 횡단도 안에 뜨는 **구조물 조정 오버레이 창**(2026-08-21 사용자 확정). * * 조정 조작구는 카드 하단이 아니라 **해당 횡단도 안에** 있어야 한다 — 어느 도면의 * 구조물을 만지는지 눈으로 붙어 있어야 한다. 기슭막이는 4축(2026-08-22 확정): * 좌우 ◀▶(노견/시작점 수평 연장, 물매 1:1.2 불변), 상하 ▲▼(성토선을 타는 대각), * 높이 ±0.1m(재질 한계), 재질(메2.0/찰3.0/콘크리트5.0). 다단은 단 수 숫자++/-. * * 창은 그래프 영역(`.b06-cross-card__chart-wrap`) 안 **좌측 하단**에 얹힌다. * ========================================================================== */ import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; import { materialLimit, REVET_MATERIALS } from "./B06_Section_UI_Cross_Culvert_Const"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { L } from "./B06_Section_UI_Section_Common"; /** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */ export interface StructurePanelDeps { /** 지금 조작값(좌우 x·상하 d·높이 h·재질 m). 창을 열거나 값이 바뀔 때마다 읽는다. */ adjustFor: (key: RevetKey) => WallAdjust; /** 좌우: 화면 좌(+)/우(−)로 미는 양(m). 부호 환산은 카드가 한다. */ nudge: (key: RevetKey, screenDeltaM: number) => void; /** 상하: 성토선을 타는 대각 이동(수평 성분 m, + = 사면 아래). */ nudgeSlope: (key: RevetKey, deltaM: number) => void; /** 높이 ±0.1m. 재질 한계 절삭·경고는 기하·카드가 한다. */ nudgeHeight: (key: RevetKey, deltaM: number) => void; /** 실제 그려진 벽 높이(m) — 표시·높이 조작의 기준값. */ heightFor: (key: RevetKey) => number; materialFor: (key: RevetKey) => RevetMaterial; setMaterial: (key: RevetKey, material: RevetMaterial) => void; /** 자동 자리로 되돌린다(4축 모두). */ reset: (key: RevetKey) => void; /** 지금 관 길이(m) — 조정 단위가 관 길이 1m이라 창에 같이 적는다. */ pipeLengthM: () => number | null; /** 창을 닫는다 = 구조물 선택 해제. */ close: () => void; /** 유입측 구조물 형식(드롭다운 값 — 2026-08-22 사용자). */ structureFor: () => InletStructureChoice; /** 유입측 구조물 형식 변경 — 카드를 다시 그린다. */ setStructure: (value: InletStructureChoice) => void; /** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */ canNudge: (key: RevetKey) => boolean; /** 상황에 안 맞는 선택지 숨김(2026-08-22 사용자) — 기하가 판정한 가용성. */ optionsFor: () => { revetAllowed: boolean; basinLUAllowed: boolean }; /** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */ extraState: () => { canAdd: boolean; count: number }; /** 다단 기슭막이 단 수 지정 — 지형 허용보다 크면 기하가 자르고 토스트로 알린다. */ setExtraCount: (count: number) => void; /** 다단 등간격 배치(2026-08-22 ①) — 사면 구간이 같아지게 단들을 재배치. */ equalizeExtras: () => void; } export interface StructurePanelHandle { root: HTMLElement; /** null이면 숨긴다. 값이 오면 그 구조물 기준으로 다시 그린다. */ show: (key: RevetKey | null) => void; } /** * 좌우·상하 한 걸음(m) — **관 길이 1m**에 맞춘다(2026-08-21 사용자 확정). * 높이는 0.1m 눈금(2026-08-22 사용자 확정 2~3m·0.5~3m 구간 0.1 단위 제어). */ const STEP_M = 1.0; const HEIGHT_STEP_M = 0.1; function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; button.className = "b06-structure-panel__btn"; button.textContent = label; button.title = title; button.addEventListener("click", (event) => { // 카드 선택·팬으로 번지면 도면이 다시 그려져 방금 맞춘 배율이 날아간다. event.stopPropagation(); onClick(); }); return button; } /** 항목 행 — 1행 이름 라벨 + 2행 값 조작(2026-08-22 사용자 확정 2행 구조). */ function makeRow(labelText: string): { row: HTMLElement; controls: HTMLElement } { const row = document.createElement("div"); row.className = "b06-structure-panel__struct"; const label = document.createElement("span"); label.className = "b06-structure-panel__label"; label.textContent = labelText; const controls = document.createElement("div"); controls.className = "b06-structure-panel__controls"; row.append(label, controls); return { row, controls }; } /** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHandle { const root = document.createElement("div"); root.className = "b06-structure-panel"; root.classList.add("is-hidden"); // 창 안에서의 클릭이 카드 선택으로 번지지 않게 한 번에 막는다. root.addEventListener("click", (event) => event.stopPropagation()); const title = document.createElement("span"); title.className = "b06-structure-panel__title"; // 정보 표시(2026-08-22 사용자 — 항목당 한 행): 관 길이 / 좌우 / 상하. const value = document.createElement("div"); value.className = "b06-structure-panel__value"; const pipeLine = document.createElement("span"); const lateralLine = document.createElement("span"); const slopeLine = document.createElement("span"); value.append(pipeLine, lateralLine, slopeLine); let current: RevetKey | null = null; const act = (run: (key: RevetKey) => void) => () => { if (current) run(current); }; const moveRow = makeRow(L("B06_Cross_Move_Label")); // 버튼 묶음 클래스는 컨트롤 행에 — **십자(D-pad) 배치**(2026-08-22 사용자): // ▲ 위 / ◀ ↺ ▶ 가운데 / ▼ 아래. 초기화가 십자 중앙이다. moveRow.controls.classList.add("b06-structure-panel__buttons"); const buttons = moveRow.controls; const dpad = ( label: string, slot: "up" | "down" | "left" | "right" | "reset" | "equal", title: string, onClick: () => void, ): HTMLButtonElement => { const button = makeButton(label, title, onClick); button.classList.add(`b06-structure-panel__btn--${slot}`); return button; }; buttons.append( dpad( "▲", "up", L("B06_Cross_Revet_Up"), act((key) => deps.nudgeSlope(key, -STEP_M)), ), dpad( "◀", "left", L("B06_Cross_Revet_Left"), act((key) => deps.nudge(key, STEP_M)), ), dpad( "↺", "reset", L("B06_Cross_Revet_Reset"), act((key) => deps.reset(key)), ), dpad( "▶", "right", L("B06_Cross_Revet_Right"), act((key) => deps.nudge(key, -STEP_M)), ), dpad( "▼", "down", L("B06_Cross_Revet_Down"), act((key) => deps.nudgeSlope(key, STEP_M)), ), dpad("≡", "equal", L("B06_Cross_Extra_Equalize"), () => deps.equalizeExtras()), ); // 재질 행(2026-08-22 사용자 ④ — 높이 행 **위**, 폭 축소: 한계는 툴팁으로). const materialParts = makeRow(L("B06_Cross_Mat_Label")); const materialRow = materialParts.row; const materialSelect = document.createElement("select"); materialSelect.className = "b06-structure-panel__select"; const MATERIAL_LABEL: Record = { dry: L("B06_Cross_Mat_Dry"), wet: L("B06_Cross_Mat_Wet"), concrete: L("B06_Cross_Mat_Concrete"), }; for (const material of REVET_MATERIALS) { const option = document.createElement("option"); option.value = material; option.textContent = MATERIAL_LABEL[material]; option.title = `${L("B06_Cross_Height_Label")} ~${materialLimit(material).toFixed(1)}m`; materialSelect.append(option); } materialSelect.addEventListener("change", () => { if (current) deps.setMaterial(current, materialSelect.value as RevetMaterial); }); materialParts.controls.append(materialSelect); // 높이 행 — ±0.1m, 한계는 재질이 정한다. const heightParts = makeRow(L("B06_Cross_Height_Label")); const heightRow = heightParts.row; const heightValue = document.createElement("span"); heightValue.className = "b06-structure-panel__hval"; const heightMinus = makeButton( "-", L("B06_Cross_Height_Minus"), act((key) => deps.nudgeHeight(key, -HEIGHT_STEP_M)), ); const heightPlus = makeButton( "+", L("B06_Cross_Height_Plus"), act((key) => deps.nudgeHeight(key, HEIGHT_STEP_M)), ); heightParts.controls.append(heightMinus, heightValue, heightPlus); // 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ. const structureParts = makeRow(L("B06_Cross_Struct_Label")); const structureRow = structureParts.row; const select = document.createElement("select"); select.className = "b06-structure-panel__select"; const OPTIONS: Array<[InletStructureChoice, string]> = [ ["auto", L("B06_Cross_Struct_Auto")], ["revet", L("B06_Cross_Struct_Revet")], ["I", L("B06_Cross_Struct_I")], ["L", L("B06_Cross_Struct_L")], ["U", L("B06_Cross_Struct_U")], ]; for (const [optionValue, label] of OPTIONS) { const option = document.createElement("option"); option.value = optionValue; option.textContent = label; select.append(option); } select.addEventListener("change", () => { deps.setStructure(select.value as InletStructureChoice); }); structureParts.controls.append(select); // 다단 기슭막이 단 수(2026-08-22 사용자) — 높이 행과 같은 형식(- 값 +), // 직접 입력 없음. 현재 단 수는 show()가 채운다. const extraParts = makeRow(L("B06_Cross_Extra_Count")); const extraRow = extraParts.row; const countValue = document.createElement("span"); countValue.className = "b06-structure-panel__hval"; let currentCount = 0; const clampCount = (value: number): number => Math.max(0, Math.min(9, Math.round(value))); const countMinus = makeButton("-", L("B06_Cross_Extra_Remove"), () => deps.setExtraCount(clampCount(currentCount - 1)), ); const countPlus = makeButton("+", L("B06_Cross_Extra_Add"), () => deps.setExtraCount(clampCount(currentCount + 1)), ); extraParts.controls.append(countMinus, countValue, countPlus); const closeButton = makeButton("✕", L("B06_Cross_Revet_Close"), () => deps.close()); closeButton.classList.add("b06-structure-panel__close"); const head = document.createElement("div"); head.className = "b06-structure-panel__head"; head.append(title, closeButton); root.append(head, value, structureRow, materialRow, heightRow, extraRow, moveRow.row); return { root, show: (key) => { current = key; root.classList.toggle("is-hidden", key === null); if (!key) return; const isExtra = key.startsWith("extra"); const movable = deps.canNudge(key); title.textContent = isExtra ? `${L("B06_Cross_Revet_Extra")} ${Number(key.slice(5)) + 1}` : key === "outlet" ? L("B06_Cross_Revet_Outlet") : movable ? L("B06_Cross_Revet_Inlet") : L("B06_Cross_Struct_InletBasin"); // 형식 선택은 유입측에서만, 이동·높이 조작은 기슭막이(집수정 제외)만. structureRow.classList.toggle("is-hidden", key !== "inlet"); if (key === "inlet") { const structure = deps.structureFor(); // 상황에 안 맞는 선택지는 숨긴다(2026-08-22 사용자). 단 지금 선택된 값은 // 남긴다 — 숨기면 셀렉트가 빈 값이 된다. const allow = deps.optionsFor(); for (const option of select.options) { const optionValue = option.value as InletStructureChoice; const hidden = (optionValue === "revet" && !allow.revetAllowed) || ((optionValue === "L" || optionValue === "U") && !allow.basinLUAllowed); option.hidden = hidden && optionValue !== structure; } select.value = structure; } moveRow.row.classList.toggle("is-hidden", !movable); materialRow.classList.toggle("is-hidden", !movable); heightRow.classList.toggle("is-hidden", !movable); if (movable) { heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`; materialSelect.value = deps.materialFor(key); } // 단 수 행은 유출 벽에서 **항상** 보인다(2026-08-22 사용자 ⑤ — 상태 따라 // 나타났다 사라지면 레이아웃이 널뛴다). const extra = deps.extraState(); extraRow.classList.toggle("is-hidden", key !== "outlet"); currentCount = extra.count; countValue.textContent = `${extra.count}단`; const adjust = deps.adjustFor(key); // 정보는 항목당 한 행(2026-08-22 사용자) — 0이면 "자동", 좌우는 안/바깥, // 상하는 위/아래로 적는다. const pipeLength = deps.pipeLengthM(); pipeLine.classList.toggle("is-hidden", pipeLength === null); pipeLine.textContent = pipeLength === null ? "" : `${L("B06_Cross_Revet_Pipe")} ${pipeLength}m`; lateralLine.textContent = `${L("B06_Cross_Lateral_Label")} ` + (Math.abs(adjust.x) > 1e-9 ? `${L(adjust.x > 0 ? "B06_Cross_Revet_Outward" : "B06_Cross_Revet_Inward")} ${Math.abs(adjust.x).toFixed(1)}m` : L("B06_Cross_Revet_Auto")); slopeLine.textContent = `${L("B06_Cross_Slope_Label")} ` + (Math.abs(adjust.d) > 1e-9 ? `${L(adjust.d > 0 ? "B06_Cross_Revet_DirDown" : "B06_Cross_Revet_DirUp")} ${Math.abs(adjust.d).toFixed(1)}m` : L("B06_Cross_Revet_Auto")); }, }; }