/* ============================================================================= * 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`) 안 **좌측 하단**에 얹힌다. * * 값 칸은 좌측 폼 하나(계획서 24-4a) — 오버레이 창 = 읽기 · 십자 · 그림 끌기 손잡이만. * 폼에 없는 값(단 수 · 옵션 · 다단 벽 · 집수정 내공 · 읽기 줄)은 사본(`dock`)이 줄로 만들어 * 폼 칸에 옮겨 붙인다(`_Adjust_Dock`). 폼과 같은 값(형태 · 높이 · 길이 · 전 · 후)은 만들지 않는다. * ========================================================================== */ import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; import { REVET_FORMS, revetHeightLimit } from "./B06_Section_UI_Cross_Culvert_Const"; import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import type { SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire"; import { L } from "./B06_Section_UI_Section_Common"; import { arrowsBelongToProfile, bindDragHandle, dragReadout, makeButton, makeRow, } from "./B06_Section_UI_Cross_Panel_Base"; /** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */ 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; /** 지금 그려진 벽의 **형태**(B05 폼과 같은 목록). */ formFor: (key: RevetKey) => string; setForm: (key: RevetKey, form: string) => void; /** 자동 자리로 되돌린다(4축 모두). */ reset: (key: RevetKey) => void; /** 지금 관 길이(m) — 조정 단위가 관 길이 1m이라 창에 같이 적는다. */ pipeLengthM: () => number | null; /** 창을 닫는다 = 구조물 선택 해제. */ close: () => void; /** 유입측 구조물 형식(드롭다운 값 — 2026-08-22 사용자). */ structureFor: () => InletStructureChoice; basinAdjustFor: () => BasinAdjust; updateBasin: (patch: Partial) => void; /** 집수정 좌우 — **화면 기준**(+ = 화면 왼쪽). 부호 환산·한계 토스트는 카드가 한다. */ nudgeBasinLateral: (screenDeltaM: number) => void; resetBasin: () => void; /** 집수정 계류측 다단 상태·단 수 지정(2026-08-22 — 유출측과 같은 체계). * 독립 기슭막이(관 숨김)에서는 집수정이 없으므로 이 채널이 **유입측 벽의 다단**이다. */ basinExtraState: () => { canAdd: boolean; count: number }; setBasinExtraCount: (count: number) => void; /** 독립 기슭막이(관 숨김) 카드인가 — 유입·유출 벽이 같은 성토 로직이라 조정창도 * 좌우 같은 행을 준다(집수정 형식 숨김 · 유입측 단 수 행 표시 — 2026-08-29). */ hiddenPipe: () => boolean; /** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */ canNudge: (key: RevetKey) => boolean; /** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */ fineMove: (key: RevetKey) => boolean; /** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */ extraState: () => { canAdd: boolean; count: number }; /** 이 카드가 연동으로 넘어온 링크 카드인가 — 단 수는 **기준(소유) 측점**에서만 * 다룬다(2026-08-29 사용자). 링크 카드에서는 단 수 행을 숨긴다. */ isLinkedCard: () => boolean; /** 다단 기슭막이 단 수 지정 — 지형 허용보다 크면 기하가 자르고 토스트로 알린다. */ setExtraCount: (count: number) => void; /** 다단 등간격 배치(2026-08-22 ①) — 사면 구간이 같아지게 단들을 재배치. */ equalizeExtras: () => void; /** 이 구조물의 종방향 구간값(길이·전·후). 구간값이 없는 요소면 null(2026-08-24). */ spanFor: (key: RevetKey) => SpanValues | null; /** 구간값 변경 — 링크 카드에서 만져도 **소유 측점** 값이 바뀐다. */ setSpan: (key: RevetKey, patch: Partial) => void; /** 이 카드가 연동으로 생긴 기슭막이인지 + 지금 연동 상태. 소유 측점이면 null. */ linkState: () => { linked: boolean } | null; setLinked: (linked: boolean) => void; /** 종단경사 반영 — 기슭막이 한 벌 전체 공통(소유 측점에 하나). */ followGrade: () => boolean; setFollowGrade: (follow: boolean) => void; /** 창 스크롤 자리를 카드별로 기억할 키(측점 id). 버튼을 누르면 카드가 다시 그려지고 * 창도 새로 만들어지므로, 이 키로 스크롤 자리를 되살린다(2026-08-24 사용자). */ scrollKey: string; } 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; /** * 독립·추가 기슭막이의 좌우·상하 한 걸음(m) — **0.1m**(2026-08-29 사용자). * 배관 기준벽(유입·유출)만 관 길이에 맞춘 1m 걸음을 그대로 쓴다. */ const OWN_STEP_M = 0.1; const HEIGHT_STEP_M = 0.1; const BASIN_STEP_M = 0.1; /** 전/후 한 걸음(m) — 집수정 기본이 2m·1/1이라 1m 눈금은 너무 거칠다. */ const SPAN_STEP_M = 0.5; /** 길이 한 걸음(m) — **전/후 걸음의 2배**여야 한 번에 양쪽이 정확히 반씩 늘어난다. * 0.5m로 두면 반쪽이 0.25m라 0.1m 눈금에 안 떨어지고 전/후가 어긋난다. */ const LENGTH_STEP_M = SPAN_STEP_M * 2; /** * 카드별 창 스크롤 자리. 조작 버튼을 누르면 카드가 통째로 다시 그려져 창이 새로 * 만들어진다 — 기억해 두지 않으면 매번 맨 위(제목)로 튄다(2026-08-24 사용자). */ const panelScrollTops = new Map(); /** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. * `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29 일원화): 흐름 배치 스타일을 입고, * 방향키 문서 리스너를 달지 않는다(오버레이 창과 이중으로 눌리는 것 방지). */ export function buildStructurePanel( deps: StructurePanelDeps, opts?: { dock?: boolean }, ): StructurePanelHandle { const root = document.createElement("div"); root.className = "b06-structure-panel"; root.classList.add("is-hidden"); if (opts?.dock) root.classList.add("b06-structure-panel--dock"); root.addEventListener("scroll", () => { if (!root.classList.contains("is-hidden")) panelScrollTops.set(deps.scrollKey, root.scrollTop); }); /** * 기억해 둔 스크롤 자리를 되살린다. 카드를 다시 그릴 때 창은 **카드가 문서에 * 붙기 전에** 만들어지므로 그 순간의 대입은 레이아웃이 없어 먹지 않는다 * (2026-08-29 사용자: 값 조절만 해도 창이 제목 줄로 튀던 원인). 대입이 먹지 * 않았으면 붙은 다음 프레임에 한 번 더 넣는다. */ const restoreScroll = (): void => { const saved = panelScrollTops.get(deps.scrollKey); if (!saved) return; root.scrollTop = saved; if (root.scrollTop === saved) return; requestAnimationFrame(() => { if (root.isConnected && !root.classList.contains("is-hidden")) root.scrollTop = saved; }); }; // 창 안에서의 클릭이 카드 선택으로 번지지 않게 한 번에 막는다. 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"; // 사본의 읽기 줄은 폼 칸으로 옮겨 붙는다 — 끌어 맞춘 좌우 · 상하가 폼에서도 보인다(24-4a). if (opts?.dock) value.classList.add("b06-structure-panel__struct--readout"); const pipeLine = document.createElement("span"); const lateralLine = document.createElement("span"); const slopeLine = document.createElement("span"); const dragLine = dragReadout(); value.append(pipeLine, lateralLine, slopeLine, dragLine); let current: RevetKey | null = null; const act = (run: (key: RevetKey) => void) => () => { if (current) run(current); }; const moveStepOf = (key: RevetKey): number => (deps.fineMove(key) ? OWN_STEP_M : STEP_M); 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, -moveStepOf(key))), ), dpad( "◀", "left", L("B06_Cross_Revet_Left"), act((key) => deps.nudge(key, moveStepOf(key))), ), dpad( "↺", "reset", L("B06_Cross_Revet_Reset"), act((key) => deps.reset(key)), ), dpad( "▶", "right", L("B06_Cross_Revet_Right"), act((key) => deps.nudge(key, -moveStepOf(key))), ), dpad( "▼", "down", L("B06_Cross_Revet_Down"), act((key) => deps.nudgeSlope(key, moveStepOf(key))), ), dpad("≡", "equal", L("B06_Cross_Extra_Equalize"), () => deps.equalizeExtras()), ); // 형태 행(2026-08-22 사용자 ④ — 높이 행 **위**, 폭 축소: 한계는 툴팁으로). // 선택지는 **B05 폼 형태 6종 그대로**다(2026-08-29 사용자). 종전에는 재질 3종 // (메/찰/콘크리트)만 보여 돌망태·통나무·바자를 고를 수 없었고, 조정창에서 만지면 // B05에서 고른 형태가 3종으로 뭉개졌다. const materialParts = makeRow(L("B06_Cross_Mat_Label")); const materialRow = materialParts.row; // 좌측 유입구·유출구 폼의 "형태"와 중복 — 사본에서는 숨긴다(2026-08-29 지시 3). materialRow.classList.add("b06-structure-panel__struct--form"); const materialSelect = document.createElement("select"); materialSelect.className = "b06-structure-panel__select"; for (const form of REVET_FORMS) { const option = document.createElement("option"); option.value = form; option.textContent = form; option.title = `${L("B06_Cross_Height_Label")} ~${revetHeightLimit(form).toFixed(1)}m`; materialSelect.append(option); } materialSelect.addEventListener("change", () => { if (current) deps.setForm(current, materialSelect.value); }); materialParts.controls.append(materialSelect); // 높이 행 — ±0.1m, 한계는 재질이 정한다. const heightParts = makeRow(L("B06_Cross_Height_Label")); const heightRow = heightParts.row; // 다단 벽에서는 이 행이 [추가 기슭막이] 그룹으로 옮겨 간다(2026-08-30 지시 4). heightRow.classList.add("b06-structure-panel__struct--height"); 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); // 유입측 구조물 형식은 좌측 유입구 「구조」 하나가 고른다(24-4a) — 창에는 칸을 두지 않는다. // 집수정 조작 = 십자(위치)만 창에 — 내공 크기(옛 9키 네 모서리)는 폼 칸으로 옮긴 // 「내공 폭 · 높이」 줄이 맡는다(24-4a). 가운데는 초기화. // 사면▲ // ◀ ↺ ▶ // 사면▼ const basinRow = makeRow("집수정 위치"); basinRow.controls.classList.add("b06-structure-panel__basin-buttons"); const basinKey = ( label: string, slot: string, titleText: string, onClick: () => void, ): HTMLButtonElement => { const button = makeButton(label, titleText, onClick); button.classList.add(`b06-structure-panel__btn--${slot}`); return button; }; const basinPatch = (patch: () => Partial) => () => deps.updateBasin(patch()); /** 집수정 사면 이동 — 걸음 수(+ = 아래). 십자 · 끌기가 같이 쓴다. */ const moveBasinSlope = (steps: number): void => deps.updateBasin({ slopeM: Math.max( 0, Math.round((deps.basinAdjustFor().slopeM + steps * BASIN_STEP_M) * 10) / 10, ), }); basinRow.controls.append( basinKey("▲", "up", "1:1.2 성토선을 따라 위로 0.1m", () => moveBasinSlope(-1)), basinKey("◀", "left", L("B06_Cross_Revet_Left"), () => deps.nudgeBasinLateral(BASIN_STEP_M)), basinKey("↺", "reset", L("B06_Cross_Revet_Reset"), () => deps.resetBasin()), basinKey("▶", "right", L("B06_Cross_Revet_Right"), () => deps.nudgeBasinLateral(-BASIN_STEP_M)), basinKey("▼", "down", "1:1.2 성토선을 따라 아래로 0.1m", () => moveBasinSlope(1)), ); // 집수정 내공 폭 · 높이(ㄴ · ㄷ형) — 옛 9키 모서리. 폼 유입구 칸으로 옮겨 붙는 줄(24-4a). const basinSizeRows = ( [ ["내공 폭", "innerWidthM"], ["내공 높이", "innerHeightM"], ] as const ).map(([label, field]) => { const parts = makeRow(label); parts.row.classList.add("b06-structure-panel__struct--basin-size"); const readout = document.createElement("span"); readout.className = "b06-structure-panel__hval"; // 한계 1~2m 는 옛 9키 그대로. const step = (delta: number) => basinPatch(() => ({ [field]: Math.min( 2, Math.max(1, Math.round((deps.basinAdjustFor()[field] + delta) * 10) / 10), ), })); parts.controls.append( makeButton("-", `${label} ${BASIN_STEP_M}m 줄이기`, step(-BASIN_STEP_M)), readout, makeButton("+", `${label} ${BASIN_STEP_M}m 늘리기`, step(BASIN_STEP_M)), ); return { row: parts.row, readout, field }; }); // 다단 기슭막이 단 수(2026-08-22 사용자) — 높이 행과 같은 형식(- 값 +), // 직접 입력 없음. 현재 단 수는 show()가 채운다. // // 벽 종류(배관 유입·유출, 독립, 집수정 계류측)를 가리지 않고 **행 하나**가 맡는다 // (2026-08-29 사용자: 좌·우 창의 행 순서가 갈리던 원인이 유입/유출 전용 행 둘이었다). // 어느 저장 채널로 보낼지는 선택된 벽이 정한다 — 유출 벽은 `extras`(extra 키), // 유입 벽·집수정 계류측은 `basinExtras`(bextra 키). const extraParts = makeRow(L("B06_Cross_Extra_Count")); const extraRow = extraParts.row; // 유입구·유출구 그룹으로 옮겨 붙는 행(2026-08-29 지시 4). extraRow.classList.add("b06-structure-panel__struct--tier"); // 독립 기슭막이 레지스트리 「단 수」(tiers)를 끄면 폼 칸에서 이 줄도 가린다(옵션 보이기 · 24-4a). extraRow.dataset.optionKeys = "tiers"; const countValue = document.createElement("span"); countValue.className = "b06-structure-panel__hval"; let currentCount = 0; /** 지금 창이 든 벽의 단 수 저장 채널. null이면 단 수 행을 숨긴다. */ let tierChannel: "outlet" | "basin" | null = null; const clampCount = (value: number): number => Math.max(0, Math.min(9, Math.round(value))); const setCount = (value: number): void => { if (tierChannel === "outlet") deps.setExtraCount(clampCount(value)); else if (tierChannel === "basin") deps.setBasinExtraCount(clampCount(value)); }; const countMinus = makeButton("-", L("B06_Cross_Extra_Remove"), () => setCount(currentCount - 1), ); const countPlus = makeButton("+", L("B06_Cross_Extra_Add"), () => setCount(currentCount + 1)); extraParts.controls.append(countMinus, countValue, countPlus); // 종방향 구간값(2026-08-24 사용자) — 길이·기준측점 전·후. 세 값이 곧 이 구조물이 // 덮는 범위이고, 그 범위에 걸린 옆 측점에 기슭막이가 자동으로 선다. 길이를 바꾸면 // 전/후가 지금 비율대로 따라가고, 전·후를 바꾸면 길이는 둘의 합이 된다. let currentSpan: SpanValues | null = null; const spanRows: Array<{ row: HTMLElement; value: HTMLElement; field: keyof SpanValues }> = []; const makeSpanRow = (label: string, field: keyof SpanValues): void => { const parts = makeRow(label); const readout = document.createElement("span"); readout.className = "b06-structure-panel__hval"; const stepM = field === "lengthM" ? LENGTH_STEP_M : SPAN_STEP_M; const step = (delta: number): void => { if (!current || !currentSpan) return; const next = Math.max(0, Math.round((currentSpan[field] + delta) * 10) / 10); deps.setSpan(current, { [field]: next } as Partial); }; parts.controls.append( makeButton("-", `${label} ${stepM}m 줄이기`, () => step(-stepM)), readout, makeButton("+", `${label} ${stepM}m 늘리기`, () => step(stepM)), ); // 다단 벽에서는 세 구간값 행도 [추가 기슭막이] 그룹으로 함께 옮겨 간다. parts.row.classList.add("b06-structure-panel__struct--span"); spanRows.push({ row: parts.row, value: readout, field }); }; makeSpanRow("길이", "lengthM"); makeSpanRow("기준측점 전", "beforeM"); makeSpanRow("기준측점 후", "afterM"); // 연동 기슭막이 옵션 2가지(2026-08-24 사용자) — **버튼 두 개를 한 행**에 둔다. // 연동은 측점별, 경사(종단경사 반영)는 기슭막이 한 벌 전체 공통이다. const optionParts = makeRow("옵션"); // 단 수와 함께 유입구·유출구 그룹으로 옮겨 붙는다(2026-08-29 지시 4). optionParts.row.classList.add("b06-structure-panel__struct--options"); optionParts.controls.classList.add("b06-structure-panel__toggles"); const makeToggle = (label: string, title: string, onToggle: (on: boolean) => void) => { const button = document.createElement("button"); button.type = "button"; button.className = "b06-structure-panel__btn b06-structure-panel__toggle"; button.textContent = label; button.title = title; let on = true; button.addEventListener("click", (event) => { event.stopPropagation(); on = !on; button.classList.toggle("is-on", on); button.setAttribute("aria-pressed", String(on)); onToggle(on); }); const set = (value: boolean): void => { on = value; button.classList.toggle("is-on", value); button.setAttribute("aria-pressed", String(value)); }; optionParts.controls.append(button); return { button, set }; }; const LINK_TITLE = "옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다."; /** 경사 해제(수평) 중에는 연동을 못 푼다 — 기슭막이 Z를 하나로 묶기 때문(2026-08-30). */ const LINK_LOCKED_TITLE = "경사 해제(수평) 중에는 연동을 풀 수 없다 — 기슭막이 Z를 하나로 묶는다."; const linkToggle = makeToggle("연동", LINK_TITLE, (on) => deps.setLinked(on)); const gradeToggle = makeToggle( "경사", "종단경사 반영(3D 전용) — 켜면 3D 기슭막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.", (on) => deps.setFollowGrade(on), ); // 키보드 방향키 = 십자 버튼(2026-08-23 사용자). 9키(집수정)도 **십자 축만** — // 모서리(크기) 키는 대상 아님. 셀렉트 포커스 중엔 드롭다운 탐색에 양보한다. // 창은 카드마다 새로 만들어지므로 문서 리스너는 show/hide에 맞춰 붙였다 떼고, // 카드가 통째로 사라진 경우(root 분리)엔 스스로 해제한다. const ARROW_SLOT: Record = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", }; const onArrowKey = (event: KeyboardEvent) => { if (!root.isConnected) { document.removeEventListener("keydown", onArrowKey); return; } const slot = ARROW_SLOT[event.key]; // 보기가 숨었으면(한 페이지 두 보기 · 계획서 24-3a) 다른 보기의 방향키를 먹지 않는다. if (!slot || root.classList.contains("is-hidden") || !root.getClientRects().length) return; if (arrowsBelongToProfile()) return; // 종단도를 눌렀으면 방향키는 계획고 몫(24-3b) const focus = event.target as HTMLElement | null; if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return; for (const candidate of [basinRow, moveRow]) { if (candidate.row.classList.contains("is-hidden")) continue; const button = candidate.controls.querySelector( `.b06-structure-panel__btn--${slot}`, ); if (button) { event.preventDefault(); button.click(); return; } } }; 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"; const basinReset = makeButton("↺", "집수정 기본값", () => deps.resetBasin()); basinReset.classList.add("b06-structure-panel__basin-reset"); head.append(title, basinReset, closeButton); // 오버레이 = 읽기 + 십자(기슭막이) · 위치 십자(집수정)만 — 값 칸이 없다(24-4a). 두 조작 // 세트는 **창 맨 아래**(2026-08-24 사용자) · 동시에 뜨지 않아 같은 자리에 온다. // 사본 = 폼 칸으로 옮겨 붙을 줄만(`_Adjust_Dock`) — 십자는 오버레이 전용(2026-08-29 지시 7). root.append( head, value, ...(opts?.dock ? [ materialRow, heightRow, extraRow, ...spanRows.map((entry) => entry.row), optionParts.row, ...basinSizeRows.map((entry) => entry.row), ] : [basinRow.row, moveRow.row]), ); // 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a). 오버레이만. let dragKind: "wall" | "basin" | null = null; const drag = bindDragHandle(root, { stepM: () => !current || !dragKind ? null : dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current), preview: (text) => (dragLine.textContent = text), commit: (left, down) => { if (!current) return; if (dragKind === "basin") { if (left) deps.nudgeBasinLateral(left * BASIN_STEP_M); if (down) moveBasinSlope(down); return; } if (left) deps.nudge(current, left * moveStepOf(current)); if (down) deps.nudgeSlope(current, down * moveStepOf(current)); }, }); return { root, show: (key) => { current = key; root.classList.toggle("is-hidden", key === null); document.removeEventListener("keydown", onArrowKey); drag.detach(); if (!key) return; if (!opts?.dock) { document.addEventListener("keydown", onArrowKey); drag.attach(); } // 다단 벽 — 유출측(extra0…)과 유입·집수정 계류측(bextra0…)이 같은 이름을 쓴다. const isExtra = key.startsWith("extra") || key.startsWith("bextra"); const extraIndex = key.startsWith("bextra") ? Number(key.slice(6)) : Number(key.slice(5)); // 독립 기슭막이(구조물 정본 D군) — 배관 부속이 아니라 자리·높이만 만진다. const isOwn = key === "own"; const movable = deps.canNudge(key); const structure = deps.structureFor(); const isBasin = key === "inlet" && (structure === "I" || structure === "L" || structure === "U"); // 독립 기슭막이(관 숨김)의 기준벽은 관이 없어 유입·유출이라는 이름이 안 맞는다 — // 좌·우 어느 쪽이든 "기슭막이(독립)"으로 적는다(2026-08-29 사용자). title.textContent = isOwn || (deps.hiddenPipe() && (key === "inlet" || key === "outlet")) ? L("B06_Cross_Revet_Own") : isExtra ? L("B06_Cross_Revet_Extra").replace("{n}", String(extraIndex + 1)) : key === "outlet" ? L("B06_Cross_Revet_Outlet") : movable ? L("B06_Cross_Revet_Inlet") : L("B06_Cross_Struct_InletBasin"); // 이동 조작은 기슭막이(집수정 제외) · 집수정은 위치 십자. dragKind = isBasin ? "basin" : movable ? "wall" : null; moveRow.row.classList.toggle("is-hidden", !movable); // 등간격(≡)은 **유출 채널 다단 전용**이다 — 유입 벽에서 누르면 남의 단을 옮긴다. // 독립 기슭막이(옛 D경로 own)는 단 수·자리가 구조물 정본 몫이라 역시 숨긴다. moveRow.controls .querySelector(".b06-structure-panel__btn--equal") ?.classList.toggle("is-hidden", isOwn || key !== "outlet"); basinRow.row.classList.toggle("is-hidden", !isBasin); basinReset.classList.toggle("is-hidden", !isBasin); // I형은 내공(높이·폭) 개념이 없다 — 내공 줄을 숨긴다. for (const entry of basinSizeRows) { entry.row.classList.toggle("is-hidden", !isBasin || structure === "I"); if (isBasin) entry.readout.textContent = `${deps.basinAdjustFor()[entry.field].toFixed(1)}m`; } // 형태 · 높이는 좌측 폼 칸이 정본이다(유입구 · 유출구 · 독립 좌우 · 구조물 옵션) — 폼에 // 자기 칸이 없는 **다단 벽**만 이 줄을 「추가 기슭막이」 칸에 세운다(24-4a). materialRow.classList.toggle("is-hidden", !isExtra); heightRow.classList.toggle("is-hidden", !isExtra); if (isExtra) { heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`; materialSelect.value = deps.formFor(key); } // 단 수 행 — 벽 종류를 가리지 않고 **높이 행 바로 아래** 한 자리에 온다. // 유출 벽은 자기 채널(extra), 유입 벽·집수정 계류측은 basin 채널(bextra)을 쓴다. // 유출 벽과 독립 기슭막이 유입 벽에서는 **항상** 보인다(2026-08-22 사용자 ⑤ — // 상태 따라 나타났다 사라지면 레이아웃이 널뛴다). 집수정 계류측은 둘 자리가 // 있을 때만 뜬다(배관 유입 기슭막이는 다단 채널 자체가 없다). // 다단 벽을 고른 동안에도 단 수는 **그 단이 딸린 부모 벽**의 값이다 — 채널을 // 부모 쪽으로 열어 두고 행은 부모 칸에 남긴다(2026-08-30 사용자: 추가 기슭막이를 // 고르면 단 수·옵션이 사라지던 것). tierChannel = key === "outlet" || key.startsWith("extra") ? "outlet" : key === "inlet" || key.startsWith("bextra") ? "basin" : null; // 좌측 [횡단 조정] 사본의 단 수·옵션 행이 어느 그룹(유입구·유출구)으로 옮겨 // 붙을지 알린다(2026-08-29 지시 4). 다단 벽은 자기 채널 쪽으로 간다. // 다단(추가) 벽은 유입·유출 어느 칸에도 자기 자리가 없다 — 유입구·유출구 아래 // **전용 칸**으로 보낸다(2026-08-30 사용자 지시 4). // 다단 벽이 어느 쪽 채널의 단인지 — 그 쪽 칸 **바로 아래**에 붙인다(2026-08-30). root.dataset.extraSide = isExtra ? (key.startsWith("bextra") ? "inlet" : "outlet") : ""; root.dataset.side = isExtra ? "extra" : tierChannel === "outlet" ? "outlet" : tierChannel === "basin" ? "inlet" : ""; // 옮겨 갈 그룹의 이름표에 쓴다 — 유입구·유출구는 괄호 안에, 추가 기슭막이는 // 이름 자체로(2026-08-30 사용자 지시 1). 어느 구조물을 만지는지 폼에서 읽힌다. // 다단은 칸 이름이 이미 "추가 기슭막이"라 단 번호만 붙인다 — "추가 기슭막이 (2단)". root.dataset.panelTitle = isExtra ? `${extraIndex + 1}단` : (title.textContent ?? ""); const tiers = tierChannel === "outlet" ? deps.extraState() : tierChannel === "basin" ? deps.basinExtraState() : { canAdd: false, count: 0 }; // 기슭막이 기준벽(배관 유입·유출, 독립 좌·우)에서는 **항상** 보인다(2026-08-30 // 사용자). 링크 카드에서 만져도 제어기가 소유 측점 값을 고치므로 안전하다. // 집수정 유입은 계류측 단이 설 자리가 있을 때만 — 벽이 아니라 구체다. // 다단 벽도 포함한다 — 행은 부모 칸에 서 있고, 그 값(부모 단 수·옵션)을 그대로 쓴다. const revetWall = ((key === "inlet" || key === "outlet") && !isBasin) || isExtra; extraRow.classList.toggle( "is-hidden", !revetWall && !(isBasin && tierChannel === "basin" && (tiers.canAdd || tiers.count > 0)), ); currentCount = tiers.count; countValue.textContent = `${tiers.count}단`; // 구간값 — 유입·유출 기슭막이, 유입 집수정, 그리고 **다단**(2026-08-29 사용자: // 계곡부·능선부에서 단마다 연장이 달라 단별로 손수 넣는다)에서 세 행이 뜬다. // 기준벽 · 집수정의 길이 · 전 · 후는 폼 칸(유입구 · 유출구)이 정본 — 줄은 다단 벽만(24-4a). currentSpan = deps.spanFor(key); for (const entry of spanRows) { entry.row.classList.toggle("is-hidden", currentSpan === null || !isExtra); if (currentSpan) entry.value.textContent = `${currentSpan[entry.field].toFixed(1)}m`; } // 연동은 링크 카드에서만, 경사는 구간값이 있는 구조물에서만. 둘 다 없으면 행 자체를 숨긴다. const linkState = deps.linkState(); linkToggle.button.classList.toggle("is-hidden", linkState === null); linkToggle.set(linkState?.linked ?? true); // 경사 해제(수평) = 연동 잠김(2026-08-30 사용자). 버튼은 눌리지 않고 이유를 단다. const levelled = !deps.followGrade(); linkToggle.button.disabled = levelled; linkToggle.button.title = levelled ? LINK_LOCKED_TITLE : LINK_TITLE; gradeToggle.button.classList.toggle("is-hidden", currentSpan === null); gradeToggle.set(!levelled); // 옵션(연동·경사)도 기슭막이 기준벽에서는 항상 자리를 지킨다 — 상태 따라 // 나타났다 사라지면 아래 행들이 널뛴다(2026-08-30 사용자). optionParts.row.classList.toggle( "is-hidden", !revetWall && linkState === null && currentSpan === null, ); 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")); // d = null → 자동(기본 자리). 숫자는 성토선 0점(벽 상단=노견) 기준 절대값 // (배관 벽 — 2026-08-23). 0 = 성토선 0 지점. slopeLine.textContent = `${L("B06_Cross_Slope_Label")} ` + (adjust.d == null ? L("B06_Cross_Revet_Auto") : 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` : "0m"); // 집수정은 벽 값이 아니라 **집수정 자리**를 적는다 — 끌어 맞춘 위치가 폼 칸에서도 보인다(24-4a). if (isBasin) { const basin = deps.basinAdjustFor(); lateralLine.textContent = `${L("B06_Cross_Lateral_Label")} ${ basin.lateralM > 1e-9 ? `${L("B06_Cross_Revet_Outward")} ${basin.lateralM.toFixed(1)}m` : L("B06_Cross_Revet_Auto") }`; slopeLine.textContent = `${L("B06_Cross_Slope_Label")} ${ basin.slopeM > 1e-9 ? `${L("B06_Cross_Revet_DirDown")} ${basin.slopeM.toFixed(1)}m` : L("B06_Cross_Revet_Auto") }`; } // 행 표시가 다 끝난 뒤에 되살린다 — 숨김 토글로 창 높이가 바뀌기 전에 넣으면 // 브라우저가 잘라 버린다. restoreScroll(); }, }; }