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>
|