feat(3d-situational-awareness): 集成高德地图路线规划用于应急调度

添加高德地图路线规划、路线可视化、紧急点选择及模拟标记的可组合项。
更新紧急调度逻辑,使用高德地图API计算动态路线,并在无法获取路线时回退至直线路径。
通过基于路线的移动和脉冲控制增强实体动画效果。
增加地理工具、API客户端及路由功能的诊断工具。
This commit is contained in:
Zzc 2025-11-26 18:05:35 +08:00
parent 918c6c8341
commit c6f47c8730
13 changed files with 2276 additions and 153 deletions

View File

@ -21,7 +21,11 @@
<ForcePreset /> <ForcePreset />
</CollapsiblePanel> </CollapsiblePanel>
<CollapsiblePanel title="快速响应" subtitle="「力量调度」"> <CollapsiblePanel
title="快速响应"
subtitle="「力量调度」"
@title-click="handleQuickResponseToggle"
>
<ForceDispatch <ForceDispatch
@start-dispatch="handleStartDispatch" @start-dispatch="handleStartDispatch"
@view-plan="handleViewPlan" @view-plan="handleViewPlan"
@ -137,6 +141,14 @@ const handleForcePresetToggle = () => {
console.log('[LeftPanel] 快速匹配标题被点击') console.log('[LeftPanel] 快速匹配标题被点击')
emit('force-preset-toggle') emit('force-preset-toggle')
} }
/**
* 处理快速响应面板标题点击事件
*/
const handleQuickResponseToggle = () => {
console.log('[LeftPanel] 快速响应标题被点击')
emit('quick-response-toggle')
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@ -0,0 +1,169 @@
/**
* 高德地图路由 Composable
* 负责路径规划和坐标转换
*/
import { ref } from 'vue'
import * as Cesium from 'cesium'
import { calculateDrivingRoute, calculateMultipleRoutes, clearRouteCache, getCacheStats } from '../utils/amapApi'
import { calculateDistance, simplifyRoute } from '../utils/geoUtils'
export function useAmapRouting() {
const isCalculating = ref(false)
const lastError = ref(null)
/**
* 计算单条路线
* @param {Object} origin - 起点 { lon, lat }
* @param {Object} destination - 终点 { lon, lat }
* @param {Object} options - 可选参数
* @returns {Promise<Object>} 路线数据
*/
const calculateRoute = async (origin, destination, options = {}) => {
try {
isCalculating.value = true
lastError.value = null
const routeData = await calculateDrivingRoute(origin, destination, options)
// 可选:简化路线
if (options.simplify) {
routeData.polyline = simplifyRoute(routeData.polyline, options.maxPoints || 50)
}
return routeData
} catch (error) {
lastError.value = error.message
console.error('[useAmapRouting] 路线计算失败:', error)
// 降级:生成直线路径
if (options.fallbackToStraightLine !== false) {
console.warn('[useAmapRouting] 使用直线降级方案')
return generateStraightLineRoute(origin, destination)
}
throw error
} finally {
isCalculating.value = false
}
}
/**
* 批量计算路线
* @param {Array} routePairs - 路线对数组
* @returns {Promise<Array>} 路线结果数组
*/
const calculateRoutes = async (routePairs) => {
try {
isCalculating.value = true
lastError.value = null
const results = await calculateMultipleRoutes(routePairs)
// 对失败的路线使用直线降级
const processedResults = results.map(result => {
if (!result.success) {
console.warn(`[useAmapRouting] 路线 ${result.name} 失败,使用直线降级`)
const fallbackRoute = generateStraightLineRoute(result.origin, result.destination)
return {
...result,
...fallbackRoute,
isFallback: true,
success: true
}
}
return result
})
return processedResults
} catch (error) {
lastError.value = error.message
console.error('[useAmapRouting] 批量路线计算失败:', error)
throw error
} finally {
isCalculating.value = false
}
}
/**
* 将高德 polyline 字符串转换为 Cesium Cartesian3 数组
* @param {string} polylineString - 高德 polyline 格式 "lon1,lat1;lon2,lat2;..."
* @returns {Array<Cesium.Cartesian3>} Cartesian3 坐标数组
*/
const transformToCartesian3 = (polylineString) => {
if (!polylineString) {
console.warn('[useAmapRouting] polyline 为空')
return []
}
try {
return polylineString
.split(';')
.map(pair => {
const [lon, lat] = pair.split(',').map(Number)
if (isNaN(lon) || isNaN(lat)) {
console.warn('[useAmapRouting] 无效坐标:', pair)
return null
}
return Cesium.Cartesian3.fromDegrees(lon, lat, 0)
})
.filter(Boolean)
} catch (error) {
console.error('[useAmapRouting] polyline 转换失败:', error)
return []
}
}
/**
* 生成直线路径降级方案
* @param {Object} origin - 起点
* @param {Object} destination - 终点
* @returns {Object} 直线路径数据
*/
const generateStraightLineRoute = (origin, destination) => {
const distance = calculateDistance(origin, destination) * 1000 // 转为米
// 假设平均速度 40 km/h
const duration = (distance / 1000) * 60 * 1.5 // 秒
return {
polyline: `${origin.lon},${origin.lat};${destination.lon},${destination.lat}`,
distance: Math.round(distance),
duration: Math.round(duration),
steps: [],
isFallback: true
}
}
/**
* 清除路由缓存
*/
const clearCache = () => {
clearRouteCache()
}
/**
* 获取缓存统计
*/
const getCacheInfo = () => {
return getCacheStats()
}
return {
// 状态
isCalculating,
lastError,
// 方法
calculateRoute,
calculateRoutes,
transformToCartesian3,
generateStraightLineRoute,
clearCache,
getCacheInfo
}
}
export default useAmapRouting

View File

@ -1,6 +1,16 @@
import { ref } from 'vue' import { ref } from 'vue'
import * as Cesium from 'cesium' import * as Cesium from 'cesium'
import { ElMessage, ElNotification } from 'element-plus'
import { ANIMATION_CONFIG, ANIMATION_PATHS } from '../constants' import { ANIMATION_CONFIG, ANIMATION_PATHS } from '../constants'
import { useAmapRouting } from './useAmapRouting'
import { useEmergencyRouteSelection } from './useEmergencyRouteSelection'
import { useRouteVisualization } from './useRouteVisualization'
// 灾害中心坐标
const DISASTER_CENTER = {
lon: 108.010961,
lat: 30.176459
}
/** /**
* 应急调度流程管理 * 应急调度流程管理
@ -10,6 +20,7 @@ import { ANIMATION_CONFIG, ANIMATION_PATHS } from '../constants'
* 2. 协调 loading 动画路径线绘制人员移动动画 * 2. 协调 loading 动画路径线绘制人员移动动画
* 3. 管理相机飞行到全景位置 * 3. 管理相机飞行到全景位置
* 4. 清理路径起点标记 * 4. 清理路径起点标记
* 5. 集成高德地图路径规划
* *
* @param {Object} dependencies - 依赖的 composables * @param {Object} dependencies - 依赖的 composables
* @param {Object} dependencies.pathLinesComposable - usePathLines 返回的对象 * @param {Object} dependencies.pathLinesComposable - usePathLines 返回的对象
@ -25,18 +36,172 @@ export function useEmergencyDispatch({
registerTimeoutFn, registerTimeoutFn,
}) { }) {
const { drawAllPathLines } = pathLinesComposable const { drawAllPathLines } = pathLinesComposable
const { startPersonnelMovement, startMultipleMovements, isAnimating } = const { startPersonnelMovement, startMultipleMovements, startMultipleAnimationsWithRoutes, isAnimating } =
entityAnimationComposable entityAnimationComposable
// 初始化路由相关 composables
const { calculateRoutes, isCalculating } = useAmapRouting()
const { selectByType } = useEmergencyRouteSelection()
const { drawMultipleRoutes, clearRoutes, getRoutesBounds } = useRouteVisualization()
// Loading 状态 // Loading 状态
const showLoading = ref(false) const showLoading = ref(false)
const loadingMessage = ref('正在加载...')
/** /**
* 启动应急调度流程 * 隐藏应急点的静态人员/装备标记
* @param {Cesium.Viewer} viewer
*/
const hideEmergencyPointMarkers = (viewer) => {
if (!viewer) return
viewer.entities.values.forEach((entity) => {
if (entity.properties) {
const type = entity.properties.type?.getValue()
if (type === 'emergencyPersonnel' || type === 'emergencyEquipment') {
entity.show = false
}
}
})
console.log('[useEmergencyDispatch] 已隐藏应急点静态标记')
}
/**
* 启动应急调度流程新版使用高德地图路径规划
* @param {Object} payload - 调度参数
*/
const startDispatchWithRouting = async (payload) => {
console.log('[useEmergencyDispatch] 启动智能路径调度:', payload)
// 防止重复启动
if (isAnimating.value || isCalculating.value) {
console.warn('[useEmergencyDispatch] 动画或计算正在运行,忽略重复启动')
return
}
const viewer = mapStore.viewer
if (!viewer) {
console.error('[useEmergencyDispatch] viewer 未就绪')
return
}
try {
// 1. 显示 loading - 加载应急点
showLoading.value = true
loadingMessage.value = '加载应急点数据...'
// 模拟从 API 加载应急点(这里使用 payload 中的数据)
// 实际项目中应该调用: await request({ url: '/snow-ops-platform/yhYjll/list', ... })
const allPoints = payload.emergencyPoints || []
if (allPoints.length === 0) {
throw new Error('没有可用的应急点')
}
console.log(`[useEmergencyDispatch] 加载了 ${allPoints.length} 个应急点`)
// 2. 选择最近的应急点
loadingMessage.value = '选择最近的应急点...'
await delay(300)
const { personnel, equipment } = selectByType(DISASTER_CENTER, allPoints, {
personnelCount: 2, // 修改为2个人员点
equipmentCount: 2 // 保持2个装备点
})
console.log(`[useEmergencyDispatch] 选择了 ${personnel.length} 个人员点, ${equipment.length} 个装备点`)
if (personnel.length === 0 && equipment.length === 0) {
throw new Error('没有选择到合适的应急点')
}
// 3. 计算路线
loadingMessage.value = '计算最优路线...'
await delay(300)
const routePairs = [
...personnel.map(p => ({
id: `personnel_${p.id || p.name}`,
name: p.name || '应急人员',
type: 'personnel',
origin: { lon: p.lon, lat: p.lat },
destination: DISASTER_CENTER
})),
...equipment.map(e => ({
id: `equipment_${e.id || e.name}`,
name: e.name || '应急装备',
type: 'equipment',
origin: { lon: e.lon, lat: e.lat },
destination: DISASTER_CENTER
}))
]
const routes = await calculateRoutes(routePairs)
console.log(`[useEmergencyDispatch] 计算了 ${routes.length} 条路线`)
// 检查是否有降级路线
const fallbackCount = routes.filter(r => r.isFallback).length
if (fallbackCount > 0) {
ElMessage.warning(`${fallbackCount} 条路线使用直线距离API 规划失败)`)
}
// 4. 清除旧路线并绘制新路线
loadingMessage.value = '绘制应急路径...'
await delay(300)
clearRoutes(viewer)
drawMultipleRoutes(viewer, routes)
// 5. 移除路径起点标记
removePathStartMarkers(viewer)
// 6. 启动动画前隐藏静态标记
loadingMessage.value = '启动应急调度...'
await delay(300)
// 隐藏应急点的静态人员/装备标记
hideEmergencyPointMarkers(viewer)
startMultipleAnimationsWithRoutes(viewer, routes, {
speedMultiplier: 10,
disablePulse: true // 禁用脉冲效果
})
// 7. 相机飞向全景位置
flyToOverviewPositionForRoutes(routes)
// 8. 隐藏 loading
showLoading.value = false
ElNotification.success({
title: '调度成功',
message: `已启动 ${routes.length} 条应急路线`,
duration: 3000
})
} catch (error) {
console.error('[useEmergencyDispatch] 调度失败:', error)
showLoading.value = false
ElNotification.error({
title: '调度失败',
message: error.message || '未知错误',
duration: 5000
})
// 降级到旧版调度
console.warn('[useEmergencyDispatch] 降级到硬编码路径调度')
startDispatch(payload)
}
}
/**
* 启动应急调度流程旧版使用硬编码路径
* @param {Object} payload - 调度参数 * @param {Object} payload - 调度参数
*/ */
const startDispatch = (payload) => { const startDispatch = (payload) => {
console.log('[useEmergencyDispatch] 启动力量调度:', payload) console.log('[useEmergencyDispatch] 启动力量调度(硬编码路径):', payload)
// 防止重复启动 // 防止重复启动
if (isAnimating.value) { if (isAnimating.value) {
@ -52,6 +217,7 @@ export function useEmergencyDispatch({
// 1. 显示 loading 动画 // 1. 显示 loading 动画
showLoading.value = true showLoading.value = true
loadingMessage.value = '正在加载...'
// 2. 绘制红色路径线 // 2. 绘制红色路径线
drawAllPathLines(viewer, { drawAllPathLines(viewer, {
@ -69,6 +235,11 @@ export function useEmergencyDispatch({
registerTimeoutFn(timeoutId) registerTimeoutFn(timeoutId)
} }
/**
* 延迟函数
*/
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms))
/** /**
* 执行调度步骤 * 执行调度步骤
* @param {Cesium.Viewer} viewer - Cesium viewer 实例 * @param {Cesium.Viewer} viewer - Cesium viewer 实例
@ -118,7 +289,34 @@ export function useEmergencyDispatch({
} }
/** /**
* 相机飞向全景位置 * 相机飞向全景位置动态路线版本
* @param {Array} routes - 路线数组
*/
const flyToOverviewPositionForRoutes = (routes) => {
const { camera } = mapStore.services()
console.log('[useEmergencyDispatch] 相机飞向动态路线全景位置...')
// 获取所有路线的边界点
const allPoints = getRoutesBounds(routes)
// 添加灾害中心点
allPoints.push(DISASTER_CENTER)
if (allPoints.length === 0) {
console.warn('[useEmergencyDispatch] 没有路线点,使用默认视角')
return
}
// 使用智能聚焦方法飞向能看到所有路径的最佳位置
camera.fitBoundsWithTrajectory(allPoints, {
duration: ANIMATION_CONFIG.cameraFlyDuration,
padding: ANIMATION_CONFIG.cameraPadding,
})
}
/**
* 相机飞向全景位置硬编码路径版本
* 计算能看到所有路径的最佳视角 * 计算能看到所有路径的最佳视角
*/ */
const flyToOverviewPosition = () => { const flyToOverviewPosition = () => {
@ -168,8 +366,14 @@ export function useEmergencyDispatch({
} }
return { return {
// 状态
showLoading, showLoading,
startDispatch, loadingMessage,
isCalculating,
// 方法
startDispatch, // 旧版(硬编码路径)
startDispatchWithRouting, // 新版(高德地图路径规划)
stopDispatch, stopDispatch,
} }
} }

View File

@ -0,0 +1,185 @@
/**
* 应急点选择算法 Composable
* 负责从应急点中选择最近的 N 个点
*/
import { calculateDistance, isValidCoordinate } from '../utils/geoUtils'
export function useEmergencyRouteSelection() {
/**
* 按直线距离选择最近的 N 个点
* @param {Object} center - 中心点 { lon, lat }
* @param {Array} points - 应急点数组
* @param {number} count - 选择数量
* @returns {Array} 选中的应急点数组
*/
const selectNearestPoints = (center, points, count) => {
if (!center || !points || points.length === 0) {
console.warn('[useEmergencyRouteSelection] 参数无效')
return []
}
// 验证中心点坐标
if (!isValidCoordinate(center)) {
console.error('[useEmergencyRouteSelection] 中心点坐标无效:', center)
return []
}
// 过滤并计算距离
const validPoints = points
.filter(point => {
// 确保点有坐标
const hasCoords = point.lon != null && point.lat != null
if (!hasCoords) {
console.warn('[useEmergencyRouteSelection] 点缺少坐标:', point)
return false
}
// 验证坐标有效性
if (!isValidCoordinate({ lon: point.lon, lat: point.lat })) {
console.warn('[useEmergencyRouteSelection] 点坐标无效:', point)
return false
}
return true
})
.map(point => {
const distance = calculateDistance(center, { lon: point.lon, lat: point.lat })
return {
...point,
distance: distance // 公里
}
})
// 按距离排序
const sorted = validPoints.sort((a, b) => a.distance - b.distance)
// 选择前 N 个
const selected = sorted.slice(0, count)
console.log(`[useEmergencyRouteSelection] 从 ${points.length} 个点中选择了 ${selected.length} 个最近的点`)
return selected
}
/**
* 按类型分别选择应急点
* @param {Object} center - 中心点
* @param {Array} points - 应急点数组
* @param {Object} config - 配置 { personnelCount, equipmentCount }
* @returns {Object} { personnel: [], equipment: [] }
*/
const selectByType = (center, points, config = {}) => {
const { personnelCount = 3, equipmentCount = 2 } = config
console.log(`[useEmergencyRouteSelection] 选择应急点: 人员=${personnelCount}, 装备=${equipmentCount}`)
// 根据 API 数据结构推断类型
// lx: 1 = 应急基地(人员)
// lx: 2 = 储备中心(装备)
const personnel = points.filter(p => {
// 优先使用 type 字段
if (p.type === 'soldier' || p.type === 'personnel') {
return true
}
// 其次使用 lx 字段
if (p.lx === 1) {
return true
}
// 最后使用 category 字段
if (p.category && (p.category.includes('人员') || p.category.includes('基地'))) {
return true
}
return false
})
const equipment = points.filter(p => {
// 优先使用 type 字段
if (p.type === 'device' || p.type === 'equipment') {
return true
}
// 其次使用 lx 字段
if (p.lx === 2) {
return true
}
// 最后使用 category 字段
if (p.category && (p.category.includes('装备') || p.category.includes('物资') || p.category.includes('储备'))) {
return true
}
return false
})
console.log(`[useEmergencyRouteSelection] 分类结果: 人员=${personnel.length}, 装备=${equipment.length}`)
// 如果分类失败,尝试平均分配
if (personnel.length === 0 && equipment.length === 0 && points.length > 0) {
console.warn('[useEmergencyRouteSelection] 无法分类,使用平均分配策略')
const totalCount = personnelCount + equipmentCount
const allSelected = selectNearestPoints(center, points, totalCount)
return {
personnel: allSelected.slice(0, personnelCount).map(p => ({ ...p, type: 'personnel' })),
equipment: allSelected.slice(personnelCount).map(p => ({ ...p, type: 'equipment' }))
}
}
// 分别选择最近的点
const selectedPersonnel = selectNearestPoints(center, personnel, personnelCount)
const selectedEquipment = selectNearestPoints(center, equipment, equipmentCount)
// 确保类型字段正确
return {
personnel: selectedPersonnel.map(p => ({ ...p, type: 'personnel' })),
equipment: selectedEquipment.map(p => ({ ...p, type: 'equipment' }))
}
}
/**
* 按距离排序点
* @param {Object} center - 中心点
* @param {Array} points - 点数组
* @returns {Array} 排序后的点数组
*/
const sortByDistance = (center, points) => {
return points
.map(point => ({
...point,
distance: calculateDistance(center, { lon: point.lon, lat: point.lat })
}))
.sort((a, b) => a.distance - b.distance)
}
/**
* 两阶段选择先直线距离预筛选再路线距离精确选择
* @param {Object} center - 中心点
* @param {Array} points - 应急点数组
* @param {number} finalCount - 最终选择数量
* @param {number} candidateMultiplier - 候选倍数默认2倍
* @returns {Array} 候选点数组
*/
const selectCandidates = (center, points, finalCount, candidateMultiplier = 2) => {
const candidateCount = finalCount * candidateMultiplier
console.log(`[useEmergencyRouteSelection] 两阶段选择: 最终=${finalCount}, 候选=${candidateCount}`)
return selectNearestPoints(center, points, candidateCount)
}
return {
selectNearestPoints,
selectByType,
sortByDistance,
selectCandidates
}
}
export default useEmergencyRouteSelection

View File

@ -143,11 +143,13 @@ export function useEntityAnimation() {
}) })
// 创建脉冲缩放效果 - 让图标闪烁更醒目 // 创建脉冲缩放效果 - 让图标闪烁更醒目
const pulseScale = new Cesium.CallbackProperty((time) => { const pulseScale = options.disablePulse
const elapsed = Cesium.JulianDate.secondsDifference(time, startTime) ? 1.0
// 使用正弦波产生脉冲效果,频率 3Hz幅度 ±30% : new Cesium.CallbackProperty((time) => {
return 1.0 + Math.sin(elapsed * 3) * 0.3 const elapsed = Cesium.JulianDate.secondsDifference(time, startTime)
}, false) // 使用正弦波产生脉冲效果,频率 3Hz幅度 ±30%
return 1.0 + Math.sin(elapsed * 3) * 0.3
}, false)
// 创建动画实体 // 创建动画实体
const entity = viewer.entities.add({ const entity = viewer.entities.add({
@ -314,10 +316,12 @@ export function useEntityAnimation() {
positionProperty.addSample(time, position) positionProperty.addSample(time, position)
}) })
const pulseScale = new Cesium.CallbackProperty((time) => { const pulseScale = config.disablePulse
const elapsed = Cesium.JulianDate.secondsDifference(time, startTime) ? 1.0
return 1.0 + Math.sin(elapsed * 3) * 0.3 : new Cesium.CallbackProperty((time) => {
}, false) const elapsed = Cesium.JulianDate.secondsDifference(time, startTime)
return 1.0 + Math.sin(elapsed * 3) * 0.3
}, false)
const icon = config.type === 'device' ? deviceIcon : soldierIcon const icon = config.type === 'device' ? deviceIcon : soldierIcon
const trailColor = config.type === 'device' ? Cesium.Color.ORANGE : Cesium.Color.CYAN const trailColor = config.type === 'device' ? Cesium.Color.ORANGE : Cesium.Color.CYAN
@ -430,6 +434,9 @@ export function useEntityAnimation() {
viewer.clock.shouldAnimate = false viewer.clock.shouldAnimate = false
// 停止所有脉冲效果
stopAllPulses(viewer)
if (animatedEntity.value) { if (animatedEntity.value) {
viewer.entities.remove(animatedEntity.value) viewer.entities.remove(animatedEntity.value)
animatedEntity.value = null animatedEntity.value = null
@ -444,6 +451,235 @@ export function useEntityAnimation() {
console.log('[useEntityAnimation] 已停止所有移动动画') console.log('[useEntityAnimation] 已停止所有移动动画')
} }
/**
* 根据动态路线创建动画实体
* @param {Cesium.Viewer} viewer
* @param {Object} route - 路线数据 { polyline, duration, type, name, id }
* @param {Object} options - 配置选项
* @param {Cesium.JulianDate} startTime
* @param {Cesium.JulianDate} stopTime
* @returns {Cesium.Entity}
*/
const createAnimationFromRoute = (viewer, route, options, startTime, stopTime) => {
// 将 polyline 字符串转换为 Cartesian3 数组
const positions = route.polyline
.split(';')
.map(pair => {
const [lon, lat] = pair.split(',').map(Number)
return Cesium.Cartesian3.fromDegrees(lon, lat, 0)
})
// 计算动画时长(使用速度倍数)
const speedMultiplier = options.speedMultiplier || 10
const animationDuration = Math.min(
Math.max(route.duration / speedMultiplier, 30), // 最小30秒
120 // 最大120秒
)
// 创建位置属性
const positionProperty = new Cesium.SampledPositionProperty()
const timeInterval = animationDuration / (positions.length - 1)
positions.forEach((position, index) => {
const time = Cesium.JulianDate.addSeconds(
startTime,
index * timeInterval,
new Cesium.JulianDate()
)
positionProperty.addSample(time, position)
})
// 脉冲缩放效果 - 添加状态控制以支持停止脉冲
const scaleState = {
isPulsing: !options.disablePulse,
fixedScale: 1.0
}
const pulseScale = options.disablePulse
? 1.0
: new Cesium.CallbackProperty((time) => {
if (!scaleState.isPulsing) {
return scaleState.fixedScale // 停止脉冲,返回固定值
}
const elapsed = Cesium.JulianDate.secondsDifference(time, startTime)
return 1.0 + Math.sin(elapsed * 3) * 0.3
}, false)
// 选择图标和轨迹颜色
const icon = route.type === 'equipment' ? deviceIcon : soldierIcon
const trailColor = route.type === 'equipment' ? Cesium.Color.ORANGE : Cesium.Color.CYAN
// 创建动画实体
return viewer.entities.add({
availability: new Cesium.TimeIntervalCollection([
new Cesium.TimeInterval({ start: startTime, stop: stopTime })
]),
position: positionProperty,
orientation: new Cesium.VelocityOrientationProperty(positionProperty),
billboard: {
image: icon,
width: 48,
height: 56,
scale: pulseScale,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: new Cesium.NearFarScalar(1000, 1.5, 50000, 0.8)
},
path: {
resolution: 1,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.4,
taperPower: 0.5,
color: trailColor
}),
width: 8,
leadTime: 0,
trailTime: animationDuration
},
properties: {
type: route.type === 'equipment' ? 'animatedDevice' : 'animatedSoldier',
name: route.name,
routeId: route.id,
isAnimating: true,
scaleState: scaleState, // 新增:保存状态引用
animationDuration: animationDuration, // 新增:保存动画时长
animationStartTime: startTime.clone() // 新增:保存开始时间
}
})
}
/**
* 启动基于动态路线的多组动画
* @param {Cesium.Viewer} viewer
* @param {Array} routes - 路线数组
* @param {Object} options - 配置选项
* @returns {Array<Cesium.Entity>}
*/
const startMultipleAnimationsWithRoutes = (viewer, routes, options = {}) => {
if (!viewer || !routes || routes.length === 0) {
console.warn('[useEntityAnimation] startMultipleAnimationsWithRoutes: 参数无效')
return []
}
console.log(`[useEntityAnimation] 启动 ${routes.length} 条路线的动画 (disablePulse: ${options.disablePulse || false})`)
// 计算最大动画时长
const speedMultiplier = options.speedMultiplier || 10
const durations = routes.map(r =>
Math.min(Math.max(r.duration / speedMultiplier, 30), 120)
)
const maxDuration = Math.max(...durations)
// 设置时间范围
const startTime = Cesium.JulianDate.now()
const stopTime = Cesium.JulianDate.addSeconds(
startTime,
maxDuration,
new Cesium.JulianDate()
)
// 配置 viewer 时钟
viewer.clock.startTime = startTime.clone()
viewer.clock.stopTime = stopTime.clone()
viewer.clock.currentTime = startTime.clone()
viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP
viewer.clock.multiplier = 1
viewer.clock.shouldAnimate = true
// 清除之前的动画实体
animatedEntities.value.forEach(entity => {
if (entity) viewer.entities.remove(entity)
})
animatedEntities.value = []
// 为每条路线创建动画实体
routes.forEach(route => {
try {
const entity = createAnimationFromRoute(
viewer,
route,
{ speedMultiplier, disablePulse: options.disablePulse },
startTime,
stopTime
)
animatedEntities.value.push(entity)
} catch (error) {
console.error(`[useEntityAnimation] 创建路线 ${route.name} 的动画失败:`, error)
}
})
isAnimating.value = true
// 监听时钟,检测动画完成
const clockListener = viewer.clock.onTick.addEventListener((clock) => {
checkAnimationsCompletion(viewer, clock.currentTime)
})
// 监听动画结束
const removeListener = viewer.clock.onStop.addEventListener(() => {
console.log('[useEntityAnimation] 动画已结束')
isAnimating.value = false
removeListener()
clockListener() // 移除 tick 监听器
})
console.log(`[useEntityAnimation] 成功启动 ${animatedEntities.value.length} 个动画实体`)
return animatedEntities.value
}
/**
* 检查动画是否完成如果完成则停止脉冲
* @param {Cesium.Viewer} viewer
* @param {Cesium.JulianDate} currentTime
*/
const checkAnimationsCompletion = (viewer, currentTime) => {
animatedEntities.value.forEach(entity => {
if (!entity || !entity.properties) return
const scaleState = entity.properties.scaleState?.getValue()
const startTime = entity.properties.animationStartTime?.getValue()
const duration = entity.properties.animationDuration?.getValue()
if (!scaleState || !startTime || !duration) return
// 如果已经停止脉冲,跳过
if (!scaleState.isPulsing) return
// 计算已过时间
const elapsed = Cesium.JulianDate.secondsDifference(currentTime, startTime)
// 如果动画已完成留0.5秒容差)
if (elapsed >= duration - 0.5) {
console.log(`[useEntityAnimation] 实体 ${entity.properties.name.getValue()} 动画完成,停止脉冲`)
scaleState.isPulsing = false
scaleState.fixedScale = 1.0 // 设置为正常大小
}
})
}
/**
* 停止所有实体的脉冲效果
* @param {Cesium.Viewer} viewer
*/
const stopAllPulses = (viewer) => {
if (!viewer) return
animatedEntities.value.forEach(entity => {
if (!entity || !entity.properties) return
const scaleState = entity.properties.scaleState?.getValue()
if (scaleState) {
scaleState.isPulsing = false
scaleState.fixedScale = 1.0
}
})
console.log('[useEntityAnimation] 已停止所有脉冲效果')
}
return { return {
animatedEntity, animatedEntity,
animatedEntities, animatedEntities,
@ -458,7 +694,13 @@ export function useEntityAnimation() {
cartesianToLonLat, cartesianToLonLat,
PERSONNEL_PATH_COORDINATES, PERSONNEL_PATH_COORDINATES,
DEVICE_PATH_COORDINATES, DEVICE_PATH_COORDINATES,
PERSONNEL_PATH_COORDINATES_2 PERSONNEL_PATH_COORDINATES_2,
// 新增:动态路线动画方法
createAnimationFromRoute,
startMultipleAnimationsWithRoutes,
// 新增:脉冲控制方法
checkAnimationsCompletion,
stopAllPulses
} }
} }

View File

@ -0,0 +1,293 @@
/**
* 路线可视化 Composable
* 负责在 Cesium 地图上绘制路线和标签
*/
import { ref } from 'vue'
import * as Cesium from 'cesium'
import { ROUTE_STYLES, LABEL_STYLE } from '../constants/routeStyles'
import { formatDistance, formatDuration } from '../utils/geoUtils'
export function useRouteVisualization() {
// 路线实体集合
const routeEntities = ref([])
const labelEntities = ref([])
/**
* polyline 字符串转换为 Cartesian3 数组
* @param {string} polylineString - 高德 polyline 格式
* @returns {Array<Cesium.Cartesian3>}
*/
const transformToCartesian3 = (polylineString) => {
if (!polylineString) return []
try {
return polylineString
.split(';')
.map(pair => {
const [lon, lat] = pair.split(',').map(Number)
if (isNaN(lon) || isNaN(lat)) return null
return Cesium.Cartesian3.fromDegrees(lon, lat, 0)
})
.filter(Boolean)
} catch (error) {
console.error('[useRouteVisualization] polyline 转换失败:', error)
return []
}
}
/**
* 绘制单条路线
* @param {Cesium.Viewer} viewer - Cesium viewer 实例
* @param {Object} routeData - 路线数据
* @param {Object} styleOptions - 样式选项
* @returns {Cesium.Entity} 路线实体
*/
const drawRoute = (viewer, routeData, styleOptions = {}) => {
if (!viewer || !routeData || !routeData.polyline) {
console.warn('[useRouteVisualization] 参数无效')
return null
}
const positions = transformToCartesian3(routeData.polyline)
if (positions.length < 2) {
console.warn('[useRouteVisualization] 路线点数不足')
return null
}
// 获取样式
const type = routeData.isFallback ? 'fallback' : (routeData.type || 'personnel')
const style = ROUTE_STYLES[type] || ROUTE_STYLES.personnel
// 创建路线实体
const entity = viewer.entities.add({
polyline: {
positions: positions,
width: styleOptions.width || style.width,
material: styleOptions.material || style.material(viewer),
clampToGround: true,
classificationType: Cesium.ClassificationType.TERRAIN
},
properties: {
type: 'route',
routeType: type,
routeId: routeData.id,
routeName: routeData.name,
distance: routeData.distance,
duration: routeData.duration,
isFallback: routeData.isFallback || false
}
})
routeEntities.value.push(entity)
console.log(`[useRouteVisualization] 绘制路线: ${routeData.name} (${type})`)
return entity
}
/**
* 绘制多条路线
* @param {Cesium.Viewer} viewer - Cesium viewer 实例
* @param {Array} routesArray - 路线数组
* @returns {Array<Cesium.Entity>} 路线实体数组
*/
const drawMultipleRoutes = (viewer, routesArray) => {
if (!viewer || !routesArray || routesArray.length === 0) {
console.warn('[useRouteVisualization] 参数无效')
return []
}
console.log(`[useRouteVisualization] 绘制 ${routesArray.length} 条路线`)
const entities = []
routesArray.forEach((route, index) => {
try {
// 绘制路线
const routeEntity = drawRoute(viewer, route)
if (routeEntity) {
entities.push(routeEntity)
// 添加标签
const labelEntity = addRouteLabel(viewer, route)
if (labelEntity) {
entities.push(labelEntity)
}
}
} catch (error) {
console.error(`[useRouteVisualization] 绘制路线 ${route.name} 失败:`, error)
}
})
console.log(`[useRouteVisualization] 成功绘制 ${entities.length / 2} 条路线`)
return entities
}
/**
* 添加路线标签显示距离和时间
* @param {Cesium.Viewer} viewer - Cesium viewer 实例
* @param {Object} routeData - 路线数据
* @returns {Cesium.Entity} 标签实体
*/
const addRouteLabel = (viewer, routeData) => {
if (!viewer || !routeData || !routeData.polyline) {
return null
}
const positions = transformToCartesian3(routeData.polyline)
if (positions.length < 2) {
return null
}
// 计算中点位置
const midIndex = Math.floor(positions.length / 2)
const labelPosition = positions[midIndex]
// 格式化文本
const distanceText = formatDistance(routeData.distance)
const durationText = formatDuration(routeData.duration)
const labelText = `${distanceText} | ${durationText}`
// 添加降级标识
const finalText = routeData.isFallback ? `${labelText} (直线)` : labelText
// 获取标签颜色
const type = routeData.isFallback ? 'fallback' : (routeData.type || 'personnel')
const style = ROUTE_STYLES[type] || ROUTE_STYLES.personnel
// 创建标签实体
const entity = viewer.entities.add({
position: labelPosition,
label: {
text: finalText,
font: LABEL_STYLE.font,
fillColor: LABEL_STYLE.fillColor,
outlineColor: LABEL_STYLE.outlineColor,
outlineWidth: LABEL_STYLE.outlineWidth,
style: LABEL_STYLE.style,
pixelOffset: LABEL_STYLE.pixelOffset,
disableDepthTestDistance: LABEL_STYLE.disableDepthTestDistance,
heightReference: LABEL_STYLE.heightReference,
verticalOrigin: LABEL_STYLE.verticalOrigin,
// 添加背景
showBackground: true,
backgroundColor: Cesium.Color.BLACK.withAlpha(0.5),
backgroundPadding: new Cesium.Cartesian2(7, 5)
},
properties: {
type: 'routeLabel',
routeId: routeData.id,
routeName: routeData.name
}
})
labelEntities.value.push(entity)
return entity
}
/**
* 清除所有路线实体
* @param {Cesium.Viewer} viewer - Cesium viewer 实例
*/
const clearRoutes = (viewer) => {
if (!viewer) return
// 清除路线实体
routeEntities.value.forEach(entity => {
if (entity) {
viewer.entities.remove(entity)
}
})
// 清除标签实体
labelEntities.value.forEach(entity => {
if (entity) {
viewer.entities.remove(entity)
}
})
routeEntities.value = []
labelEntities.value = []
console.log('[useRouteVisualization] 已清除所有路线')
}
/**
* 清除指定路线
* @param {Cesium.Viewer} viewer - Cesium viewer 实例
* @param {string} routeId - 路线 ID
*/
const clearRoute = (viewer, routeId) => {
if (!viewer || !routeId) return
// 清除路线实体
routeEntities.value = routeEntities.value.filter(entity => {
if (entity && entity.properties && entity.properties.routeId === routeId) {
viewer.entities.remove(entity)
return false
}
return true
})
// 清除标签实体
labelEntities.value = labelEntities.value.filter(entity => {
if (entity && entity.properties && entity.properties.routeId === routeId) {
viewer.entities.remove(entity)
return false
}
return true
})
console.log(`[useRouteVisualization] 已清除路线: ${routeId}`)
}
/**
* 获取所有路线的边界框
* @param {Array} routesArray - 路线数组
* @returns {Array} 边界点数组 [{ lon, lat }]
*/
const getRoutesBounds = (routesArray) => {
const allPoints = []
routesArray.forEach(route => {
if (route.polyline) {
const positions = transformToCartesian3(route.polyline)
positions.forEach(pos => {
const carto = Cesium.Cartographic.fromCartesian(pos)
allPoints.push({
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude)
})
})
}
})
return allPoints
}
return {
// 状态
routeEntities,
labelEntities,
// 方法
drawRoute,
drawMultipleRoutes,
addRouteLabel,
clearRoutes,
clearRoute,
getRoutesBounds,
transformToCartesian3,
// 样式常量
ROUTE_STYLES
}
}
export default useRouteVisualization

View File

@ -0,0 +1,196 @@
import { ref } from 'vue'
import * as Cesium from 'cesium'
import { applyRandomOffset } from '../utils/geoUtils'
import soldierIcon from '../assets/images/SketchPngfbec927027ff9e49207749ebaafd229429315341fda199251b6dfb1723ff17fb.png'
import deviceIcon from '../assets/images/SketchPng860d54f2a31f5f441fc6a88081224f1e98534bf6d5ca1246e420983bdf690380.png'
/**
* Composable for managing simulated emergency markers
*
* Manages 6 markers total:
* - 2 at disaster center (static)
* - 4 at the 2 nearest reserve centers (will be animated on dispatch)
*/
export function useSimulatedMarkers() {
const simulatedMarkers = ref([])
/**
* Create all 6 simulated markers
* @param {Cesium.Viewer} viewer
* @param {Object} disasterCenter - { lon, lat }
* @param {Array} emergencyPoints - Top 2 nearest emergency points [{ gl1Lng, gl1Lat, gl1Yjllmc, gl1Id, ... }, ...]
*/
const createSimulatedMarkers = (viewer, disasterCenter, emergencyPoints) => {
if (!viewer || !disasterCenter || !emergencyPoints || emergencyPoints.length < 2) {
console.error('[useSimulatedMarkers] Invalid parameters')
return
}
// Clear existing simulated markers
clearSimulatedMarkers(viewer)
const newMarkers = []
// 1. Create 2 center markers (static, no animation)
const centerPersonnelCoords = applyRandomOffset(disasterCenter, 10, 30)
const centerPersonnel = viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(centerPersonnelCoords.lon, centerPersonnelCoords.lat, 0),
billboard: {
image: soldierIcon,
width: 36,
height: 40,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY
},
properties: new Cesium.PropertyBag({
type: 'centerPersonnel',
name: '应急人员',
isSimulated: true,
isStatic: true
})
})
newMarkers.push(centerPersonnel)
const centerEquipmentCoords = applyRandomOffset(disasterCenter, 10, 30)
const centerEquipment = viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(centerEquipmentCoords.lon, centerEquipmentCoords.lat, 0),
billboard: {
image: deviceIcon,
width: 36,
height: 40,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY
},
properties: new Cesium.PropertyBag({
type: 'centerEquipment',
name: '应急装备',
isSimulated: true,
isStatic: true
})
})
newMarkers.push(centerEquipment)
// 2. Create 4 emergency point markers (will be animated later)
emergencyPoints.slice(0, 2).forEach((point, index) => {
const pointCoords = { lon: point.gl1Lng, lat: point.gl1Lat }
const pointName = point.gl1Yjllmc || `应急点${index + 1}`
// Personnel marker
const personnelCoords = applyRandomOffset(pointCoords, 10, 30)
const personnelMarker = viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(personnelCoords.lon, personnelCoords.lat, 0),
billboard: {
image: soldierIcon,
width: 36,
height: 40,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY
},
properties: new Cesium.PropertyBag({
type: 'emergencyPersonnel',
name: `${pointName} - 应急人员`,
isSimulated: true,
isStatic: false,
emergencyPointId: point.gl1Id,
emergencyPointCoords: pointCoords,
markerCoords: personnelCoords
})
})
newMarkers.push(personnelMarker)
// Equipment marker
const equipmentCoords = applyRandomOffset(pointCoords, 10, 30)
const equipmentMarker = viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(equipmentCoords.lon, equipmentCoords.lat, 0),
billboard: {
image: deviceIcon,
width: 36,
height: 40,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY
},
properties: new Cesium.PropertyBag({
type: 'emergencyEquipment',
name: `${pointName} - 应急装备`,
isSimulated: true,
isStatic: false,
emergencyPointId: point.gl1Id,
emergencyPointCoords: pointCoords,
markerCoords: equipmentCoords
})
})
newMarkers.push(equipmentMarker)
})
simulatedMarkers.value = newMarkers
console.log(`[useSimulatedMarkers] Created ${newMarkers.length} simulated markers`)
return newMarkers
}
/**
* Clear all simulated markers
*/
const clearSimulatedMarkers = (viewer) => {
if (!viewer) return
simulatedMarkers.value.forEach(entity => {
viewer.entities.remove(entity)
})
simulatedMarkers.value = []
console.log('[useSimulatedMarkers] Cleared all simulated markers')
}
/**
* Hide emergency point markers (before starting animation)
*/
const hideEmergencyMarkers = (viewer) => {
if (!viewer) return
viewer.entities.values.forEach(entity => {
if (entity.properties) {
const type = entity.properties.type?.getValue()
const isStatic = entity.properties.isStatic?.getValue()
if ((type === 'emergencyPersonnel' || type === 'emergencyEquipment') && !isStatic) {
entity.show = false
}
}
})
console.log('[useSimulatedMarkers] Hidden emergency markers')
}
/**
* Get emergency marker data for animation
* @returns {Array} [{ type, name, markerCoords: { lon, lat }, emergencyPointCoords: { lon, lat } }, ...]
*/
const getEmergencyMarkerData = () => {
return simulatedMarkers.value
.filter(entity => {
const isStatic = entity.properties?.isStatic?.getValue()
return !isStatic
})
.map(entity => {
const props = entity.properties
return {
type: props.type.getValue(),
name: props.name.getValue(),
markerCoords: props.markerCoords.getValue(),
emergencyPointCoords: props.emergencyPointCoords.getValue()
}
})
}
return {
simulatedMarkers,
createSimulatedMarkers,
clearSimulatedMarkers,
hideEmergencyMarkers,
getEmergencyMarkerData
}
}

View File

@ -0,0 +1,48 @@
/**
* 高德地图 API 配置
* 用于路径规划和地理编码服务
*/
export const AMAP_CONFIG = {
// Web 服务 API Key
webServiceKey: 'c30e9ebd414fd6a4dfcc1ba8c2060dbb',
// API 基础 URL
baseUrl: 'https://restapi.amap.com',
// API 版本
apiVersion: 'v3',
// 请求超时时间(毫秒)
timeout: 10000,
// 重试次数
retryAttempts: 2,
// 缓存过期时间(毫秒)- 24小时
cacheExpiry: 24 * 60 * 60 * 1000,
// 路径规划策略
// 0: 速度优先(时间)
// 1: 费用优先(不走收费路段的最快道路)
// 2: 距离优先
// 3: 不走快速路
strategy: 0
}
/**
* 高德地图 API 端点
*/
export const AMAP_ENDPOINTS = {
// 驾车路径规划
driving: '/v3/direction/driving',
// 步行路径规划
walking: '/v3/direction/walking',
// 地理编码
geocode: '/v3/geocode/geo',
// 逆地理编码
regeocode: '/v3/geocode/regeo'
}

View File

@ -0,0 +1,84 @@
/**
* 路线样式配置
* 定义不同类型路线的可视化样式
*/
import * as Cesium from 'cesium'
/**
* 路线样式预设
*/
export const ROUTE_STYLES = {
// 人员路线样式
personnel: {
color: Cesium.Color.CYAN,
width: 5,
material: (viewer) => new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.3,
color: Cesium.Color.CYAN
}),
labelColor: Cesium.Color.CYAN
},
// 装备路线样式
equipment: {
color: Cesium.Color.ORANGE,
width: 6,
material: (viewer) => new Cesium.PolylineDashMaterialProperty({
color: Cesium.Color.ORANGE,
dashLength: 20.0,
dashPattern: 255
}),
labelColor: Cesium.Color.ORANGE
},
// 降级直线样式API 失败时使用)
fallback: {
color: Cesium.Color.YELLOW.withAlpha(0.6),
width: 3,
material: (viewer) => new Cesium.PolylineDashMaterialProperty({
color: Cesium.Color.YELLOW.withAlpha(0.6),
dashLength: 10.0,
dashPattern: 255
}),
labelColor: Cesium.Color.YELLOW
}
}
/**
* 标签样式配置
*/
export const LABEL_STYLE = {
font: '14px SourceHanSansCN-Medium, sans-serif',
fillColor: Cesium.Color.WHITE,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -10),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM
}
/**
* 路线动画配置
*/
export const ROUTE_ANIMATION_CONFIG = {
// 动画速度倍数(相对于真实时间)
speedMultiplier: 10,
// 最小动画时长(秒)
minDuration: 30,
// 最大动画时长(秒)
maxDuration: 120,
// 轨迹发光强度
trailGlowPower: 0.4,
// 轨迹渐变效果
trailTaperPower: 0.5,
// 轨迹宽度
trailWidth: 8
}

View File

@ -61,6 +61,7 @@
@start-dispatch="handleStartDispatch" @start-dispatch="handleStartDispatch"
@view-plan="handleViewPlan" @view-plan="handleViewPlan"
@force-preset-toggle="handleForcePresetToggle" @force-preset-toggle="handleForcePresetToggle"
@quick-response-toggle="handleQuickResponseToggle"
/> />
</div> </div>
</Transition> </Transition>
@ -264,6 +265,8 @@ import { useMapClickHandler } from './composables/useMapClickHandler'
import { useRangeCircle } from './composables/useRangeCircle' import { useRangeCircle } from './composables/useRangeCircle'
import { usePathLines } from './composables/usePathLines' import { usePathLines } from './composables/usePathLines'
import { useEmergencyDispatch } from './composables/useEmergencyDispatch' import { useEmergencyDispatch } from './composables/useEmergencyDispatch'
import { useEmergencyRouteSelection } from './composables/useEmergencyRouteSelection'
import { useSimulatedMarkers } from './composables/useSimulatedMarkers'
// ========== ========== // ========== ==========
import { useMapStore } from '@/map' import { useMapStore } from '@/map'
@ -276,6 +279,7 @@ import {
DEFAULT_SEARCH_RADIUS, DEFAULT_SEARCH_RADIUS,
MARKER_ICON_SIZE, MARKER_ICON_SIZE,
} from './constants' } from './constants'
import { calculateDistance, isValidCoordinate } from './utils/geoUtils'
// ========== ========== // ========== ==========
import emergencyCenterIcon from './assets/images/应急中心.png' import emergencyCenterIcon from './assets/images/应急中心.png'
@ -382,13 +386,20 @@ const mapClickHandler = useMapClickHandler({
}) })
// //
const { showLoading, startDispatch } = useEmergencyDispatch({ const { showLoading, loadingMessage, startDispatch, startDispatchWithRouting } = useEmergencyDispatch({
pathLinesComposable, pathLinesComposable,
entityAnimationComposable, entityAnimationComposable,
mapStore, mapStore,
registerTimeoutFn: registerTimeout, registerTimeoutFn: registerTimeout,
}) })
//
const { selectByType } = useEmergencyRouteSelection()
//
const { createSimulatedMarkers, clearSimulatedMarkers, hideEmergencyMarkers, getEmergencyMarkerData } =
useSimulatedMarkers()
// ==================== // ====================
// UI // UI
// ==================== // ====================
@ -400,6 +411,9 @@ const isRightPanelCollapsed = ref(false)
// //
const showMarkersAndRange = ref(false) const showMarkersAndRange = ref(false)
//
const quickResponseExecuted = ref(false)
// - // -
const activeToolKey = ref('modelCompare') const activeToolKey = ref('modelCompare')
@ -530,8 +544,33 @@ const handleVideoModalClose = () => {
/** /**
* 处理力量调度启动事件 * 处理力量调度启动事件
*/ */
const handleStartDispatch = (payload) => { const handleStartDispatch = async (payload) => {
startDispatch(payload) console.log('[index.vue] 启动响应调度')
// Check if Quick Response has been executed
if (!quickResponseExecuted.value) {
console.log('[index.vue] 快速响应未执行,自动执行...')
await executeQuickResponse()
quickResponseExecuted.value = true
}
// Get emergency marker data
const emergencyMarkerData = getEmergencyMarkerData()
if (emergencyMarkerData.length === 0) {
ElMessage.error('未找到应急点数据')
return
}
// Start dispatch with routing using emergency points
startDispatchWithRouting({
emergencyPoints: emergencyMarkerData.map(marker => ({
lon: marker.markerCoords.lon,
lat: marker.markerCoords.lat,
type: marker.type === 'emergencyPersonnel' ? 'personnel' : 'equipment',
name: marker.name
}))
})
} }
/** /**
@ -545,14 +584,33 @@ const handleViewPlan = (plan) => {
/** /**
* 处理弹窗中的一键启动按钮点击事件 * 处理弹窗中的一键启动按钮点击事件
*/ */
const handleModalStartDispatch = () => { const handleModalStartDispatch = async () => {
console.log('[index.vue] 弹窗中点击一键启动') console.log('[index.vue] 弹窗中点击一键启动 - 使用智能路径规划')
showStretchableModal.value = false showStretchableModal.value = false
handleStartDispatch({
planName: '智能应急方案', // Check if Quick Response has been executed
plan: disasterData.forceDispatch.value.plan, if (!quickResponseExecuted.value) {
responseLevel: disasterData.forceDispatch.value.responseLevel, console.log('[index.vue] 快速响应未执行,自动执行...')
estimatedClearTime: disasterData.forceDispatch.value.estimatedClearTime, await executeQuickResponse()
quickResponseExecuted.value = true
}
// Get emergency marker data
const emergencyMarkerData = getEmergencyMarkerData()
if (emergencyMarkerData.length === 0) {
ElMessage.error('未找到应急点数据')
return
}
// Use emergency marker data for routing
startDispatchWithRouting({
emergencyPoints: emergencyMarkerData.map(marker => ({
lon: marker.markerCoords.lon,
lat: marker.markerCoords.lat,
type: marker.type === 'emergencyPersonnel' ? 'personnel' : 'equipment',
name: marker.name
}))
}) })
} }
@ -659,6 +717,185 @@ const handleForcePresetToggle = async () => {
} }
} }
/**
* 处理快速响应标题点击事件
*/
const handleQuickResponseToggle = async () => {
console.log('[index.vue] 快速响应标题点击')
if (!quickResponseExecuted.value) {
// Execute Quick Response
await executeQuickResponse()
quickResponseExecuted.value = true
} else {
// Hide Quick Response markers
hideQuickResponse()
quickResponseExecuted.value = false
}
}
/**
* 辅助函数判断是否为应急点标记
*/
const isEmergencyPointMarker = (type) => {
return type === 'cityEmergency' ||
type === 'districtEmergency' ||
type === 'otherEmergency'
}
/**
* 执行快速响应逻辑
*/
const executeQuickResponse = async () => {
const viewer = mapStore.viewer
if (!viewer) return
console.log('[index.vue] 执行快速响应...')
// 1. Hide all other markers
viewer.entities.values.forEach((entity) => {
if (entity.properties) {
const type = entity.properties.type?.getValue()
const isSimulated = entity.properties.isSimulated?.getValue()
// Hide non-simulated, non-emergency-point markers
if (!isSimulated && !isEmergencyPointMarker(type)) {
entity.show = false
}
}
})
// Hide emergency resource markers
hideMarkers()
// 2. Load emergency points within range
const rangeResponse = await request({
url: `/snow-ops-platform/yhYjll/list`,
method: 'GET',
params: {
longitude: DISASTER_CENTER.lon,
latitude: DISASTER_CENTER.lat,
maxDistance: disasterData.forcePreset.value.searchRadius, // 30 or 50 km
},
})
if (!rangeResponse?.data || !Array.isArray(rangeResponse.data)) {
ElMessage.error('加载应急点失败')
return
}
// 3. Display emergency points on map
await displayEmergencyPoints(rangeResponse.data)
// 4. Select 2 nearest emergency points
const emergencyPoints = rangeResponse.data
const pointsWithDistance = emergencyPoints.map(point => ({
...point,
distance: calculateDistance(
{ lon: DISASTER_CENTER.lon, lat: DISASTER_CENTER.lat },
{ lon: point.gl1Lng, lat: point.gl1Lat }
)
}))
const sortedPoints = pointsWithDistance.sort((a, b) => a.distance - b.distance)
const nearest2Points = sortedPoints.slice(0, 2)
if (nearest2Points.length < 2) {
ElMessage.warning('应急点数量不足2个')
return
}
console.log('[index.vue] 选择最近的2个应急点:', nearest2Points.map(p => p.gl1Yjllmc))
// 5. Create 6 simulated markers
createSimulatedMarkers(viewer, DISASTER_CENTER, nearest2Points)
// 6. Force render
viewer.scene.requestRender()
console.log('[index.vue] 快速响应执行完成')
}
/**
* 显示应急点标记
*/
const displayEmergencyPoints = async (emergencyPoints) => {
const viewer = mapStore.viewer
if (!viewer) return
// Clear existing emergency point markers
viewer.entities.values.forEach(entity => {
const type = entity.properties?.type?.getValue()
if (isEmergencyPointMarker(type)) {
viewer.entities.remove(entity)
}
})
// Add new emergency point markers
emergencyPoints.forEach(point => {
// Determine icon based on level
const levelString = String(point.gl1Lx || '').trim()
let icon = otherEmergencyIcon
let type = 'otherEmergency'
let levelName = '其他'
if (levelString.startsWith('1') || levelString === '1') {
icon = cityEmergencyIcon
type = 'cityEmergency'
levelName = '市级'
} else if (levelString.startsWith('2') || levelString === '2') {
icon = districtEmergencyIcon
type = 'districtEmergency'
levelName = '区县级'
}
viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(point.gl1Lng, point.gl1Lat, 0),
billboard: {
image: icon,
width: 29,
height: 32,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY
},
properties: new Cesium.PropertyBag({
type: type,
name: point.gl1Yjllmc,
level: levelName,
district: point.gl1Qxmc,
personnel: point.gl1Rysl,
area: point.gl1Zdmj
})
})
})
console.log(`[index.vue] 显示 ${emergencyPoints.length} 个应急点`)
}
/**
* 隐藏快速响应标记
*/
const hideQuickResponse = () => {
const viewer = mapStore.viewer
if (!viewer) return
console.log('[index.vue] 隐藏快速响应标记...')
// Hide emergency point markers
viewer.entities.values.forEach(entity => {
const type = entity.properties?.type?.getValue()
if (isEmergencyPointMarker(type)) {
entity.show = false
}
})
// Clear simulated markers
clearSimulatedMarkers(viewer)
console.log('[index.vue] 已隐藏快速响应标记')
}
/** /**
* 处理地图工具变化事件 * 处理地图工具变化事件
*/ */
@ -720,72 +957,84 @@ const showAllMarkersAndRange = async () => {
const viewer = mapStore.viewer const viewer = mapStore.viewer
if (!viewer) return if (!viewer) return
console.log('[index.vue] 开始显示快速响应标记...') // 1.
// viewer.entities.values.forEach((entity) => {
// if (entity.properties) {
// const props = entity.properties
// if (props.type?.getValue() === 'soldier' ||
// props.type?.getValue() === 'device' ||
// props.isPathStartMarker?.getValue()) {
// entity.show = true
// }
// }
// })
// 1. / // 2.
// 1.1 soldier/device // hideMarkers()
// 2. /
console.log('[index.vue] 加载全部储备中心/预置点到地图...')
await loadReserveCentersAndPresets(DISASTER_CENTER.lon, DISASTER_CENTER.lat, true)
// 3.
showRangeCircle()
console.log('[index.vue] 已显示所有标记点和范围圈')
}
/**
* 隐藏所有标记点和范围圈
*/
const hideAllMarkersAndRange = () => {
const viewer = mapStore.viewer
if (!viewer) return
// 1.
viewer.entities.values.forEach((entity) => { viewer.entities.values.forEach((entity) => {
if (entity.properties) { if (entity.properties) {
const props = entity.properties const props = entity.properties
const type = props.type?.getValue() if (props.type?.getValue() === 'soldier' ||
if (type === 'soldier' || props.type?.getValue() === 'device' ||
type === 'device' ||
props.isPathStartMarker?.getValue()) { props.isPathStartMarker?.getValue()) {
entity.show = false entity.show = false
} }
} }
}) })
// 1.2 // 2.
hideMarkers() hideMarkers()
console.log('[index.vue] 已清除其他标记') // 3.
hideRangeCircle()
// 2. / console.log('[index.vue] 已隐藏所有标记点和范围圈')
console.log('[index.vue] 加载全部储备中心/预置点到地图...') }
await loadReserveCentersAndPresets(DISASTER_CENTER.lon, DISASTER_CENTER.lat, true)
// 3. / /**
console.log('[index.vue] 加载范围内储备中心/预置点数据(用于面板显示)...') * 在选中的应急点位置显示人员/装备初始标记
const rangeResponse = await request({ * @param {Array} personnelPoints - 选中的人员应急点
url: `/snow-ops-platform/yhYjll/list`, * @param {Array} equipmentPoints - 选中的装备应急点
method: 'GET', */
params: { const showEmergencyPointMarkers = (personnelPoints, equipmentPoints) => {
longitude: DISASTER_CENTER.lon, const viewer = mapStore.viewer
latitude: DISASTER_CENTER.lat, if (!viewer) return
maxDistance: disasterData.forcePreset.value.searchRadius,
}, console.log('🔥🔥🔥 [NEW CODE] 在应急点位置添加人员/装备标记 🔥🔥🔥')
//
viewer.entities.values.forEach((entity) => {
if (entity.properties) {
const type = entity.properties.type?.getValue()
if (type === 'emergencyPersonnel' || type === 'emergencyEquipment') {
entity.show = false
}
}
}) })
// stations //
if (rangeResponse?.data && Array.isArray(rangeResponse.data)) { personnelPoints.forEach((point, index) => {
const transformedStations = disasterData.transformReserveDataToStations( viewer.entities.add({
rangeResponse.data, position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat, 0),
{ longitude: DISASTER_CENTER.lon, latitude: DISASTER_CENTER.lat }
)
if (transformedStations.length > 0) {
disasterData.forcePreset.value.stations = transformedStations
console.log('[index.vue] 已更新面板 stations范围内:', transformedStations.length, '个')
}
}
console.log(`[index.vue] 显示全部储备中心/预置点标记`)
// 4.
const offset = 0.0002 // 22
//
let existingCenterPersonnel = viewer.entities.values.find(
e => e.properties && e.properties.type && e.properties.type.getValue() === 'centerPersonnel'
)
let existingCenterEquipment = viewer.entities.values.find(
e => e.properties && e.properties.type && e.properties.type.getValue() === 'centerEquipment'
)
//
if (!existingCenterPersonnel) {
const personnelEntity = viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(DISASTER_CENTER.lon + offset, DISASTER_CENTER.lat, 0),
billboard: { billboard: {
image: soldierIcon, image: soldierIcon,
width: 36, width: 36,
@ -795,21 +1044,19 @@ const showAllMarkersAndRange = async () => {
disableDepthTestDistance: Number.POSITIVE_INFINITY disableDepthTestDistance: Number.POSITIVE_INFINITY
}, },
properties: new Cesium.PropertyBag({ properties: new Cesium.PropertyBag({
type: 'centerPersonnel', type: 'emergencyPersonnel',
name: '应急人员' name: point.name || `应急人员${index + 1}`,
emergencyPointId: point.id
}), }),
show: true show: true
}) })
console.log('[index.vue] 已添加中心点人员标记:', personnelEntity.id) console.log(`[index.vue] 已添加人员标记: ${point.name}`)
} else { })
existingCenterPersonnel.show = true
console.log('[index.vue] 显示已存在的中心点人员标记')
}
// //
if (!existingCenterEquipment) { equipmentPoints.forEach((point, index) => {
const equipmentEntity = viewer.entities.add({ viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(DISASTER_CENTER.lon - offset, DISASTER_CENTER.lat, 0), position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat, 0),
billboard: { billboard: {
image: deviceIcon, image: deviceIcon,
width: 36, width: 36,
@ -819,77 +1066,17 @@ const showAllMarkersAndRange = async () => {
disableDepthTestDistance: Number.POSITIVE_INFINITY disableDepthTestDistance: Number.POSITIVE_INFINITY
}, },
properties: new Cesium.PropertyBag({ properties: new Cesium.PropertyBag({
type: 'centerEquipment', type: 'emergencyEquipment',
name: '应急装备' name: point.name || `应急装备${index + 1}`,
emergencyPointId: point.id
}), }),
show: true show: true
}) })
console.log('[index.vue] 已添加中心点装备标记:', equipmentEntity.id) console.log(`[index.vue] 已添加装备标记: ${point.name}`)
} else { })
existingCenterEquipment.show = true
console.log('[index.vue] 显示已存在的中心点装备标记')
}
//
viewer.scene.requestRender() viewer.scene.requestRender()
console.log('[index.vue] 应急点标记显示完成')
// 5.
showRangeCircle()
console.log('[index.vue] 快速响应标记显示完成')
}
/**
* 隐藏所有标记点和范围圈
* 恢复到初始状态隐藏快速响应相关的标记
*/
const hideAllMarkersAndRange = () => {
const viewer = mapStore.viewer
if (!viewer) return
console.log('[index.vue] 开始隐藏快速响应标记...')
// 1. /
reserveCenterEntities.value.forEach((entity) => {
if (entity) {
entity.show = false
}
})
console.log(`[index.vue] 隐藏 ${reserveCenterEntities.value.length} 个储备中心/预置点标记`)
// 2.
viewer.entities.values.forEach((entity) => {
if (entity.properties?.type) {
const type = entity.properties.type.getValue()
if (type === 'centerPersonnel' || type === 'centerEquipment') {
entity.show = false
}
}
})
console.log('[index.vue] 隐藏中心点人员和装备标记')
// 3.
hideRangeCircle()
// 4.
//
// viewer.entities.values.forEach((entity) => {
// if (entity.properties) {
// const props = entity.properties
// const type = props.type?.getValue()
// if (type === 'soldier' ||
// type === 'device' ||
// props.isPathStartMarker?.getValue()) {
// entity.show = true
// }
// }
// })
// 5.
//
// showMarkers()
console.log('[index.vue] 快速响应标记隐藏完成')
} }
/** /**

View File

@ -0,0 +1,178 @@
/**
* 高德地图 API 客户端
* 封装路径规划 API 调用
*/
import axios from 'axios'
import { AMAP_CONFIG, AMAP_ENDPOINTS } from '../config/amap'
import { generateCacheKey } from './geoUtils'
// 路由缓存
const routeCache = new Map()
/**
* 清理过期缓存
*/
function cleanExpiredCache() {
const now = Date.now()
for (const [key, value] of routeCache.entries()) {
if (now - value.timestamp > AMAP_CONFIG.cacheExpiry) {
routeCache.delete(key)
}
}
}
// 定期清理缓存(每小时)
setInterval(cleanExpiredCache, 60 * 60 * 1000)
/**
* 调用高德地图驾车路径规划 API
* @param {Object} origin - 起点 { lon, lat }
* @param {Object} destination - 终点 { lon, lat }
* @param {Object} options - 可选参数
* @returns {Promise<Object>} 路径规划结果
*/
export async function calculateDrivingRoute(origin, destination, options = {}) {
const cacheKey = generateCacheKey(origin, destination)
// 检查缓存
if (routeCache.has(cacheKey)) {
const cached = routeCache.get(cacheKey)
console.log('[AmapAPI] 使用缓存路线:', cacheKey)
return cached.data
}
const url = `${AMAP_CONFIG.baseUrl}${AMAP_ENDPOINTS.driving}`
const params = {
key: AMAP_CONFIG.webServiceKey,
origin: `${origin.lon},${origin.lat}`,
destination: `${destination.lon},${destination.lat}`,
extensions: 'all', // 返回详细路径
output: 'json',
strategy: options.strategy ?? AMAP_CONFIG.strategy
}
try {
console.log('[AmapAPI] 请求路径规划:', params)
const response = await axios.get(url, {
params,
timeout: AMAP_CONFIG.timeout
})
if (response.data.status !== '1') {
throw new Error(`高德API错误: ${response.data.info}`)
}
if (!response.data.route || !response.data.route.paths || response.data.route.paths.length === 0) {
throw new Error('未找到可用路线')
}
const path = response.data.route.paths[0]
// 提取路径数据
const routeData = {
distance: parseInt(path.distance), // 米
duration: parseInt(path.duration), // 秒
polyline: extractPolyline(path.steps),
steps: path.steps.map(step => ({
instruction: step.instruction,
distance: parseInt(step.distance),
duration: parseInt(step.duration)
}))
}
// 缓存结果
routeCache.set(cacheKey, {
data: routeData,
timestamp: Date.now()
})
console.log('[AmapAPI] 路径规划成功:', {
distance: routeData.distance,
duration: routeData.duration,
points: routeData.polyline.split(';').length
})
return routeData
} catch (error) {
console.error('[AmapAPI] 路径规划失败:', error.message)
throw error
}
}
/**
* 从步骤中提取完整的 polyline
* @param {Array} steps - 路径步骤数组
* @returns {string} 完整的 polyline 字符串
*/
function extractPolyline(steps) {
const allPolylines = steps
.map(step => step.polyline)
.filter(Boolean)
return allPolylines.join(';')
}
/**
* 批量计算路线并行请求
* @param {Array} routePairs - 路线对数组 [{ origin, destination, type, name, id }]
* @returns {Promise<Array>} 路线结果数组
*/
export async function calculateMultipleRoutes(routePairs) {
console.log(`[AmapAPI] 批量计算 ${routePairs.length} 条路线`)
const promises = routePairs.map(async (pair) => {
try {
const routeData = await calculateDrivingRoute(pair.origin, pair.destination)
return {
id: pair.id,
name: pair.name,
type: pair.type,
origin: pair.origin,
destination: pair.destination,
...routeData,
success: true
}
} catch (error) {
console.warn(`[AmapAPI] 路线计算失败 (${pair.name}):`, error.message)
return {
id: pair.id,
name: pair.name,
type: pair.type,
origin: pair.origin,
destination: pair.destination,
error: error.message,
success: false
}
}
})
const results = await Promise.all(promises)
const successCount = results.filter(r => r.success).length
console.log(`[AmapAPI] 批量计算完成: ${successCount}/${routePairs.length} 成功`)
return results
}
/**
* 清除所有缓存
*/
export function clearRouteCache() {
routeCache.clear()
console.log('[AmapAPI] 缓存已清空')
}
/**
* 获取缓存统计信息
*/
export function getCacheStats() {
return {
size: routeCache.size,
keys: Array.from(routeCache.keys())
}
}

View File

@ -0,0 +1,171 @@
/**
* 高德 API 诊断工具
* 在浏览器控制台运行此脚本诊断 API 问题
*/
async function diagnoseAmapAPI() {
console.log('='.repeat(60))
console.log('🔍 高德地图 API 诊断工具')
console.log('='.repeat(60))
console.log('')
// 1. 读取当前配置
console.log('📋 步骤 1/5: 检查当前配置')
console.log('-'.repeat(60))
const currentKey = 'c30e9ebd414fd6a4dfcc1ba8c2060dbb'
console.log('当前 API Key:', currentKey)
console.log('Key 长度:', currentKey.length, currentKey.length === 32 ? '✅' : '❌ (应该是32位)')
console.log('')
// 2. 测试 API 连接
console.log('📋 步骤 2/5: 测试 API 连接')
console.log('-'.repeat(60))
const testOrigin = '108.020961,30.186459'
const testDestination = '108.010961,30.176459'
const testUrl = `https://restapi.amap.com/v3/direction/driving?key=${currentKey}&origin=${testOrigin}&destination=${testDestination}&extensions=all&output=json`
try {
const response = await fetch(testUrl)
const data = await response.json()
console.log('API 响应状态:', response.status)
console.log('API 返回数据:', data)
console.log('')
// 3. 分析响应
console.log('📋 步骤 3/5: 分析 API 响应')
console.log('-'.repeat(60))
if (data.status === '1') {
console.log('✅ API 调用成功!')
console.log('路线距离:', data.route.paths[0].distance, '米')
console.log('预计时间:', data.route.paths[0].duration, '秒')
console.log('')
console.log('🎉 恭喜!你的 API Key 配置正确,可以正常使用路径规划功能。')
} else {
console.log('❌ API 调用失败')
console.log('错误代码:', data.infocode)
console.log('错误信息:', data.info)
console.log('')
// 4. 错误诊断
console.log('📋 步骤 4/5: 错误诊断')
console.log('-'.repeat(60))
const errorMap = {
'10001': {
name: 'INVALID_USER_KEY',
reason: 'Key 不存在或已过期',
solution: [
'1. 检查 Key 是否复制完整',
'2. 登录高德控制台确认 Key 状态',
'3. 如果 Key 已删除,请创建新的 Key'
]
},
'10003': {
name: 'DAILY_QUERY_OVER_LIMIT',
reason: '访问次数超出限制',
solution: [
'1. 等待明天配额重置',
'2. 升级到付费套餐',
'3. 临时使用直线距离功能'
]
},
'10004': {
name: 'IP_QUERY_OVER_LIMIT',
reason: 'IP 白名单校验失败',
solution: [
'1. 在控制台将当前 IP 添加到白名单',
'2. 或者删除 IP 白名单限制'
]
},
'10005': {
name: 'INVALID_USER_SCODE',
reason: '用户签名未通过',
solution: [
'1. 检查是否配置了数字签名',
'2. 如果不需要签名,在控制台关闭'
]
},
'10009': {
name: 'USERKEY_PLAT_NOMATCH',
reason: '⚠️ Key 的类型与请求的服务不匹配',
solution: [
'❌ 你提供的是 "Web 端JS API" 类型的 Key',
'✅ 需要的是 "Web 服务 API" 类型的 Key',
'',
'解决步骤:',
'1. 访问 https://console.amap.com/dev/key/app',
'2. 点击"添加 Key"',
'3. ⭐ 服务平台选择 "Web 服务"',
'4. 提交后复制新的 Key',
'5. 替换代码中的 Key'
]
},
'10010': {
name: 'IP_ACCESS_LIMITED',
reason: 'IP 访问受限',
solution: [
'1. 检查 IP 白名单配置',
'2. 确认当前 IP 在允许列表中'
]
},
'20003': {
name: 'INVALID_PARAMS',
reason: '请求参数非法',
solution: [
'1. 检查坐标格式是否正确',
'2. 确认坐标在中国境内'
]
}
}
const error = errorMap[data.infocode]
if (error) {
console.log('错误名称:', error.name)
console.log('问题原因:', error.reason)
console.log('')
console.log('解决方案:')
error.solution.forEach((step, index) => {
console.log(step)
})
} else {
console.log('未知错误代码,请查阅高德官方文档:')
console.log('https://lbs.amap.com/api/webservice/guide/tools/info')
}
}
} catch (error) {
console.log('❌ 网络请求失败')
console.log('错误信息:', error.message)
console.log('')
console.log('可能原因:')
console.log('1. 网络连接问题')
console.log('2. CORS 跨域限制正常Web 服务 API 不受影响)')
console.log('3. 防火墙阻止了请求')
}
console.log('')
console.log('📋 步骤 5/5: 建议操作')
console.log('-'.repeat(60))
console.log('')
console.log('👉 立即操作:')
console.log('1. 访问: https://console.amap.com/dev/key/app')
console.log('2. 创建 "Web 服务" 类型的 Key')
console.log('3. 开通 "路径规划" 服务')
console.log('4. 替换代码中的 Key')
console.log('')
console.log('📚 相关文档:')
console.log('- API 问题诊断: 项目根目录/API问题诊断.md')
console.log('- 测试指南: 项目根目录/如何测试路径规划.md')
console.log('')
console.log('='.repeat(60))
console.log('诊断完成!')
console.log('='.repeat(60))
}
// 自动运行诊断
console.log('⏳ 开始诊断...\n')
diagnoseAmapAPI()

View File

@ -0,0 +1,154 @@
/**
* 地理计算工具函数
* 提供距离计算坐标转换等功能
*/
/**
* 使用 Haversine 公式计算两点之间的直线距离
* @param {Object} point1 - 第一个点 { lon, lat }
* @param {Object} point2 - 第二个点 { lon, lat }
* @returns {number} 距离公里
*/
export function calculateDistance(point1, point2) {
const R = 6371 // 地球半径(公里)
const toRad = (deg) => deg * (Math.PI / 180)
const dLat = toRad(point2.lat - point1.lat)
const dLon = toRad(point2.lon - point1.lon)
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(point1.lat)) *
Math.cos(toRad(point2.lat)) *
Math.sin(dLon / 2) ** 2
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
/**
* 生成缓存键
* @param {Object} origin - 起点 { lon, lat }
* @param {Object} destination - 终点 { lon, lat }
* @returns {string} 缓存键
*/
export function generateCacheKey(origin, destination) {
return `${origin.lon.toFixed(6)},${origin.lat.toFixed(6)}_${destination.lon.toFixed(6)},${destination.lat.toFixed(6)}`
}
/**
* 简化路线减少路点数量
* @param {string} polyline - 高德 polyline 字符串
* @param {number} maxPoints - 最大路点数
* @returns {string} 简化后的 polyline
*/
export function simplifyRoute(polyline, maxPoints = 50) {
const points = polyline.split(';')
if (points.length <= maxPoints) {
return polyline
}
const step = Math.ceil(points.length / maxPoints)
const simplified = points.filter((_, index) => index % step === 0)
// 确保保留终点
if (simplified[simplified.length - 1] !== points[points.length - 1]) {
simplified.push(points[points.length - 1])
}
return simplified.join(';')
}
/**
* 验证坐标是否有效
* @param {Object} point - 坐标点 { lon, lat }
* @returns {boolean} 是否有效
*/
export function isValidCoordinate(point) {
if (!point || typeof point.lon !== 'number' || typeof point.lat !== 'number') {
return false
}
// 中国境内经纬度范围
const isLonValid = point.lon >= 73 && point.lon <= 135
const isLatValid = point.lat >= 3 && point.lat <= 54
return isLonValid && isLatValid
}
/**
* 格式化距离显示
* @param {number} meters - 距离
* @returns {string} 格式化后的距离字符串
*/
export function formatDistance(meters) {
if (meters < 1000) {
return `${Math.round(meters)}`
}
return `${(meters / 1000).toFixed(1)}公里`
}
/**
* 格式化时间显示
* @param {number} seconds - 时间
* @returns {string} 格式化后的时间字符串
*/
export function formatDuration(seconds) {
const minutes = Math.ceil(seconds / 60)
if (minutes < 60) {
return `${minutes}分钟`
}
const hours = Math.floor(minutes / 60)
const remainingMinutes = minutes % 60
if (remainingMinutes === 0) {
return `${hours}小时`
}
return `${hours}小时${remainingMinutes}分钟`
}
/**
* Generate random offset in degrees for 10-30m distance
* @param {number} minMeters - Minimum distance in meters (default: 10)
* @param {number} maxMeters - Maximum distance in meters (default: 30)
* @returns {Object} { lonOffset, latOffset }
*/
export function generateRandomOffset(minMeters = 10, maxMeters = 30) {
// 1 degree latitude ≈ 111km
// At 30° latitude, 1 degree longitude ≈ 96km
const LAT_METERS_PER_DEGREE = 111000
const LON_METERS_PER_DEGREE = 96000 // Approximate for China region
// Random distance between min and max
const distance = minMeters + Math.random() * (maxMeters - minMeters)
// Random angle (0-360 degrees)
const angle = Math.random() * 2 * Math.PI
// Calculate offsets
const latOffset = (distance * Math.cos(angle)) / LAT_METERS_PER_DEGREE
const lonOffset = (distance * Math.sin(angle)) / LON_METERS_PER_DEGREE
return { lonOffset, latOffset }
}
/**
* Apply random offset to coordinates
* @param {Object} coords - { lon, lat }
* @param {number} minMeters - Min distance (default: 10)
* @param {number} maxMeters - Max distance (default: 30)
* @returns {Object} { lon, lat }
*/
export function applyRandomOffset(coords, minMeters = 10, maxMeters = 30) {
const { lonOffset, latOffset } = generateRandomOffset(minMeters, maxMeters)
return {
lon: coords.lon + lonOffset,
lat: coords.lat + latOffset
}
}