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; } /** * 「임도설치 및 관리 등에 관한 규정」[별표 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; onSurfaceVisible: (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; onResetView: () => void; onMovePoint: () => void; onDeletePoint: () => void; onRadiusChange: (radius: number) => void; onInputChange: () => void; /** 구조물 목록(구조물군 B~G)이 바뀔 때 — 정본 저장·그래프 반영은 Page가 맡는다. */ onStructuresChange: (structures: StructureInstance[]) => void; /** 구조물을 목록에서 선택/해제할 때 해당 구조물(또는 null). */ onStructureSelect: (structure: StructureInstance | null) => 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")), ); const visibilityButtons = document.createElement("div"); visibilityButtons.className = "b05-route__view-group"; visibilityButtons.append( toggleButton("지표면", true, callbacks.onSurfaceVisible), 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 사용자 요청). toggleButton("흑백 지형", false, callbacks.onSurfaceGrayscale), ); const separator1 = document.createElement("span"); separator1.className = "b05-route__view-separator"; const separator2 = separator1.cloneNode() as HTMLSpanElement; viewControls.append( viewButtons, separator1, visibilityButtons, separator2, button("뷰 초기화", callbacks.onResetView, "glass"), ); // 등고선 간격 행 — 「페이지 설정」 섹션에 들어간다(2026-08-18 컨테이너 병합). const contourInterval = numberField("간격 (m), 최소 0.5m", "1"); const contourRow = document.createElement("div"); contourRow.className = "b05-route__contour-row"; contourRow.append( contourInterval.wrapper, button("재적용", () => callbacks.onContourApply(Number(contourInterval.value) || 1)), ); // 포인트 팔레트 + 임도 기준·옵션을 한 컨테이너로 병합하고 [최적 경로 계산]도 이 안에 // 둔다 — 경로 재탐색은 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; // 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로 // 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다. 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, 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, }); /** 지금 적용되는 종단기울기 상한(%) — 사용자가 값을 넣었으면 그 값, 비웠으면 * 등급·지형의 법정 기준. 종단 그래프의 위반 판정이 이 값을 즉시 따라온다 * (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, ]; 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, 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), }; }, 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); 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;