769 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			HTML
		
	
	
	
	
	
		
		
			
		
	
	
			769 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			HTML
		
	
	
	
	
	
|   | <!DOCTYPE html> | |||
|  | <html lang="zh-CN"> | |||
|  | <head> | |||
|  |     <meta charset="UTF-8"> | |||
|  |     <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no"> | |||
|  |     <title>路径规划系统</title> | |||
|  |     <!-- 仅保留必要CDN --> | |||
|  |     <script src="https://cdn.tailwindcss.com"></script> | |||
|  |     <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet"> | |||
|  |     <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/> | |||
|  |     <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script> | |||
|  |     <script src="https://cdn.jsdelivr.net/npm/axios@1.6.8/dist/axios.min.js"></script> | |||
|  | 
 | |||
|  |     <!-- Tailwind配置 --> | |||
|  |     <script> | |||
|  |         tailwind.config = { | |||
|  |             theme: { | |||
|  |                 extend: { | |||
|  |                     colors: { | |||
|  |                         primary: '#165DFF', | |||
|  |                         success: '#00B42A', | |||
|  |                         danger: '#F53F3F', | |||
|  |                         warning: '#FF7D00', | |||
|  |                         neutral: '#F5F7FA', | |||
|  |                         'neutral-dark': '#4E5969', | |||
|  |                     }, | |||
|  |                     fontFamily: { | |||
|  |                         inter: ['Inter', 'system-ui', 'sans-serif'], | |||
|  |                     }, | |||
|  |                 } | |||
|  |             } | |||
|  |         } | |||
|  |     </script> | |||
|  | 
 | |||
|  |     <!-- 自定义工具类 --> | |||
|  |     <style type="text/tailwindcss"> | |||
|  |         @layer utilities { | |||
|  |             .content-auto { | |||
|  |                 content-visibility: auto; | |||
|  |             } | |||
|  |             .map-height { | |||
|  |                 height: 100vh; /* 调整为占满视口高度 */ | |||
|  |             } | |||
|  |             .sidebar-height { | |||
|  |                 height: 100vh; /* 调整为占满视口高度 */ | |||
|  |             } | |||
|  |             .scrollbar-hide { | |||
|  |                 -ms-overflow-style: none; | |||
|  |                 scrollbar-width: none; | |||
|  |             } | |||
|  |             .scrollbar-hide::-webkit-scrollbar { | |||
|  |                 display: none; | |||
|  |             } | |||
|  |             .custom-marker .fa { | |||
|  |                 font-size: 14px; | |||
|  |             } | |||
|  |             .input-error { | |||
|  |                 @apply border-danger focus:ring-danger/50 focus:border-danger; | |||
|  |             } | |||
|  |             .btn-disabled { | |||
|  |                 @apply bg-gray-300 text-gray-500 cursor-not-allowed hover:bg-gray-300; | |||
|  |             } | |||
|  |         } | |||
|  |     </style> | |||
|  | </head> | |||
|  | <body class="font-inter bg-gray-50 text-gray-800 antialiased m-0"> | |||
|  | <!-- 主内容区(直接顶到顶部、删除原header) --> | |||
|  | <main class="flex flex-col md:flex-row"> | |||
|  |     <!-- 左侧控制面板 --> | |||
|  |     <aside class="w-full md:w-96 bg-white shadow-sm z-10 md:sidebar-height overflow-y-auto scrollbar-hide transition-all"> | |||
|  |         <div class="p-4 space-y-6"> | |||
|  |             <!-- 地图加载区域 --> | |||
|  |             <div class="p-4 border border-gray-100 rounded-lg bg-neutral shadow-sm"> | |||
|  |                 <h2 class="text-lg font-semibold mb-3 flex items-center text-neutral-dark"> | |||
|  |                     <i class="fa fa-map text-primary mr-2"></i>地图管理 | |||
|  |                 </h2> | |||
|  |                 <div class="flex items-center space-x-2 mb-2"> | |||
|  |                     <input type="file" id="mapFile" accept=".pbf" class="hidden" multiple="false"/> | |||
|  |                     <button id="selectFileBtn" | |||
|  |                             class="flex-1 px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded text-sm font-medium transition-colors duration-200"> | |||
|  |                         选择PBF文件 | |||
|  |                     </button> | |||
|  |                     <button id="loadMapBtn" | |||
|  |                             class="px-4 py-2 bg-primary hover:bg-primary/90 text-white rounded text-sm font-medium transition-colors duration-200"> | |||
|  |                         加载地图 | |||
|  |                     </button> | |||
|  |                 </div> | |||
|  |                 <div id="fileInfo" class="mt-1 text-sm hidden"></div> | |||
|  |                 <div id="loadProgress" class="mt-1 text-sm text-primary hidden flex items-center"> | |||
|  |                     <i class="fa fa-spinner fa-spin mr-1.5"></i> | |||
|  |                     <span id="progressText">正在处理...</span> | |||
|  |                 </div> | |||
|  |             </div> | |||
|  | 
 | |||
|  |             <!-- 路径规划参数 --> | |||
|  |             <div class="space-y-4"> | |||
|  |                 <h2 class="text-lg font-semibold flex items-center text-neutral-dark"> | |||
|  |                     <i class="fa fa-road text-primary mr-2"></i>路径参数 | |||
|  |                 </h2> | |||
|  | 
 | |||
|  |                 <!-- 起点:移除默认value、默认无数据 --> | |||
|  |                 <div class="space-y-1"> | |||
|  |                     <label class="block text-sm font-medium text-gray-700">起点 <span class="text-danger">*</span></label> | |||
|  |                     <div class="flex space-x-2"> | |||
|  |                         <div class="flex-1 space-y-0.5"> | |||
|  |                             <input type="text" id="startLat" placeholder="纬度" | |||
|  |                                    class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm transition-all" | |||
|  |                                    maxlength="10"> | |||
|  |                             <span id="startLatError" class="text-danger text-xs hidden">请输入有效纬度</span> | |||
|  |                         </div> | |||
|  |                         <div class="flex-1 space-y-0.5"> | |||
|  |                             <input type="text" id="startLng" placeholder="经度" | |||
|  |                                    class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm transition-all" | |||
|  |                                    maxlength="10"> | |||
|  |                             <span id="startLngError" class="text-danger text-xs hidden">请输入有效经度</span> | |||
|  |                         </div> | |||
|  |                         <button id="setStartBtn" class="p-2 bg-gray-100 hover:bg-gray-200 rounded transition-colors duration-200" | |||
|  |                                 title="在地图上选择起点"> | |||
|  |                             <i class="fa fa-map-marker text-danger"></i> | |||
|  |                         </button> | |||
|  |                     </div> | |||
|  |                 </div> | |||
|  | 
 | |||
|  |                 <!-- 终点 --> | |||
|  |                 <div class="space-y-1"> | |||
|  |                     <label class="block text-sm font-medium text-gray-700">终点 <span class="text-danger">*</span></label> | |||
|  |                     <div class="flex space-x-2"> | |||
|  |                         <div class="flex-1 space-y-0.5"> | |||
|  |                             <input type="text" id="endLat" placeholder="纬度" | |||
|  |                                    class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm transition-all" | |||
|  |                                    maxlength="10"> | |||
|  |                             <span id="endLatError" class="text-danger text-xs hidden">请输入有效纬度</span> | |||
|  |                         </div> | |||
|  |                         <div class="flex-1 space-y-0.5"> | |||
|  |                             <input type="text" id="endLng" placeholder="经度" | |||
|  |                                    class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm transition-all" | |||
|  |                                    maxlength="10"> | |||
|  |                             <span id="endLngError" class="text-danger text-xs hidden">请输入有效经度</span> | |||
|  |                         </div> | |||
|  |                         <button id="setEndBtn" class="p-2 bg-gray-100 hover:bg-gray-200 rounded transition-colors duration-200" | |||
|  |                                 title="在地图上选择终点"> | |||
|  |                             <i class="fa fa-flag text-success"></i> | |||
|  |                         </button> | |||
|  |                     </div> | |||
|  |                 </div> | |||
|  | 
 | |||
|  |                 <!-- 途经点 --> | |||
|  |                 <div class="space-y-1"> | |||
|  |                     <div class="flex justify-between items-center"> | |||
|  |                         <label class="block text-sm font-medium text-gray-700">途经点</label> | |||
|  |                         <button id="addWaypointBtn" | |||
|  |                                 class="text-xs px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded transition-colors duration-200 flex items-center"> | |||
|  |                             <i class="fa fa-plus mr-1"></i>添加 | |||
|  |                         </button> | |||
|  |                     </div> | |||
|  |                     <div id="waypointsContainer" class="space-y-2"> | |||
|  |                         <!-- 途经点动态添加 --> | |||
|  |                     </div> | |||
|  |                 </div> | |||
|  | 
 | |||
|  |                 <!-- 交通方式 --> | |||
|  |                 <div class="space-y-1"> | |||
|  |                     <label class="block text-sm font-medium text-gray-700">交通方式 <span class="text-danger">*</span></label> | |||
|  |                     <select id="profile" | |||
|  |                             class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm transition-all bg-white"> | |||
|  |                         <option value="car">驾车</option> | |||
|  |                         <option value="bike">骑行</option> | |||
|  |                         <option value="foot">步行</option> | |||
|  |                     </select> | |||
|  |                 </div> | |||
|  | 
 | |||
|  |                 <!-- 计算路径按钮 --> | |||
|  |                 <button id="calculateRouteBtn" | |||
|  |                         class="w-full py-2.5 bg-primary hover:bg-primary/90 text-white rounded-md font-medium transition-colors duration-200 flex items-center justify-center btn-disabled" | |||
|  |                         disabled> | |||
|  |                     <i class="fa fa-calculator mr-2"></i>请先加载地图 | |||
|  |                 </button> | |||
|  |             </div> | |||
|  | 
 | |||
|  |             <!-- 结果展示区域 --> | |||
|  |             <div id="resultContainer" class="p-4 border border-gray-100 rounded-lg bg-neutral shadow-sm hidden"> | |||
|  |                 <h2 class="text-lg font-semibold mb-3 flex items-center text-neutral-dark"> | |||
|  |                     <i class="fa fa-check-circle text-success mr-2"></i>路径结果 | |||
|  |                 </h2> | |||
|  | 
 | |||
|  |                 <div class="grid grid-cols-2 gap-4 mb-4"> | |||
|  |                     <div class="bg-white p-3 rounded shadow-sm border border-gray-100 hover:shadow-md transition-shadow"> | |||
|  |                         <div class="text-xs text-gray-500 mb-1">总距离</div> | |||
|  |                         <div id="distanceResult" class="text-lg font-semibold text-gray-800">-</div> | |||
|  |                     </div> | |||
|  |                     <div class="bg-white p-3 rounded shadow-sm border border-gray-100 hover:shadow-md transition-shadow"> | |||
|  |                         <div class="text-xs text-gray-500 mb-1">预计时间</div> | |||
|  |                         <div id="timeResult" class="text-lg font-semibold text-gray-800">-</div> | |||
|  |                     </div> | |||
|  |                 </div> | |||
|  | 
 | |||
|  |                 <div class="grid grid-cols-2 gap-2"> | |||
|  |                     <button id="clearRouteBtn" | |||
|  |                             class="py-2 border border-gray-300 bg-white hover:bg-gray-50 text-gray-700 rounded-md text-sm font-medium transition-colors duration-200"> | |||
|  |                         <i class="fa fa-trash mr-1"></i>清除路径 | |||
|  |                     </button> | |||
|  |                     <button id="clearAllBtn" | |||
|  |                             class="py-2 border border-gray-300 bg-white hover:bg-gray-50 text-gray-700 rounded-md text-sm font-medium transition-colors duration-200"> | |||
|  |                         <i class="fa fa-refresh mr-1"></i>清空所有 | |||
|  |                     </button> | |||
|  |                 </div> | |||
|  |             </div> | |||
|  |         </div> | |||
|  |     </aside> | |||
|  | 
 | |||
|  |     <!-- 右侧地图区域 --> | |||
|  |     <section class="flex-1 relative"> | |||
|  |         <div id="map" class="w-full map-height z-0"></div> | |||
|  | 
 | |||
|  |         <!-- 地图控件 --> | |||
|  |         <div class="absolute top-4 right-4 z-10 flex flex-col space-y-2"> | |||
|  |             <button id="zoomInBtn" | |||
|  |                     class="w-10 h-10 bg-white rounded-full shadow-md flex items-center justify-center hover:bg-gray-100 transition-colors duration-200" | |||
|  |                     title="放大"> | |||
|  |                 <i class="fa fa-plus"></i> | |||
|  |             </button> | |||
|  |             <button id="zoomOutBtn" | |||
|  |                     class="w-10 h-10 bg-white rounded-full shadow-md flex items-center justify-center hover:bg-gray-100 transition-colors duration-200" | |||
|  |                     title="缩小"> | |||
|  |                 <i class="fa fa-minus"></i> | |||
|  |             </button> | |||
|  |             <button id="centerMapBtn" | |||
|  |                     class="w-10 h-10 bg-white rounded-full shadow-md flex items-center justify-center hover:bg-gray-100 transition-colors duration-200" | |||
|  |                     title="重置中心(成都)"> | |||
|  |                 <i class="fa fa-crosshairs"></i> | |||
|  |             </button> | |||
|  |         </div> | |||
|  | 
 | |||
|  |         <!-- 地图操作提示(移动端) --> | |||
|  |         <div class="absolute bottom-4 left-4 z-10 md:hidden bg-white/90 px-3 py-2 rounded-full text-xs text-neutral-dark shadow-md"> | |||
|  |             <i class="fa fa-hand-pointer-o text-primary mr-1"></i>点击地图设起点/终点 | |||
|  |         </div> | |||
|  |     </section> | |||
|  | </main> | |||
|  | 
 | |||
|  | <script> | |||
|  |     // 大整数转字符串处理(保留原逻辑) | |||
|  |     axios.defaults.transformResponse = [ | |||
|  |         function(data) { | |||
|  |             if (typeof data !== 'string') return data; | |||
|  |             const bigIntRegex = /(\s*"[^"]*"\s*:\s*)(\d{16,})(\s*)/g; | |||
|  |             return data.replace(bigIntRegex, (match, keyPart, bigInt, endPart) => { | |||
|  |                 return `${keyPart}"${bigInt}"${endPart}`; | |||
|  |             }); | |||
|  |         }, | |||
|  |         function(parsedData) { | |||
|  |             try { | |||
|  |                 return JSON.parse(parsedData); | |||
|  |             } catch (e) { | |||
|  |                 return parsedData; | |||
|  |             } | |||
|  |         } | |||
|  |     ]; | |||
|  | 
 | |||
|  |     // 全局变量 | |||
|  |     let map; | |||
|  |     let startMarker = null; | |||
|  |     let endMarker = null; | |||
|  |     let waypointMarkers = []; | |||
|  |     let routeLine = null; | |||
|  |     let waypointCount = 0; | |||
|  |     const API_BASE_URL = "http://127.0.0.1:8848"; | |||
|  |     const DEFAULT_CENTER = { lat: 30.6570, lng: 104.0650 }; // 成都默认坐标 | |||
|  | 
 | |||
|  |     // 地图初始化 | |||
|  |     function initMap() { | |||
|  |         map = L.map('map', { | |||
|  |             zoomControl: false, | |||
|  |             attributionControl: true, | |||
|  |             minZoom: 5, | |||
|  |             maxZoom: 18 | |||
|  |         }).setView([DEFAULT_CENTER.lat, DEFAULT_CENTER.lng], 12); | |||
|  | 
 | |||
|  |         L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { | |||
|  |             maxZoom: 18, | |||
|  |             tileSize: 256, | |||
|  |             zoomOffset: 0 | |||
|  |         }).addTo(map); | |||
|  | 
 | |||
|  |         map.on('click', handleMapClick); | |||
|  |     } | |||
|  | 
 | |||
|  |     // 表单验证 | |||
|  |     function validateCoord(value, type) { | |||
|  |         const num = parseFloat(value); | |||
|  |         if (isNaN(num)) return false; | |||
|  |         return type === 'lat' ? (num >= -90 && num <= 90) : (num >= -180 && num <= 180); | |||
|  |     } | |||
|  | 
 | |||
|  |     function toggleInputError(inputId, errorId, show, inputEl = null, errorEl = null) { | |||
|  |         inputEl = inputEl || document.getElementById(inputId); | |||
|  |         errorEl = errorEl || document.getElementById(errorId); | |||
|  | 
 | |||
|  |         if (show) { | |||
|  |             inputEl.classList.add('input-error'); | |||
|  |             errorEl.classList.remove('hidden'); | |||
|  |         } else { | |||
|  |             inputEl.classList.remove('input-error'); | |||
|  |             errorEl.classList.add('hidden'); | |||
|  |         } | |||
|  |     } | |||
|  | 
 | |||
|  |     // 绑定坐标验证 | |||
|  |     function bindCoordValidation() { | |||
|  |         // 起点验证 | |||
|  |         document.getElementById('startLat').addEventListener('input', (e) => { | |||
|  |             const isValid = validateCoord(e.target.value, 'lat'); | |||
|  |             toggleInputError('startLat', 'startLatError', !isValid); | |||
|  |         }); | |||
|  |         document.getElementById('startLng').addEventListener('input', (e) => { | |||
|  |             const isValid = validateCoord(e.target.value, 'lng'); | |||
|  |             toggleInputError('startLng', 'startLngError', !isValid); | |||
|  |         }); | |||
|  | 
 | |||
|  |         // 终点验证 | |||
|  |         document.getElementById('endLat').addEventListener('input', (e) => { | |||
|  |             const isValid = validateCoord(e.target.value, 'lat'); | |||
|  |             toggleInputError('endLat', 'endLatError', !isValid); | |||
|  |         }); | |||
|  |         document.getElementById('endLng').addEventListener('input', (e) => { | |||
|  |             const isValid = validateCoord(e.target.value, 'lng'); | |||
|  |             toggleInputError('endLng', 'endLngError', !isValid); | |||
|  |         }); | |||
|  |     } | |||
|  | 
 | |||
|  |     // 标记管理 | |||
|  |     function setStartPoint(lat, lng) { | |||
|  |         const latEl = document.getElementById('startLat'); | |||
|  |         const lngEl = document.getElementById('startLng'); | |||
|  | 
 | |||
|  |         latEl.value = lat.toFixed(6); | |||
|  |         lngEl.value = lng.toFixed(6); | |||
|  |         latEl.dispatchEvent(new Event('input')); | |||
|  |         lngEl.dispatchEvent(new Event('input')); | |||
|  | 
 | |||
|  |         if (startMarker) { | |||
|  |             startMarker.setLatLng([lat, lng]); | |||
|  |         } else { | |||
|  |             startMarker = L.marker([lat, lng], { | |||
|  |                 icon: L.divIcon({ | |||
|  |                     className: 'custom-marker', | |||
|  |                     html: '<div class="w-6 h-6 bg-danger rounded-full flex items-center justify-center text-white shadow-md"><i class="fa fa-map-marker"></i></div>', | |||
|  |                     iconSize: [30, 30], | |||
|  |                     iconAnchor: [15, 30] | |||
|  |                 }), | |||
|  |                 draggable: true, | |||
|  |                 riseOnHover: true | |||
|  |             }).addTo(map); | |||
|  | 
 | |||
|  |             startMarker.on('dragend', (e) => { | |||
|  |                 const { lat, lng } = e.target.getLatLng(); | |||
|  |                 setStartPoint(lat, lng); | |||
|  |             }); | |||
|  |         } | |||
|  |     } | |||
|  | 
 | |||
|  |     function setEndPoint(lat, lng) { | |||
|  |         const latEl = document.getElementById('endLat'); | |||
|  |         const lngEl = document.getElementById('endLng'); | |||
|  | 
 | |||
|  |         latEl.value = lat.toFixed(6); | |||
|  |         lngEl.value = lng.toFixed(6); | |||
|  |         latEl.dispatchEvent(new Event('input')); | |||
|  |         lngEl.dispatchEvent(new Event('input')); | |||
|  | 
 | |||
|  |         if (endMarker) { | |||
|  |             endMarker.setLatLng([lat, lng]); | |||
|  |         } else { | |||
|  |             endMarker = L.marker([lat, lng], { | |||
|  |                 icon: L.divIcon({ | |||
|  |                     className: 'custom-marker', | |||
|  |                     html: '<div class="w-6 h-6 bg-success rounded-full flex items-center justify-center text-white shadow-md"><i class="fa fa-flag"></i></div>', | |||
|  |                     iconSize: [30, 30], | |||
|  |                     iconAnchor: [15, 30] | |||
|  |                 }), | |||
|  |                 draggable: true, | |||
|  |                 riseOnHover: true | |||
|  |             }).addTo(map); | |||
|  | 
 | |||
|  |             endMarker.on('dragend', (e) => { | |||
|  |                 const { lat, lng } = e.target.getLatLng(); | |||
|  |                 setEndPoint(lat, lng); | |||
|  |             }); | |||
|  |         } | |||
|  |     } | |||
|  | 
 | |||
|  |     function addWaypoint(lat = '', lng = '') { | |||
|  |         waypointCount++; | |||
|  |         const container = document.getElementById('waypointsContainer'); | |||
|  |         const waypointDiv = document.createElement('div'); | |||
|  |         waypointDiv.className = 'flex space-x-2 waypoint-item'; | |||
|  |         waypointDiv.dataset.id = waypointCount; | |||
|  | 
 | |||
|  |         waypointDiv.innerHTML = ` | |||
|  |             <div class="flex-1 space-y-0.5"> | |||
|  |                 <input type="text" class="waypoint-lat w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm transition-all" | |||
|  |                        placeholder="纬度" value="${lat}" maxlength="10"> | |||
|  |                 <span class="waypoint-lat-error text-danger text-xs hidden">请输入有效纬度</span> | |||
|  |             </div> | |||
|  |             <div class="flex-1 space-y-0.5"> | |||
|  |                 <input type="text" class="waypoint-lng w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm transition-all" | |||
|  |                        placeholder="经度" value="${lng}" maxlength="10"> | |||
|  |                 <span class="waypoint-lng-error text-danger text-xs hidden">请输入有效经度</span> | |||
|  |             </div> | |||
|  |             <button class="set-waypoint-btn p-2 bg-gray-100 hover:bg-gray-200 rounded transition-colors duration-200" title="在地图上选择"> | |||
|  |                 <i class="fa fa-map-pin text-primary"></i> | |||
|  |             </button> | |||
|  |             <button class="remove-waypoint-btn p-2 bg-gray-100 hover:bg-gray-200 rounded transition-colors duration-200" title="删除途经点"> | |||
|  |                 <i class="fa fa-times text-gray-500"></i> | |||
|  |             </button> | |||
|  |         `; | |||
|  | 
 | |||
|  |         container.appendChild(waypointDiv); | |||
|  | 
 | |||
|  |         const latInput = waypointDiv.querySelector('.waypoint-lat'); | |||
|  |         const lngInput = waypointDiv.querySelector('.waypoint-lng'); | |||
|  |         const latError = waypointDiv.querySelector('.waypoint-lat-error'); | |||
|  |         const lngError = waypointDiv.querySelector('.waypoint-lng-error'); | |||
|  | 
 | |||
|  |         latInput.addEventListener('input', (e) => { | |||
|  |             const isValid = validateCoord(e.target.value, 'lat'); | |||
|  |             toggleInputError(null, null, !isValid, latInput, latError); | |||
|  |         }); | |||
|  | 
 | |||
|  |         lngInput.addEventListener('input', (e) => { | |||
|  |             const isValid = validateCoord(e.target.value, 'lng'); | |||
|  |             toggleInputError(null, null, !isValid, lngInput, lngError); | |||
|  |         }); | |||
|  | 
 | |||
|  |         // 途经点地图选点 | |||
|  |         waypointDiv.querySelector('.set-waypoint-btn').addEventListener('click', () => { | |||
|  |             const id = parseInt(waypointDiv.dataset.id); | |||
|  |             map.once('click', (e) => { | |||
|  |                 const { lat, lng } = e.latlng; | |||
|  |                 latInput.value = lat.toFixed(6); | |||
|  |                 lngInput.value = lng.toFixed(6); | |||
|  |                 latInput.dispatchEvent(new Event('input')); | |||
|  |                 lngInput.dispatchEvent(new Event('input')); | |||
|  | 
 | |||
|  |                 if (waypointMarkers[id]) { | |||
|  |                     waypointMarkers[id].setLatLng([lat, lng]); | |||
|  |                 } else { | |||
|  |                     waypointMarkers[id] = L.marker([lat, lng], { | |||
|  |                         icon: L.divIcon({ | |||
|  |                             className: 'custom-marker', | |||
|  |                             html: `<div class="w-5 h-5 bg-primary rounded-full flex items-center justify-center text-white text-xs shadow-md">${id}</div>`, | |||
|  |                             iconSize: [25, 25], | |||
|  |                             iconAnchor: [12, 25] | |||
|  |                         }), | |||
|  |                         draggable: true, | |||
|  |                         riseOnHover: true | |||
|  |                     }).addTo(map); | |||
|  | 
 | |||
|  |                     waypointMarkers[id].on('dragend', (e) => { | |||
|  |                         const { lat, lng } = e.target.getLatLng(); | |||
|  |                         latInput.value = lat.toFixed(6); | |||
|  |                         lngInput.value = lng.toFixed(6); | |||
|  |                         latInput.dispatchEvent(new Event('input')); | |||
|  |                         lngInput.dispatchEvent(new Event('input')); | |||
|  |                     }); | |||
|  |                 } | |||
|  |             }); | |||
|  |         }); | |||
|  | 
 | |||
|  |         // 删除途经点 | |||
|  |         waypointDiv.querySelector('.remove-waypoint-btn').addEventListener('click', () => { | |||
|  |             const id = parseInt(waypointDiv.dataset.id); | |||
|  |             if (waypointMarkers[id]) { | |||
|  |                 map.removeLayer(waypointMarkers[id]); | |||
|  |                 waypointMarkers[id] = null; | |||
|  |             } | |||
|  |             waypointDiv.remove(); | |||
|  |         }); | |||
|  |     } | |||
|  | 
 | |||
|  |     // 地图点击处理 | |||
|  |     function handleMapClick(e) { | |||
|  |         const { lat, lng } = e.latlng; | |||
|  |         const startLat = document.getElementById('startLat').value; | |||
|  |         const endLat = document.getElementById('endLat').value; | |||
|  | 
 | |||
|  |         // 先设起点(空则设起点)→ 再设终点 | |||
|  |         if (!startLat.trim()) { | |||
|  |             setStartPoint(lat, lng); | |||
|  |         } else if (!endLat.trim()) { | |||
|  |             setEndPoint(lat, lng); | |||
|  |         } | |||
|  |     } | |||
|  | 
 | |||
|  |     // 路径操作 | |||
|  |     function clearRoute() { | |||
|  |         if (routeLine) { | |||
|  |             map.removeLayer(routeLine); | |||
|  |             routeLine = null; | |||
|  |         } | |||
|  |         document.getElementById('resultContainer').classList.add('hidden'); | |||
|  |     } | |||
|  | 
 | |||
|  |     // 清空所有:起点重置为空(而非默认坐标) | |||
|  |     function clearAll() { | |||
|  |         clearRoute(); | |||
|  | 
 | |||
|  |         // 清除起点(重置为空) | |||
|  |         if (startMarker) { | |||
|  |             map.removeLayer(startMarker); | |||
|  |             startMarker = null; | |||
|  |         } | |||
|  |         document.getElementById('startLat').value = ''; | |||
|  |         document.getElementById('startLng').value = ''; | |||
|  |         toggleInputError('startLat', 'startLatError', false); | |||
|  |         toggleInputError('startLng', 'startLngError', false); | |||
|  | 
 | |||
|  |         // 清除终点 | |||
|  |         if (endMarker) { | |||
|  |             map.removeLayer(endMarker); | |||
|  |             endMarker = null; | |||
|  |         } | |||
|  |         document.getElementById('endLat').value = ''; | |||
|  |         document.getElementById('endLng').value = ''; | |||
|  |         toggleInputError('endLat', 'endLatError', false); | |||
|  |         toggleInputError('endLng', 'endLngError', false); | |||
|  | 
 | |||
|  |         // 清除途经点 | |||
|  |         waypointMarkers.forEach(marker => { | |||
|  |             if (marker) map.removeLayer(marker); | |||
|  |         }); | |||
|  |         waypointMarkers = []; | |||
|  |         document.getElementById('waypointsContainer').innerHTML = ''; | |||
|  |         waypointCount = 0; | |||
|  |         addWaypoint(); // 重置默认空途经点 | |||
|  |     } | |||
|  | 
 | |||
|  |     // 地图加载进度 | |||
|  |     function updateLoadProgress(show, text = '正在处理...') { | |||
|  |         const progressEl = document.getElementById('loadProgress'); | |||
|  |         const textEl = document.getElementById('progressText'); | |||
|  | 
 | |||
|  |         if (show) { | |||
|  |             progressEl.classList.remove('hidden'); | |||
|  |             textEl.textContent = text; | |||
|  |         } else { | |||
|  |             progressEl.classList.add('hidden'); | |||
|  |         } | |||
|  |     } | |||
|  | 
 | |||
|  |     // 上传PBF文件 | |||
|  |     async function uploadPbfFile(file) { | |||
|  |         try { | |||
|  |             const formData = new FormData(); | |||
|  |             formData.append('files', file); | |||
|  |             updateLoadProgress(true, '正在上传地图文件...'); | |||
|  | 
 | |||
|  |             const response = await axios.post(`${API_BASE_URL}/fileInfo/upload`, formData, { | |||
|  |                 onUploadProgress: (progressEvent) => { | |||
|  |                     const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100); | |||
|  |                     if (percent < 100) { | |||
|  |                         updateLoadProgress(true, `上传中... ${percent}%`); | |||
|  |                     } | |||
|  |                 } | |||
|  |             }); | |||
|  | 
 | |||
|  |             if (response.data.code !== 200 || !response.data.data || !response.data.data[0]) { | |||
|  |                 throw new Error(`上传失败:${response.data.message || '未知错误'}`); | |||
|  |             } | |||
|  | 
 | |||
|  |             const fileId = response.data.data[0].id; | |||
|  |             updateLoadProgress(true, `上传成功(ID: ${fileId})、正在加载地图...`); | |||
|  |             return fileId; | |||
|  |         } catch (error) { | |||
|  |             const errorMsg = error.response?.data?.message || error.message || '上传异常'; | |||
|  |             updateLoadProgress(false); | |||
|  |             alert(`⚠️ 上传失败:${errorMsg}`); | |||
|  |             throw error; | |||
|  |         } | |||
|  |     } | |||
|  | 
 | |||
|  |     // 加载地图 | |||
|  |     async function loadMapByFileId(fileId) { | |||
|  |         try { | |||
|  |             const formData = new FormData(); | |||
|  |             formData.append('fileId', fileId); | |||
|  | 
 | |||
|  |             const response = await axios.post(`${API_BASE_URL}/graphhopper/loadMap`, formData); | |||
|  |             if (response.data.code !== 200) { | |||
|  |                 throw new Error(`加载失败:${response.data.message || '接口返回异常'}`); | |||
|  |             } | |||
|  | 
 | |||
|  |             updateLoadProgress(false); | |||
|  |             const calcBtn = document.getElementById('calculateRouteBtn'); | |||
|  |             calcBtn.disabled = false; | |||
|  |             calcBtn.classList.remove('btn-disabled'); | |||
|  |             calcBtn.innerHTML = '<i class="fa fa-calculator mr-2"></i>计算路径'; | |||
|  |             return true; | |||
|  |         } catch (error) { | |||
|  |             const errorMsg = error.response?.data?.message || error.message || '加载异常'; | |||
|  |             updateLoadProgress(false); | |||
|  |             alert(`⚠️ 地图加载失败:${errorMsg}`); | |||
|  |             throw error; | |||
|  |         } | |||
|  |     } | |||
|  | 
 | |||
|  |     // 计算路径 | |||
|  |     async function calculateRoute() { | |||
|  |         const startLat = parseFloat(document.getElementById('startLat').value); | |||
|  |         const startLng = parseFloat(document.getElementById('startLng').value); | |||
|  |         const endLat = parseFloat(document.getElementById('endLat').value); | |||
|  |         const endLng = parseFloat(document.getElementById('endLng').value); | |||
|  |         const profile = document.getElementById('profile').value; | |||
|  | 
 | |||
|  |         // 基础验证 | |||
|  |         if ([startLat, startLng, endLat, endLng].some(isNaN)) { | |||
|  |             alert('⚠️ 请确保起点/终点坐标为有效数字'); | |||
|  |             return; | |||
|  |         } | |||
|  |         if (!validateCoord(startLat, 'lat') || !validateCoord(startLng, 'lng')) { | |||
|  |             alert('⚠️ 起点坐标超出有效范围(纬度-90~90、经度-180~180)'); | |||
|  |             return; | |||
|  |         } | |||
|  |         if (!validateCoord(endLat, 'lat') || !validateCoord(endLng, 'lng')) { | |||
|  |             alert('⚠️ 终点坐标超出有效范围(纬度-90~90、经度-180~180)'); | |||
|  |             return; | |||
|  |         } | |||
|  | 
 | |||
|  |         // 处理途经点 | |||
|  |         const waypoints = []; | |||
|  |         document.querySelectorAll('.waypoint-item').forEach(item => { | |||
|  |             const lat = parseFloat(item.querySelector('.waypoint-lat').value); | |||
|  |             const lng = parseFloat(item.querySelector('.waypoint-lng').value); | |||
|  |             if (!isNaN(lat) && !isNaN(lng) && validateCoord(lat, 'lat') && validateCoord(lng, 'lng')) { | |||
|  |                 waypoints.push({ lat, lng }); | |||
|  |             } | |||
|  |         }); | |||
|  | 
 | |||
|  |         // 发起请求 | |||
|  |         const calcBtn = document.getElementById('calculateRouteBtn'); | |||
|  |         calcBtn.disabled = true; | |||
|  |         calcBtn.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i>计算中...'; | |||
|  | 
 | |||
|  |         try { | |||
|  |             const response = await axios.post( | |||
|  |                 `${API_BASE_URL}/graphhopper/route`, | |||
|  |                 { startLat, startLng, endLat, endLng, profile, waypoints }, | |||
|  |                 { headers: { 'Content-Type': 'application/json' } } | |||
|  |             ); | |||
|  | 
 | |||
|  |             if (response.data.code !== 200 || !response.data.data) { | |||
|  |                 throw new Error(`计算失败:${response.data.message || '接口返回异常'}`); | |||
|  |             } | |||
|  | 
 | |||
|  |             handleRouteResponse(response.data.data); | |||
|  |         } catch (error) { | |||
|  |             const errorMsg = error.response?.data?.message || error.message || '计算异常'; | |||
|  |             alert(`⚠️ 路径计算失败:${errorMsg}`); | |||
|  |         } finally { | |||
|  |             calcBtn.disabled = false; | |||
|  |             calcBtn.innerHTML = '<i class="fa fa-calculator mr-2"></i>计算路径'; | |||
|  |         } | |||
|  |     } | |||
|  | 
 | |||
|  |     // 处理路径结果 | |||
|  |     function handleRouteResponse(routeData) { | |||
|  |         document.getElementById('distanceResult').textContent = `${routeData.distanceKm.toFixed(2)} 公里`; | |||
|  |         document.getElementById('timeResult').textContent = `${routeData.timeMinutes} 分钟`; | |||
|  |         document.getElementById('resultContainer').classList.remove('hidden'); | |||
|  | 
 | |||
|  |         if (routeLine) map.removeLayer(routeLine); | |||
|  |         const latLngs = routeData.pathPoints.map(point => [point.lat, point.lng]); | |||
|  |         const lineStyles = { | |||
|  |             car: { color: '#165DFF', weight: 5, opacity: 0.8, dashArray: '' }, | |||
|  |             bike: { color: '#00B42A', weight: 4, opacity: 0.8, dashArray: '5,5' }, | |||
|  |             foot: { color: '#4b0c35', weight: 3, opacity: 0.8, dashArray: '2,2' } | |||
|  |         }; | |||
|  | 
 | |||
|  |         routeLine = L.polyline(latLngs, lineStyles[document.getElementById('profile').value]) | |||
|  |             .addTo(map) | |||
|  |             .bindPopup(`<div class="text-sm"><p>距离:${routeData.distanceKm.toFixed(2)} 公里</p><p>时间:${routeData.timeMinutes} 分钟</p></div>`); | |||
|  | 
 | |||
|  |         map.fitBounds(routeLine.getBounds(), { padding: [50, 50], maxZoom: 14 }); | |||
|  |     } | |||
|  | 
 | |||
|  |     // 事件绑定:移除起点按钮弹窗 | |||
|  |     function bindEvents() { | |||
|  |         // 文件选择相关 | |||
|  |         document.getElementById('selectFileBtn').addEventListener('click', () => { | |||
|  |             document.getElementById('mapFile').click(); | |||
|  |         }); | |||
|  | 
 | |||
|  |         document.getElementById('mapFile').addEventListener('change', (e) => { | |||
|  |             const fileInfoEl = document.getElementById('fileInfo'); | |||
|  |             if (e.target.files.length === 0) { | |||
|  |                 fileInfoEl.classList.add('hidden'); | |||
|  |                 return; | |||
|  |             } | |||
|  | 
 | |||
|  |             const file = e.target.files[0]; | |||
|  |             if (file.name.toLowerCase().endsWith('.pbf')) { | |||
|  |                 const fileSizeMB = (file.size / (1024 * 1024)).toFixed(2); | |||
|  |                 fileInfoEl.textContent = `已选择:${file.name}(${fileSizeMB} MB)`; | |||
|  |                 fileInfoEl.className = 'mt-1 text-sm text-gray-600'; | |||
|  |             } else { | |||
|  |                 fileInfoEl.textContent = '❌ 请选择PBF格式的地图文件'; | |||
|  |                 fileInfoEl.className = 'mt-1 text-sm text-danger'; | |||
|  |                 e.target.value = ''; | |||
|  |             } | |||
|  |         }); | |||
|  | 
 | |||
|  |         document.getElementById('loadMapBtn').addEventListener('click', async () => { | |||
|  |             const fileInput = document.getElementById('mapFile'); | |||
|  |             if (fileInput.files.length === 0) { | |||
|  |                 alert('⚠️ 请先选择PBF格式地图文件'); | |||
|  |                 return; | |||
|  |             } | |||
|  | 
 | |||
|  |             try { | |||
|  |                 const file = fileInput.files[0]; | |||
|  |                 const fileId = await uploadPbfFile(file); | |||
|  |                 await loadMapByFileId(fileId); | |||
|  |                 fileInput.value = ''; | |||
|  |                 document.getElementById('fileInfo').classList.add('hidden'); | |||
|  |             } catch (error) { | |||
|  |                 console.error('地图加载失败:', error); | |||
|  |             } | |||
|  |         }); | |||
|  | 
 | |||
|  |         // 起点按钮:移除弹窗、仅保留地图选点逻辑 | |||
|  |         document.getElementById('setStartBtn').addEventListener('click', () => { | |||
|  |             map.once('click', (e) => setStartPoint(e.latlng.lat, e.latlng.lng)); | |||
|  |         }); | |||
|  | 
 | |||
|  |         // 终点按钮(保留弹窗、如需统一移除可删除alert) | |||
|  |         document.getElementById('setEndBtn').addEventListener('click', () => { | |||
|  |             map.once('click', (e) => setEndPoint(e.latlng.lat, e.latlng.lng)); | |||
|  |         }); | |||
|  | 
 | |||
|  |         // 途经点/路径操作 | |||
|  |         document.getElementById('addWaypointBtn').addEventListener('click', () => addWaypoint()); | |||
|  |         document.getElementById('calculateRouteBtn').addEventListener('click', calculateRoute); | |||
|  |         document.getElementById('clearRouteBtn').addEventListener('click', clearRoute); | |||
|  |         document.getElementById('clearAllBtn').addEventListener('click', clearAll); | |||
|  | 
 | |||
|  |         // 地图控件 | |||
|  |         document.getElementById('zoomInBtn').addEventListener('click', () => map.zoomIn()); | |||
|  |         document.getElementById('zoomOutBtn').addEventListener('click', () => map.zoomOut()); | |||
|  |         document.getElementById('centerMapBtn').addEventListener('click', () => { | |||
|  |             map.setView([DEFAULT_CENTER.lat, DEFAULT_CENTER.lng], 12); | |||
|  |         }); | |||
|  | 
 | |||
|  |         // 表单验证绑定 | |||
|  |         bindCoordValidation(); | |||
|  |     } | |||
|  | 
 | |||
|  |     // 应用初始化 | |||
|  |     function initApp() { | |||
|  |         initMap(); | |||
|  |         bindEvents(); | |||
|  |         addWaypoint(); // 默认添加1个空途经点 | |||
|  |     } | |||
|  | 
 | |||
|  |     // 页面加载完成后初始化 | |||
|  |     window.addEventListener('load', initApp); | |||
|  | </script> | |||
|  | </body> | |||
|  | </html> |