/* ============================================================================= * B06_Section_UI_Berm_Panel.ts * 좌측 [소단] 패널 — 사용자가 **구간에 소단을 놓고 빼는** 자리 (계획서 3-9). * * 왜 자동이 아닌가 (2026-09-07 사용자 확정) — 소단 규격이 법령·도로·사방 기준마다 갈려 * (별표2 사면길이 2~3m마다 폭 50~100㎝ / KDS 높이 5m마다 폭 1m / 사방 절토고 3~5m마다 * 폭 0.5m 이상) 어느 것을 자동 적용해도 다른 설계가 된다. 그래서 **프로그램이 판정하지 * 않고 사용자가 놓는다**. 「붕괴 우려 지역」 판정도 하지 않는다. * * 입력 꼴은 **C군 구간형 구조물과 같다** — 기준 측점 + 전·후 거리로 종단 범위를 잡고, * 제원(폭·간격·기울기)을 함께 받는다. 값은 세션 초안(`berm`)에 쌓이고 [저장]·[확정]에서 * 정본으로 나간다(CLAUDE.md 5장 데이터 3층). * ========================================================================== */ import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { BERM_DEFAULT_INTERVAL_M, BERM_DEFAULT_SLOPE_DEG, BERM_DEFAULT_WIDTH_M, } from "@util/common_util_cross_berm"; import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; import { formatStation } from "../B05_Profile/B05_Profile_Util_Station"; import { buildGroup } from "./B06_Section_UI_Page_Common"; import { readBermSpans, writeBermSpans, type BermSpan } from "./B06_Section_UI_Page_Persist"; /** 기준측점 앞뒤 기본 거리(m) — C군 구간형 폼과 같은 값(길이 10m). */ const DEFAULT_BEFORE_M = 5; const DEFAULT_AFTER_M = 5; export interface BermPanelDeps { /** 측점간격(m) — 측점 칸이 「3+15」 꼴을 읽고 쓰는 데 쓴다. */ getInterval: () => number; /** 지금 대상 — 없으면 패널은 그려지되 저장하지 않는다. */ target: () => { projectId: string; routeId: number } | null; /** 목록이 바뀌었을 때 — 전 측점 재계산을 부르는 자리. */ onChange: () => void; } export interface BermPanel { root: HTMLElement; /** 프로젝트·노선이 정해진 뒤 세션값을 다시 읽어 목록을 그린다. */ reload: () => void; } function numberField( label: string, value: number, step: string, ): ReturnType { const field = createInputField({ label, type: "number" }); field.input.step = step; field.input.min = "0"; field.input.value = String(value); return field; } export function createBermPanel(deps: BermPanelDeps): BermPanel { const root = buildGroup("소단"); const anchor = stationFields("기준 측점", () => deps.getInterval()); const beforeField = numberField("기준측점 전 (m)", DEFAULT_BEFORE_M, "0.5"); const afterField = numberField("기준측점 후 (m)", DEFAULT_AFTER_M, "0.5"); const widthField = numberField("폭 (m)", BERM_DEFAULT_WIDTH_M, "0.1"); const intervalField = numberField("간격(사면길이) (m)", BERM_DEFAULT_INTERVAL_M, "0.5"); const slopeField = numberField("안쪽 기울기 (°)", BERM_DEFAULT_SLOPE_DEG, "0.5"); const spanRow = document.createElement("div"); spanRow.className = "b05-structure__grid"; spanRow.append(beforeField.root, afterField.root); const specRow = document.createElement("div"); specRow.className = "b05-structure__grid"; specRow.append(widthField.root, intervalField.root); const slopeRow = document.createElement("div"); slopeRow.className = "b05-structure__grid"; slopeRow.append(slopeField.root); const list = document.createElement("ul"); list.className = "b05-route__irregular-list"; let selected = -1; const addButton = createButton({ label: "추가", variant: "filled", onClick: () => add() }); const removeButton = createButton({ label: "삭제", variant: "ghost", onClick: () => remove() }); removeButton.classList.add("is-danger"); removeButton.disabled = true; const actions = document.createElement("div"); actions.className = "b06-profile__field-row"; actions.append(addButton, removeButton); root.append(anchor.wrap, spanRow, specRow, slopeRow, actions, list); function spans(): BermSpan[] { const target = deps.target(); return target ? readBermSpans(target.projectId, target.routeId) : []; } function save(next: BermSpan[]): void { const target = deps.target(); if (!target) return; writeBermSpans(target.projectId, target.routeId, next); render(); deps.onChange(); } function readNumber(field: { input: HTMLInputElement }, fallback: number): number { const parsed = Number(field.input.value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; } function add(): void { const interval = deps.getInterval(); const chainage = anchor.read(interval, true); if (chainage === null) { showToast("소단을 놓을 기준 측점을 넣어 주세요.", "error"); return; } const width = readNumber(widthField, BERM_DEFAULT_WIDTH_M); const gap = readNumber(intervalField, BERM_DEFAULT_INTERVAL_M); if (width <= 0 || gap <= 0) { showToast("소단 폭과 간격은 0보다 커야 합니다.", "error"); return; } const before = readNumber(beforeField, DEFAULT_BEFORE_M); const after = readNumber(afterField, DEFAULT_AFTER_M); save([ ...spans(), { start_m: Math.max(chainage - before, 0), end_m: chainage + after, width_m: width, interval_m: gap, slope_deg: readNumber(slopeField, BERM_DEFAULT_SLOPE_DEG), }, ]); selected = -1; } function remove(): void { if (selected < 0) return; const next = spans().filter((_span, index) => index !== selected); selected = -1; save(next); } function render(): void { const interval = deps.getInterval(); const current = spans(); list.replaceChildren(); removeButton.disabled = selected < 0 || selected >= current.length; if (!current.length) { const empty = document.createElement("li"); empty.className = "b05-route__irregular-empty"; empty.textContent = "놓은 소단이 없습니다."; list.append(empty); return; } current.forEach((span, index) => { const item = document.createElement("li"); item.className = "b05-route__irregular-item"; item.classList.toggle("is-selected", index === selected); const where = document.createElement("strong"); where.textContent = `${formatStation(span.start_m, interval)}~${formatStation(span.end_m, interval)}`; const what = document.createElement("span"); what.textContent = `폭 ${span.width_m}m · ${span.interval_m}m마다`; item.append(where, what); item.addEventListener("click", () => { selected = index === selected ? -1 : index; render(); }); list.append(item); }); } render(); return { root, reload(): void { selected = -1; render(); }, }; }