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"; gradeClass: "trunk" | "branch" | "work"; 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가 단일 소스이며 여기서는 안내만 한다. */ const PROFILE_CRITERIA: Record< RoutePanelValues["gradeClass"], { speed: number; grade: Record; radius: number } > = { trunk: { speed: 40, grade: { normal: 7, special: 10 }, radius: 450 }, branch: { speed: 30, grade: { normal: 8, special: 12 }, radius: 250 }, work: { speed: 20, grade: { normal: 9, special: 14 }, radius: 100 }, }; 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; onPipeFacilitySelect: (chainageM: number) => 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"), ); const contour = section("등고선 간격"); contour.root.classList.add("is-collapsed"); 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)), ); contour.body.append(contourRow); // 포인트 팔레트 + 임도 기준·옵션을 한 컨테이너로 병합하고 [최적 경로 계산]도 이 안에 // 둔다 — 경로 재탐색은 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; const gradeField = createSelectField({ label: "임도 등급", options: [ { value: "trunk", text: "간선" }, { value: "branch", text: "지선" }, { value: "work", text: "작업" }, ], }); const gradeClass = gradeField.select; 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, gradeField.root, paved.wrapper, avoidPass.wrapper, details, solveButton, ); const sectionOptions = section(L("B05_Route_Group_SectionOptions")); sectionOptions.root.classList.add("is-collapsed"); const stationInterval = numberField(L("B05_Route_Field_StationInterval")); const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample")); const longSampleInterval = numberField(L("B05_Route_Field_LongSample")); sectionOptions.body.append( stationInterval.wrapper, crossSampleInterval.wrapper, longSampleInterval.wrapper, ); // 종단 설계 기준은 한 번 정하면 자주 손대지 않는다 — 기본은 접힌 상태 // (2026-08-17 사용자 지시). const gradeLine = section("종단 설계 기준", true); 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, ); gradeLine.body.append(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: () => Number(stationInterval.value) || 20, onPipeAdd: callbacks.onPipeFacilityAdd, onPipeUpdate: callbacks.onPipeFacilityUpdate, onPipeRemove: callbacks.onPipeFacilityRemove, onPipeSelect: callbacks.onPipeFacilitySelect, }); /** 등급·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */ function syncCriteria(): void { const criteria = PROFILE_CRITERIA[gradeClass.value as RoutePanelValues["gradeClass"]]; const terrain = terrainType.value as RoutePanelValues["terrainType"]; maxGradePct.placeholder = String(criteria.grade[terrain]); minVerticalRadius.placeholder = String(criteria.radius); minTangentLength.placeholder = "20"; criteriaNote.textContent = `설계속도 ${criteria.speed}km/h 기준 — 종단기울기 ${criteria.grade[terrain]}% 이하, ` + `종단곡선 반경 ${criteria.radius}m 이상. 비워두면 이 기준이 적용됩니다.`; } gradeClass.addEventListener("change", syncCriteria); terrainType.addEventListener("change", syncCriteria); 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, stationInterval, crossSampleInterval, longSampleInterval, terrainType, maxGradePct, minVerticalRadius, minTangentLength, startElevationOffset, endElevationOffset, ]; inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange)); root.append( gradeLine.root, contour.root, sectionOptions.root, structures.root, routeCalc.root, selected.root, actionDock, ); // 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도 // 접힘 항목은 손대지 않는다. attachCollapsible(root); return { root, viewControls, /** 「구조물 배치」 섹션 API(타입 주입·목록 교체·계곡 시설 병합·선택·추가/이동/삭제). */ structures: structures as StructuresSection, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋) 현재값. */ stationDisplayOffset, 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: parseOptional(stationInterval), crossSampleInterval: parseOptional(crossSampleInterval), longSampleInterval: parseOptional(longSampleInterval), 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; if (values.stationInterval != null) stationInterval.value = String(values.stationInterval); if (values.crossSampleInterval != null) crossSampleInterval.value = String(values.crossSampleInterval); if (values.longSampleInterval != null) longSampleInterval.value = String(values.longSampleInterval); 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;