import type { PlacedRoutePoint, RoutePointKind } from "./B05_wf2_Route_UI_Markers"; import { type ButtonVariant, createButton } from "@ui/ui_template_elements"; 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; } interface PanelCallbacks { onSolve: () => void; onConfirm: () => void; onContourApply: (interval: number) => void; onSurfaceVisible: (visible: boolean) => void; onContoursVisible: (visible: boolean) => void; onAxesVisible: (visible: boolean) => void; onStationLinesVisible: (visible: boolean) => void; onView: (view: "iso" | "top" | "front" | "side") => void; onResetView: () => void; onMovePoint: () => void; onDeletePoint: () => void; onRadiusChange: (radius: number) => void; onInputChange: () => void; } type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement }; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } function section(title: string): { root: HTMLElement; body: HTMLElement } { const root = document.createElement("section"); root.className = "b05-route__panel-section"; const heading = document.createElement("h3"); 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), ); 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("등고선 간격"); 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); const palette = section("포인트 팔레트"); 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); }); palette.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); const conditions = section("임도 기준·옵션"); const algorithm = document.createElement("select"); algorithm.innerHTML = ''; const gradeClass = document.createElement("select"); gradeClass.innerHTML = ''; const algorithmLabel = document.createElement("label"); algorithmLabel.className = "b05-route__field"; algorithmLabel.append(document.createTextNode("알고리즘"), algorithm); const gradeLabel = document.createElement("label"); gradeLabel.className = "b05-route__field"; gradeLabel.append(document.createTextNode("임도 등급"), gradeClass); 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 help = document.createElement("details"); help.innerHTML = "최적경로란?

배치 포인트와 경사·곡선·회피 조건을 만족하며 비용을 최소화한 경로입니다.

"; conditions.body.append( algorithmLabel, gradeLabel, paved.wrapper, avoidPass.wrapper, details, help, ); const sectionOptions = section(L("B05_Route_Group_SectionOptions")); 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, ); const result = section("경로 설계 산출 결과"); const stale = document.createElement("span"); stale.className = "b05-route__stale"; stale.textContent = "재탐색 필요"; stale.hidden = true; const metrics = document.createElement("div"); metrics.className = "b05-route__metrics"; metrics.textContent = "경로를 계산하면 결과가 표시됩니다."; result.body.append(stale, metrics); const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); const confirmButton = button("경로 확정", callbacks.onConfirm); confirmButton.disabled = true; const actionRow = document.createElement("div"); actionRow.className = "b05-route__actions"; actionRow.append(solveButton, confirmButton); const inputElements = [ algorithm, gradeClass, paved, avoidPass, minCurveRadius, maxUphillGrade, maxDownhillGrade, minUphillGrade, minDownhillGrade, stationInterval, crossSampleInterval, longSampleInterval, ]; inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange)); root.append( contour.root, palette.root, selected.root, conditions.root, sectionOptions.root, result.root, actionRow, ); return { root, viewControls, 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), }; }, 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); }, 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); }, setStale(value: boolean) { stale.hidden = !value; confirmButton.disabled = value; }, setCanConfirm(value: boolean) { confirmButton.disabled = !value; }, renderMetrics(values: Record) { const rows = [ ["총 연장", values.length_m], ["평균 경사", values.avg_grade_pct ?? values.mean_slope], ["최대 경사", values.max_grade_pct ?? values.max_slope], ["비용", values.cost_score], ["경사 초과", values.slope_violations], ["곡선반경 미달", values.curve_violations], ]; metrics.replaceChildren( ...rows.map(([label, value]) => { const row = document.createElement("div"); row.textContent = `${label}: ${value ?? "-"}`; return row; }), ); }, }; } export type RoutePanel = ReturnType;