import type { PlacedRoutePoint, RoutePointKind } from "./B05_Profile_UI_Markers"; import type { StructureInstance } from "./B05_Profile_Api_Structures"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { createStructuresSection, type StructuresSection } from "./B05_Profile_UI_Structures_Panel"; import { type ButtonVariant, createButton, createSelectField } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; export interface RoutePanelValues { contourInterval: number; algorithm: "dijkstra" | "ridge_valley"; /** 임도 종류 — 현행 3종(간선·산불진화·작업). branch(지선)는 폐지됐으나 기존 * 저장분 호환을 위해 값으로는 받는다(2026-08-19). 값의 원천은 프로젝트 등록 * 정보(projects.road_type)이며 화면에서는 고르지 않는다. */ gradeClass: "trunk" | "fire" | "branch" | "work"; /** 설계속도(km/h) — 임도는 속도를 낼 수 없는 노선이라 기본 20이다. 간선·산불진화만 * 20~40에서 고를 수 있고 작업임도는 20 고정(별표2, 2026-08-19 사용자 확정). */ designSpeed: 20 | 30 | 40; paved: boolean; minCurveRadius: number | null; maxUphillGrade: number | null; maxDownhillGrade: number | null; minUphillGrade: number | null; minDownhillGrade: number | null; allowAvoidPassThrough: boolean; stationInterval: number | null; crossSampleInterval: number | null; longSampleInterval: number | null; terrainType: "normal" | "special"; maxGradePct: number | null; minVerticalRadius: number | null; minTangentLength: number | null; startElevationOffset: number | null; endElevationOffset: number | null; /** 횡단배수 최소 계획고를 계획선·편집에 강제할지 — 기본 해제(2026-09-01 사용자 지시). * 해제해도 부족분 경고는 그대로 뜬다. 강제만 풀고 판단은 사용자에게 맡긴다. */ enforcePipeClearance: boolean; } /** * 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값. * 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다. * 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다. */ /** 측점 간격(m) — 20m 고정(2026-08-18 사용자 확정, 입력 UI 삭제). */ const STATION_INTERVAL_M = 20; /* 종단기울기·종단곡선 반경 — 지식DB `설계제원_총괄 §6·§7`(간선·산불진화 공통, * 설계속도 40/30/20 → 7·10 / 8·12 / 9·14 %, 반경 450/250/100 m) 값과 일치한다 * (2026-08-19 재점검). 서버 `legal_grade_limit_pct`가 같은 표를 쓰므로 화면 안내와 * 실제 적용값이 어긋나지 않는다. * * ⚠ 미해결(사용자 협의 필요, 2026-08-19 재점검에서 확인): * ① 등급 선택지가 간선·지선·작업인데 현행 규칙은 **지선 폐지**, 3종은 간선· * 산불진화·작업이다(설계제원_총괄 §1 [구현] 지침). * ② 지식DB의 40/30/20 표는 **설계속도**별이고 간선·산불진화 공통이다. 지금은 * 등급에 속도를 고정 매핑(trunk=40·branch=30·work=20)했는데, 작업임도는 * 설계속도 20 이하 + 종단기울기 최대 20% 범위 조정이라는 별도 규정이다. * ③ "최소 곡선반경" 입력 칸의 안내값이 **종단**곡선 반경(450/250/100)이다. * 평면 최소곡선반지름은 일반지형 60/30/15 (설계제원_총괄 §4). * 값을 임의로 바꾸지 않고 남긴다 — 기본값 확정은 사용자 협의 영역이다. */ const PROFILE_CRITERIA: Record< RoutePanelValues["designSpeed"], { grade: Record; radius: number } > = { 40: { grade: { normal: 7, special: 10 }, radius: 450 }, 30: { grade: { normal: 8, special: 12 }, radius: 250 }, 20: { grade: { normal: 9, special: 14 }, radius: 100 }, }; /** 임도 종류별 표시 이름 — 프로젝트 등록(B02)과 같은 말을 쓴다(2026-08-19 확정). */ const ROAD_TYPE_LABELS: Record = { trunk: "간선임도", fire: "산불진화임도", work: "작업임도", branch: "작업임도(구 지선)", }; /** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */ const SPEED_CHOICES: Record> = { trunk: [20, 30, 40], fire: [20, 30, 40], work: [20], branch: [20], }; interface PanelCallbacks { onSolve: () => void; /** [임시저장] — 현재 편집(계획선 델타·관로·비정규 측점·상단측)을 확정 전이 없이 저장. */ onTempSave: () => void; /** [횡단 이동] — 저장 없이 B06 횡단 페이지로 이동만. */ onGoCross: () => void; /** [초기화] — 사용자 편집을 버리고 초기 자동 계산 상태로 롤백. */ onReset: () => void; onContourApply: (interval: number) => void | Promise; onSurfaceVisible: (visible: boolean) => void; /** [예상형상] — 계획 코리도 서피스 + 클리핑 지형(공사 후) ↔ 원지반 완전체 전환. */ onCorridorVisible: (visible: boolean) => void; onContoursVisible: (visible: boolean) => void; onAxesVisible: (visible: boolean) => void; onStationLinesVisible: (visible: boolean) => void; /** 측점 번호·이름 라벨 표시 토글(기본 켜짐 — BP·EP·5측점 배수·구조물). */ onStationLabelsVisible: (visible: boolean) => void; /** 지표면 흑백 표시 토글(기본 꺼짐 — 무지개 고도색). */ onSurfaceGrayscale: (grayscale: boolean) => void; onView: (view: "iso" | "top" | "front" | "side") => void; /** 직교/원근 전환 — 탑뷰에서 크기를 정밀 대조할 때만 직교로 본다(2026-09-04 사용자 지시). */ onProjection: (kind: "perspective" | "ortho") => void; onResetView: () => void; /** [3D 업데이트] — 계획선 편집을 3D 예상형상·측점선에 한 번에 반영(2026-09-01 사용자 * 지시). 편집마다 따라오던 자동 갱신을 없애고 이 버튼으로만 돌린다. */ onCorridorRefresh: () => void | Promise; onMovePoint: () => void; onDeletePoint: () => void; onRadiusChange: (radius: number) => void; onInputChange: () => void; /** 구조물 목록(구조물군 B~G)이 바뀔 때 — 정본 저장·그래프 반영은 Page가 맡는다. */ onStructuresChange: (structures: StructureInstance[]) => void; /** 구조물을 목록에서 선택/해제할 때 해당 구조물(또는 null). */ onStructureSelect: (structure: StructureInstance | null) => void; /** 바깥(3D·종단·유역도)에서 고른 것이 폼에 실릴 때 — 접힌 좌측 패널을 펼친다. */ onStructureReveal?: () => void; /** 계곡 통과 시설 추가·수정·삭제·선택 — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합). */ onPipeFacilityAdd: (chainageM: number, attributes: FacilityAttributes) => void; onPipeFacilityUpdate: ( fromChainageM: number, toChainageM: number, attributes: FacilityAttributes, ) => void; onPipeFacilityRemove: (chainageM: number) => void; /** null = 사이드 목록 재클릭 해제 — 전 화면 선택도 함께 푼다(2026-08-18). */ onPipeFacilitySelect: (chainageM: number | null) => void; /** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */ onStationDisplayChange: (offset: { station: number; cumulative: number }) => void; } type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement }; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } function section(title: string, collapsed = false): { root: HTMLElement; body: HTMLElement } { const root = document.createElement("section"); // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시). // 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫. root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`; const heading = document.createElement("h3"); heading.className = "ui-collapsible__title"; heading.textContent = title; const body = document.createElement("div"); body.className = "b05-route__panel-body"; root.append(heading, body); return { root, body }; } function button( label: string, onClick: () => void, variant: ButtonVariant = "ghost", ): HTMLButtonElement { return createButton({ label, variant, onClick: () => onClick() }); } function numberField(label: string, value = ""): WrappedInput { const wrapper = document.createElement("label"); wrapper.className = "b05-route__field"; const caption = document.createElement("span"); caption.textContent = label; const input = document.createElement("input"); input.type = "number"; input.step = "0.01"; input.value = value; wrapper.append(caption, input); return Object.assign(input, { wrapper }); } function checkbox(label: string, checked: boolean): WrappedInput { const wrapper = document.createElement("label"); wrapper.className = "b05-route__check"; const input = document.createElement("input"); input.type = "checkbox"; input.checked = checked; wrapper.append(input, document.createTextNode(label)); return Object.assign(input, { wrapper }); } function toggleButton( label: string, checked: boolean, onChange: (checked: boolean) => void, ): HTMLButtonElement { const element = button( label, () => { const active = !element.classList.contains("is-active"); element.classList.toggle("is-active", active); element.setAttribute("aria-pressed", String(active)); onChange(active); }, "glass", ); element.classList.toggle("is-active", checked); element.setAttribute("aria-pressed", String(checked)); return element; } function parseOptional(input: HTMLInputElement): number | null { if (!input.value.trim()) return null; const value = Number(input.value); return Number.isFinite(value) ? value : null; } export function createRoutePanel(callbacks: PanelCallbacks) { const root = document.createElement("div"); root.className = "b05-route__panel"; const viewControls = document.createElement("div"); viewControls.className = "b05-route__view-controls"; const viewButtons = document.createElement("div"); viewButtons.className = "b05-route__view-group"; (["iso", "top", "front", "side"] as const).forEach((preset) => viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), ); // 직교/원근 전환(2026-09-04 사용자 지시) — 기본은 원근이고, 단추 글자는 **바뀔 쪽**을 // 가리킨다(누르면 그쪽으로 간다). let projection: "perspective" | "ortho" = "perspective"; const projectionButton = button( "직교로", () => { projection = projection === "perspective" ? "ortho" : "perspective"; projectionButton.textContent = projection === "perspective" ? "직교로" : "원근으로"; callbacks.onProjection(projection); }, "glass", ); viewButtons.append(projectionButton); const visibilityButtons = document.createElement("div"); visibilityButtons.className = "b05-route__view-group"; visibilityButtons.append( toggleButton("지표면", true, callbacks.onSurfaceVisible), // 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체. toggleButton("예상형상", true, callbacks.onCorridorVisible), toggleButton("등고선", true, callbacks.onContoursVisible), toggleButton("축 표시", false, callbacks.onAxesVisible), toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible), toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible), // 무지개 고도색이 헷갈릴 때 명도만 남기는 흑백 표시(2026-08-05 사용자 요청). // 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다. toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale), ); // [3D 업데이트](2026-09-01 사용자 지시) — 계획선을 고칠 때마다 3D가 따라오면 조작이 // 무거워, 갱신을 이 버튼으로 모았다. 편집이 쌓이면 is-pending으로 알리고, 누르는 동안은 // 이 버튼만 잠근다(화면 전체 잠금 금지 — CLAUDE.md 5장). const corridorRefresh = button( "3D 업데이트", () => { corridorRefresh.disabled = true; corridorRefresh.textContent = "갱신 중…"; void Promise.resolve(callbacks.onCorridorRefresh()).finally(() => { corridorRefresh.disabled = false; corridorRefresh.textContent = "3D 업데이트"; corridorRefresh.classList.remove("is-pending"); }); }, "glass", ); corridorRefresh.title = "계획선 편집을 3D 예상형상·측점선에 반영합니다.\n편집이 쌓이면 버튼에 표시가 붙습니다."; const separator1 = document.createElement("span"); separator1.className = "b05-route__view-separator"; const separator2 = separator1.cloneNode() as HTMLSpanElement; const separator3 = separator1.cloneNode() as HTMLSpanElement; viewControls.append( viewButtons, separator1, visibilityButtons, separator2, button("뷰 초기화", callbacks.onResetView, "glass"), separator3, corridorRefresh, ); // 등고선 간격 행 — 「페이지 설정」 섹션에 들어간다(2026-08-18 컨테이너 병합). const contourInterval = numberField("간격 (m), 최소 0.5m", "1"); const contourRow = document.createElement("div"); contourRow.className = "b05-route__contour-row"; // 등고선 재적용은 화면 전체를 잠그지 않는다(CLAUDE.md 5장) — 누른 버튼만 잠가 // 진행을 알리고, 나머지 조작은 그대로 열어 둔다. const contourApply = button("재적용", () => { contourApply.disabled = true; contourApply.textContent = "적용 중…"; void Promise.resolve(callbacks.onContourApply(Number(contourInterval.value) || 1)).finally( () => { contourApply.disabled = false; contourApply.textContent = "재적용"; }, ); }); contourRow.append(contourInterval.wrapper, contourApply); // 포인트 팔레트 + 임도 기준·옵션을 한 컨테이너로 병합하고 [최적 경로 계산]도 이 안에 // 둔다 — 경로 재탐색은 B04~B06 재계산을 부르는 무거운 작업이라 가끔만 쓴다 // (2026-08-08 사용자 지시). const routeCalc = section("경로 계산 설정"); routeCalc.root.classList.add("is-collapsed"); const paletteGrid = document.createElement("div"); paletteGrid.className = "b05-route__palette"; const pointLabels: Record = { bp: "BP 시작점", ep: "EP 종료점", cp: "CP 경유점", ap: "AP 회피구역", fp: "FP 금지구역", }; (Object.keys(pointLabels) as RoutePointKind[]).forEach((kind) => { const chip = document.createElement("div"); chip.className = `b05-route__chip is-${kind}`; chip.draggable = true; chip.textContent = pointLabels[kind]; chip.addEventListener("dragstart", (event) => event.dataTransfer?.setData("pointType", kind)); paletteGrid.append(chip); }); routeCalc.body.append(paletteGrid); const selected = section("선택 포인트 상세 설정"); selected.root.hidden = true; const selectedName = document.createElement("strong"); const radius = numberField("회피/금지 반경 (m)", "25"); radius.addEventListener("change", () => callbacks.onRadiusChange(Number(radius.value) || 1)); const selectedActions = document.createElement("div"); selectedActions.className = "b05-route__actions"; selectedActions.append( button("위치 이동", callbacks.onMovePoint), button("삭제", callbacks.onDeletePoint, "danger"), ); selected.body.append(selectedName, radius.wrapper, selectedActions); // 드롭다운은 공통 컴포넌트(createSelectField) 재사용. `.select`로 받아 이하 로직 불변. const algorithmField = createSelectField({ label: "알고리즘", options: [ { value: "dijkstra", text: "Dijkstra" }, { value: "ridge_valley", text: "능선·계곡" }, ], }); const algorithm = algorithmField.select; // 임도 종류는 **프로젝트 등록(B02)에서 정한 값**을 그대로 쓴다 — 여기서 고치는 // 값이 아니다(2026-08-19 사용자 확정). 화면에는 읽기 전용으로 보여만 준다. const gradeField = createSelectField({ label: "임도 종류 (프로젝트 등록값)", options: [ { value: "trunk", text: ROAD_TYPE_LABELS.trunk }, { value: "fire", text: ROAD_TYPE_LABELS.fire }, { value: "work", text: ROAD_TYPE_LABELS.work }, { value: "branch", text: ROAD_TYPE_LABELS.branch }, ], }); const gradeClass = gradeField.select; gradeClass.disabled = true; gradeClass.title = "프로젝트 등록 정보의 임도 종류입니다. 변경은 대시보드에서 하세요."; // 설계속도 — 임도는 속도를 낼 수 없어 기본 20km/h다. 종류에 따라 선택지가 갈린다. const speedField = createSelectField({ label: "설계속도 (km/h)", options: [20, 30, 40].map((value) => ({ value: String(value), text: `${value} km/h` })), }); const designSpeed = speedField.select; designSpeed.value = "20"; designSpeed.title = "종단기울기·종단곡선의 법정 기준은 설계속도로 정해집니다(별표2).\n" + "간선·산불진화임도는 20~40, 작업임도는 20만 쓸 수 있습니다."; const minCurveRadius = numberField("최소 곡선반경 (m)"); const maxUphillGrade = numberField("오르막 경사 상한 (%)"); const maxDownhillGrade = numberField("내리막 경사 상한 (%)"); const minUphillGrade = numberField("오르막 경사 하한 (%)"); const minDownhillGrade = numberField("내리막 경사 하한 (%)"); const paved = checkbox("포장 임도", false); const avoidPass = checkbox("회피구역 통과 허용", false); const details = document.createElement("details"); const summary = document.createElement("summary"); summary.textContent = "사용한 조건"; details.append( summary, minCurveRadius.wrapper, maxUphillGrade.wrapper, maxDownhillGrade.wrapper, minUphillGrade.wrapper, minDownhillGrade.wrapper, ); const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); routeCalc.body.append( algorithmField.root, paved.wrapper, avoidPass.wrapper, details, solveButton, ); // 「페이지 설정」 — 등고선 간격·시작 측점 및 샘플링·종단 설계 기준 세 섹션 병합 // (2026-08-18 사용자 확정). 측점 간격 입력은 삭제 — 20m 고정. const sectionOptions = section("페이지 설정"); sectionOptions.root.classList.add("is-collapsed"); const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample")); const longSampleInterval = numberField(L("B05_Route_Field_LongSample")); // 샘플 간격 두 칸은 2열 한 행 — 종단이 먼저(2026-08-18 사용자 지시). const sampleRow = document.createElement("div"); sampleRow.className = "b05-route__field-row"; sampleRow.append(longSampleInterval.wrapper, crossSampleInterval.wrapper); sectionOptions.body.append(sampleRow); const terrainField = createSelectField({ label: "지형 구분", options: [ { value: "normal", text: "일반지형" }, { value: "special", text: "특수지형" }, ], }); const terrainType = terrainField.select; // 기본값 특수지형(2026-09-02 사용자 지시) — 임도 대상지는 대개 특수지형이라 // 매번 바꿔야 했다. 저장분이 있으면 `apply()`가 그 값으로 덮는다. terrainType.value = "special"; // 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로 // 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다. // 횡단배수 최소고 강제(2026-09-01 사용자 지시) — 기본 해제. 켜면 자동 계획선이 배관 // 자리를 시설 여유만큼 들어 올리고, 그 아래로 내리는 편집도 막힌다. 꺼도 부족분 경고는 // 종단 상단 표시줄에 계속 뜬다. const enforcePipeClearance = checkbox("횡단배수 최소고 강제", false); enforcePipeClearance.title = "켜면 계획선이 배수관·BOX암거·세월교 자리에서 관경(구체 높이)+토피 0.5m 만큼 들립니다.\n" + "끄면 들어 올리지 않고 부족분은 경고로만 알립니다(기본)."; const maxGradePct = numberField("최대 종단기울기 (%)"); const minVerticalRadius = numberField("종단곡선 최소 반경 (m)"); const minTangentLength = numberField("최소 직선 길이 (m)"); const startElevationOffset = numberField("시점 계획고 조정 (m)"); const endElevationOffset = numberField("종점 계획고 조정 (m)"); const criteriaNote = document.createElement("p"); criteriaNote.className = "b05-route__note"; const gradeAdvanced = document.createElement("details"); const gradeSummary = document.createElement("summary"); gradeSummary.textContent = "기준값 직접 지정"; gradeAdvanced.append( gradeSummary, maxGradePct.wrapper, minVerticalRadius.wrapper, minTangentLength.wrapper, startElevationOffset.wrapper, endElevationOffset.wrapper, ); // 병합 구획 구분선 — 샘플링 / 등고선 / 종단 설계 기준. const settingsDivider1 = document.createElement("hr"); settingsDivider1.className = "b05-structure__divider"; const settingsDivider2 = settingsDivider1.cloneNode() as HTMLHRElement; sectionOptions.body.append( settingsDivider1, contourRow, settingsDivider2, // 임도 종류(등록값) → 설계속도 → 지형 구분 순 — 법정 기준이 정해지는 순서다 // (2026-08-19 사용자 확정: 종류는 B02에서, 속도·지형은 여기서). gradeField.root, speedField.root, terrainField.root, criteriaNote, enforcePipeClearance.wrapper, gradeAdvanced, ); // 공사 시작 기준 — 이전 공사에 이어 시공할 때 0측점을 임의 측점/누가거리로 시작 표기한다. // (내부 chainage는 0기준 유지, 측점 라벨·누가거리 "표시"만 이 값만큼 이동.) 기본값 0/0. // 별도 "공사 시작점" 컨테이너를 없애고 "시작 측점 및 샘플링 설정" 최상단으로 일원화 // (2026-08-06 사용자 지시). const startStation = numberField("시작 측점", "0"); startStation.step = "1"; startStation.min = "0"; const startCumulative = numberField("시작 누가거리 (m)", "0"); // 2열×2행: 1행 항목명 · 2행 값 입력 / 1열 시작 측점 · 2열 시작 누가거리. const startRow = document.createElement("div"); startRow.className = "b05-route__field-row"; startRow.append(startStation.wrapper, startCumulative.wrapper); sectionOptions.body.prepend(startRow); const stationDisplayOffset = (): { station: number; cumulative: number } => ({ station: Math.max(0, Math.round(Number(startStation.value) || 0)), cumulative: Number(startCumulative.value) || 0, }); [startStation, startCumulative].forEach((input) => input.addEventListener("change", () => callbacks.onStationDisplayChange(stationDisplayOffset()), ), ); // 「구조물 배치」 — 구 비정규 측점 섹션을 흡수한 단일 섹션(2026-08-17 컨테이너 병합). // 타입 목록과 옵션 칸은 서버 레지스트리에서 받아 그리고, 계곡 통과 시설(A군)은 관 // 지점 정본과 연동한다. 위치 입출력은 측점번호+잔여거리 두 칸이다. const structures = createStructuresSection({ onChange: callbacks.onStructuresChange, onSelect: callbacks.onStructureSelect, getInterval: () => STATION_INTERVAL_M, onPipeAdd: callbacks.onPipeFacilityAdd, onPipeUpdate: callbacks.onPipeFacilityUpdate, onPipeRemove: callbacks.onPipeFacilityRemove, onPipeSelect: callbacks.onPipeFacilitySelect, onReveal: () => callbacks.onStructureReveal?.(), }); /** 지금 적용되는 종단기울기 상한(%) — 사용자가 값을 넣었으면 그 값, 비웠으면 * 등급·지형의 법정 기준. 종단 그래프의 위반 판정이 이 값을 즉시 따라온다 * (2026-08-19 사용자 지시 6). 특수지형+포장 예외(18%)는 서버가 최종 판정한다. */ function currentSpeed(): RoutePanelValues["designSpeed"] { const value = Number(designSpeed.value); return (value === 40 || value === 30 ? value : 20) as RoutePanelValues["designSpeed"]; } function gradeLimitPct(): number { const criteria = PROFILE_CRITERIA[currentSpeed()]; const terrain = terrainType.value as RoutePanelValues["terrainType"]; const typed = parseOptional(maxGradePct); return typed != null && typed > 0 ? typed : criteria.grade[terrain]; } /** 임도 종류에 맞춰 설계속도 선택지를 갈아 끼운다 — 작업임도는 20만 쓸 수 있다. */ function syncSpeedChoices(): void { const allowed = SPEED_CHOICES[gradeClass.value] ?? [20]; const keep = allowed.includes(currentSpeed()) ? currentSpeed() : allowed[0]; designSpeed.replaceChildren( ...allowed.map((value) => new Option(`${value} km/h`, String(value))), ); designSpeed.value = String(keep); // 고를 것이 하나뿐이면 잠근다 — 열어 두면 바꿀 수 있다는 오해를 준다. designSpeed.disabled = allowed.length < 2; } /** 설계속도·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */ function syncCriteria(): void { const speed = currentSpeed(); const criteria = PROFILE_CRITERIA[speed]; const terrain = terrainType.value as RoutePanelValues["terrainType"]; maxGradePct.placeholder = String(criteria.grade[terrain]); minVerticalRadius.placeholder = String(criteria.radius); minTangentLength.placeholder = "20"; const roadType = ROAD_TYPE_LABELS[gradeClass.value] ?? gradeClass.value; criteriaNote.textContent = `${roadType} · 설계속도 ${speed}km/h · ${terrain === "special" ? "특수지형" : "일반지형"} 기준 — ` + `종단기울기 ${criteria.grade[terrain]}% 이하, 종단곡선 반경 ${criteria.radius}m 이상. ` + "비워두면 이 기준이 적용됩니다."; } // 설계속도·지형뿐 아니라 기울기 세부 입력까지 — 바꾸는 즉시 안내문과 위반 기준이 // 따라온다(2026-08-19 사용자 지시 6). onInputChange가 그래프 쪽 갱신을 잇는다. [designSpeed, terrainType, maxGradePct].forEach((input) => input.addEventListener("change", syncCriteria), ); maxGradePct.addEventListener("input", syncCriteria); syncSpeedChoices(); syncCriteria(); // 하단 고정 액션 행: [초기화][임시저장][횡단 이동] — 경로 확정 개념 폐지, 종·횡 통합 // 확정은 B06에서 한다(2026-08-08 워크플로우 재정의). const resetButton = button(L("Common_Btn_Reset"), callbacks.onReset, "danger"); const tempSaveButton = button(L("B05_Route_Btn_TempSave"), callbacks.onTempSave); const goCrossButton = button(L("B05_Route_Btn_GoCross"), callbacks.onGoCross, "filled"); const actionRow = document.createElement("div"); actionRow.className = "b05-route__actions"; actionRow.append(resetButton, tempSaveButton, goCrossButton); // 사이드 최하단 고정 영역(2026-08-05 사용자 지시) — 위 구분선(dock 상단 테두리) → // 구조물 목록 → 중간 구분선 → 액션 버튼 순. 목록을 「구조물 배치」 본문에 두면 // 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시). const dockDivider = document.createElement("hr"); dockDivider.className = "b05-structure__divider"; const actionDock = document.createElement("div"); actionDock.className = "b05-route__dock ui-sidebar-actions"; actionDock.append(structures.listRoot, dockDivider, actionRow); const inputElements = [ algorithm, gradeClass, paved, avoidPass, minCurveRadius, maxUphillGrade, maxDownhillGrade, minUphillGrade, minDownhillGrade, crossSampleInterval, longSampleInterval, designSpeed, terrainType, maxGradePct, minVerticalRadius, minTangentLength, startElevationOffset, endElevationOffset, enforcePipeClearance, ]; inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange)); // 경로 계산 설정 — 사용여부 미결정으로 조작 불가(숨기지 않는다, 2026-08-18 사용자 // 지시). inert가 본문 전체 입력·드래그를 막고, is-disabled가 흐림을 그린다. // 제목 클릭(접기/펼치기)은 본문 밖이라 그대로 산다. routeCalc.root.classList.add("is-disabled"); routeCalc.body.inert = true; // 배치 순서: 구조물 배치 > 페이지 설정 > 경로 계산 설정(비활성) > 선택 포인트 // (평소 숨김) > 하단 고정 dock (2026-08-18 사용자 확정). root.append(structures.root, sectionOptions.root, routeCalc.root, selected.root, actionDock); // 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도 // 접힘 항목은 손대지 않는다. attachCollapsible(root); return { root, viewControls, /** 「구조물 배치」 섹션 API(타입 주입·목록 교체·계곡 시설 병합·선택·추가/이동/삭제). */ structures: structures as StructuresSection, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋) 현재값. */ stationDisplayOffset, /** 지금 적용되는 종단기울기 상한(%) — 그래프 위반 판정 즉시 반영용(2026-08-19). */ gradeLimitPct, /** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */ setCorridorPending(pending: boolean) { corridorRefresh.classList.toggle("is-pending", pending); }, values(): RoutePanelValues { return { contourInterval: Number(contourInterval.value) || 1, algorithm: algorithm.value as RoutePanelValues["algorithm"], gradeClass: gradeClass.value as RoutePanelValues["gradeClass"], paved: paved.checked, minCurveRadius: parseOptional(minCurveRadius), maxUphillGrade: parseOptional(maxUphillGrade), maxDownhillGrade: parseOptional(maxDownhillGrade), minUphillGrade: parseOptional(minUphillGrade), minDownhillGrade: parseOptional(minDownhillGrade), allowAvoidPassThrough: avoidPass.checked, stationInterval: STATION_INTERVAL_M, crossSampleInterval: parseOptional(crossSampleInterval), longSampleInterval: parseOptional(longSampleInterval), designSpeed: currentSpeed(), terrainType: terrainType.value as RoutePanelValues["terrainType"], maxGradePct: parseOptional(maxGradePct), minVerticalRadius: parseOptional(minVerticalRadius), minTangentLength: parseOptional(minTangentLength), startElevationOffset: parseOptional(startElevationOffset), endElevationOffset: parseOptional(endElevationOffset), enforcePipeClearance: enforcePipeClearance.checked, }; }, restore(values: Partial) { if (values.contourInterval != null) contourInterval.value = String(values.contourInterval); if (values.algorithm) algorithm.value = values.algorithm; if (values.gradeClass) gradeClass.value = values.gradeClass; if (values.paved != null) paved.checked = values.paved; if (values.minCurveRadius != null) minCurveRadius.value = String(values.minCurveRadius); if (values.maxUphillGrade != null) maxUphillGrade.value = String(values.maxUphillGrade); if (values.maxDownhillGrade != null) maxDownhillGrade.value = String(values.maxDownhillGrade); if (values.minUphillGrade != null) minUphillGrade.value = String(values.minUphillGrade); if (values.minDownhillGrade != null) minDownhillGrade.value = String(values.minDownhillGrade); if (values.allowAvoidPassThrough != null) avoidPass.checked = values.allowAvoidPassThrough; // stationInterval은 복원하지 않는다 — 20m 고정(2026-08-18). if (values.crossSampleInterval != null) crossSampleInterval.value = String(values.crossSampleInterval); if (values.longSampleInterval != null) longSampleInterval.value = String(values.longSampleInterval); if (values.gradeClass) { gradeClass.value = values.gradeClass; syncSpeedChoices(); } if (values.designSpeed) designSpeed.value = String(values.designSpeed); if (values.terrainType) terrainType.value = values.terrainType; if (values.maxGradePct != null) maxGradePct.value = String(values.maxGradePct); if (values.minVerticalRadius != null) minVerticalRadius.value = String(values.minVerticalRadius); if (values.minTangentLength != null) minTangentLength.value = String(values.minTangentLength); if (values.startElevationOffset != null) startElevationOffset.value = String(values.startElevationOffset); if (values.endElevationOffset != null) endElevationOffset.value = String(values.endElevationOffset); if (values.enforcePipeClearance != null) enforcePipeClearance.checked = values.enforcePipeClearance; syncCriteria(); }, setSelected(point: PlacedRoutePoint | null) { selected.root.hidden = !point; if (!point) return; selectedName.textContent = `${point.type.toUpperCase()} (${point.x.toFixed(2)}, ${point.y.toFixed(2)})`; radius.wrapper.hidden = point.type !== "ap" && point.type !== "fp"; radius.value = String(point.radius_m ?? 25); }, }; } export type RoutePanel = ReturnType;