feat(3d-situational-awareness): 集成高德地图路线规划用于应急调度
添加高德地图路线规划、路线可视化、紧急点选择及模拟标记的可组合项。 更新紧急调度逻辑,使用高德地图API计算动态路线,并在无法获取路线时回退至直线路径。 通过基于路线的移动和脉冲控制增强实体动画效果。 增加地理工具、API客户端及路由功能的诊断工具。
This commit is contained in:
parent
918c6c8341
commit
c6f47c8730
@ -21,7 +21,11 @@
|
||||
<ForcePreset />
|
||||
</CollapsiblePanel>
|
||||
|
||||
<CollapsiblePanel title="快速响应" subtitle="「力量调度」">
|
||||
<CollapsiblePanel
|
||||
title="快速响应"
|
||||
subtitle="「力量调度」"
|
||||
@title-click="handleQuickResponseToggle"
|
||||
>
|
||||
<ForceDispatch
|
||||
@start-dispatch="handleStartDispatch"
|
||||
@view-plan="handleViewPlan"
|
||||
@ -137,6 +141,14 @@ const handleForcePresetToggle = () => {
|
||||
console.log('[LeftPanel] 快速匹配标题被点击')
|
||||
emit('force-preset-toggle')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理快速响应面板标题点击事件
|
||||
*/
|
||||
const handleQuickResponseToggle = () => {
|
||||
console.log('[LeftPanel] 快速响应标题被点击')
|
||||
emit('quick-response-toggle')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@ -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
|
||||
@ -1,6 +1,16 @@
|
||||
import { ref } from 'vue'
|
||||
import * as Cesium from 'cesium'
|
||||
import { ElMessage, ElNotification } from 'element-plus'
|
||||
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 动画、路径线绘制、人员移动动画
|
||||
* 3. 管理相机飞行到全景位置
|
||||
* 4. 清理路径起点标记
|
||||
* 5. 集成高德地图路径规划
|
||||
*
|
||||
* @param {Object} dependencies - 依赖的 composables
|
||||
* @param {Object} dependencies.pathLinesComposable - usePathLines 返回的对象
|
||||
@ -25,18 +36,172 @@ export function useEmergencyDispatch({
|
||||
registerTimeoutFn,
|
||||
}) {
|
||||
const { drawAllPathLines } = pathLinesComposable
|
||||
const { startPersonnelMovement, startMultipleMovements, isAnimating } =
|
||||
const { startPersonnelMovement, startMultipleMovements, startMultipleAnimationsWithRoutes, isAnimating } =
|
||||
entityAnimationComposable
|
||||
|
||||
// 初始化路由相关 composables
|
||||
const { calculateRoutes, isCalculating } = useAmapRouting()
|
||||
const { selectByType } = useEmergencyRouteSelection()
|
||||
const { drawMultipleRoutes, clearRoutes, getRoutesBounds } = useRouteVisualization()
|
||||
|
||||
// Loading 状态
|
||||
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 - 调度参数
|
||||
*/
|
||||
const startDispatch = (payload) => {
|
||||
console.log('[useEmergencyDispatch] 启动力量调度:', payload)
|
||||
console.log('[useEmergencyDispatch] 启动力量调度(硬编码路径):', payload)
|
||||
|
||||
// 防止重复启动
|
||||
if (isAnimating.value) {
|
||||
@ -52,6 +217,7 @@ export function useEmergencyDispatch({
|
||||
|
||||
// 1. 显示 loading 动画
|
||||
showLoading.value = true
|
||||
loadingMessage.value = '正在加载...'
|
||||
|
||||
// 2. 绘制红色路径线
|
||||
drawAllPathLines(viewer, {
|
||||
@ -69,6 +235,11 @@ export function useEmergencyDispatch({
|
||||
registerTimeoutFn(timeoutId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟函数
|
||||
*/
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* 执行调度步骤
|
||||
* @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 = () => {
|
||||
@ -168,8 +366,14 @@ export function useEmergencyDispatch({
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
showLoading,
|
||||
startDispatch,
|
||||
loadingMessage,
|
||||
isCalculating,
|
||||
|
||||
// 方法
|
||||
startDispatch, // 旧版(硬编码路径)
|
||||
startDispatchWithRouting, // 新版(高德地图路径规划)
|
||||
stopDispatch,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -143,11 +143,13 @@ export function useEntityAnimation() {
|
||||
})
|
||||
|
||||
// 创建脉冲缩放效果 - 让图标闪烁更醒目
|
||||
const pulseScale = new Cesium.CallbackProperty((time) => {
|
||||
const elapsed = Cesium.JulianDate.secondsDifference(time, startTime)
|
||||
// 使用正弦波产生脉冲效果,频率 3Hz,幅度 ±30%
|
||||
return 1.0 + Math.sin(elapsed * 3) * 0.3
|
||||
}, false)
|
||||
const pulseScale = options.disablePulse
|
||||
? 1.0
|
||||
: new Cesium.CallbackProperty((time) => {
|
||||
const elapsed = Cesium.JulianDate.secondsDifference(time, startTime)
|
||||
// 使用正弦波产生脉冲效果,频率 3Hz,幅度 ±30%
|
||||
return 1.0 + Math.sin(elapsed * 3) * 0.3
|
||||
}, false)
|
||||
|
||||
// 创建动画实体
|
||||
const entity = viewer.entities.add({
|
||||
@ -314,10 +316,12 @@ export function useEntityAnimation() {
|
||||
positionProperty.addSample(time, position)
|
||||
})
|
||||
|
||||
const pulseScale = new Cesium.CallbackProperty((time) => {
|
||||
const elapsed = Cesium.JulianDate.secondsDifference(time, startTime)
|
||||
return 1.0 + Math.sin(elapsed * 3) * 0.3
|
||||
}, false)
|
||||
const pulseScale = config.disablePulse
|
||||
? 1.0
|
||||
: new Cesium.CallbackProperty((time) => {
|
||||
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 trailColor = config.type === 'device' ? Cesium.Color.ORANGE : Cesium.Color.CYAN
|
||||
@ -430,6 +434,9 @@ export function useEntityAnimation() {
|
||||
|
||||
viewer.clock.shouldAnimate = false
|
||||
|
||||
// 停止所有脉冲效果
|
||||
stopAllPulses(viewer)
|
||||
|
||||
if (animatedEntity.value) {
|
||||
viewer.entities.remove(animatedEntity.value)
|
||||
animatedEntity.value = null
|
||||
@ -444,6 +451,235 @@ export function 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 {
|
||||
animatedEntity,
|
||||
animatedEntities,
|
||||
@ -458,7 +694,13 @@ export function useEntityAnimation() {
|
||||
cartesianToLonLat,
|
||||
PERSONNEL_PATH_COORDINATES,
|
||||
DEVICE_PATH_COORDINATES,
|
||||
PERSONNEL_PATH_COORDINATES_2
|
||||
PERSONNEL_PATH_COORDINATES_2,
|
||||
// 新增:动态路线动画方法
|
||||
createAnimationFromRoute,
|
||||
startMultipleAnimationsWithRoutes,
|
||||
// 新增:脉冲控制方法
|
||||
checkAnimationsCompletion,
|
||||
stopAllPulses
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -61,6 +61,7 @@
|
||||
@start-dispatch="handleStartDispatch"
|
||||
@view-plan="handleViewPlan"
|
||||
@force-preset-toggle="handleForcePresetToggle"
|
||||
@quick-response-toggle="handleQuickResponseToggle"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
@ -264,6 +265,8 @@ import { useMapClickHandler } from './composables/useMapClickHandler'
|
||||
import { useRangeCircle } from './composables/useRangeCircle'
|
||||
import { usePathLines } from './composables/usePathLines'
|
||||
import { useEmergencyDispatch } from './composables/useEmergencyDispatch'
|
||||
import { useEmergencyRouteSelection } from './composables/useEmergencyRouteSelection'
|
||||
import { useSimulatedMarkers } from './composables/useSimulatedMarkers'
|
||||
|
||||
// ========== 工具和常量导入 ==========
|
||||
import { useMapStore } from '@/map'
|
||||
@ -276,6 +279,7 @@ import {
|
||||
DEFAULT_SEARCH_RADIUS,
|
||||
MARKER_ICON_SIZE,
|
||||
} from './constants'
|
||||
import { calculateDistance, isValidCoordinate } from './utils/geoUtils'
|
||||
|
||||
// ========== 图标资源导入 ==========
|
||||
import emergencyCenterIcon from './assets/images/应急中心.png'
|
||||
@ -382,13 +386,20 @@ const mapClickHandler = useMapClickHandler({
|
||||
})
|
||||
|
||||
// 应急调度流程
|
||||
const { showLoading, startDispatch } = useEmergencyDispatch({
|
||||
const { showLoading, loadingMessage, startDispatch, startDispatchWithRouting } = useEmergencyDispatch({
|
||||
pathLinesComposable,
|
||||
entityAnimationComposable,
|
||||
mapStore,
|
||||
registerTimeoutFn: registerTimeout,
|
||||
})
|
||||
|
||||
// 应急点选择
|
||||
const { selectByType } = useEmergencyRouteSelection()
|
||||
|
||||
// 模拟标记管理
|
||||
const { createSimulatedMarkers, clearSimulatedMarkers, hideEmergencyMarkers, getEmergencyMarkerData } =
|
||||
useSimulatedMarkers()
|
||||
|
||||
// ====================
|
||||
// UI 状态
|
||||
// ====================
|
||||
@ -400,6 +411,9 @@ const isRightPanelCollapsed = ref(false)
|
||||
// 标记点和范围圈显示状态
|
||||
const showMarkersAndRange = ref(false)
|
||||
|
||||
// 快速响应执行状态
|
||||
const quickResponseExecuted = ref(false)
|
||||
|
||||
// 地图工具激活状态 - 默认激活模型对比
|
||||
const activeToolKey = ref('modelCompare')
|
||||
|
||||
@ -530,8 +544,33 @@ const handleVideoModalClose = () => {
|
||||
/**
|
||||
* 处理力量调度启动事件
|
||||
*/
|
||||
const handleStartDispatch = (payload) => {
|
||||
startDispatch(payload)
|
||||
const handleStartDispatch = async (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 = () => {
|
||||
console.log('[index.vue] 弹窗中点击一键启动')
|
||||
const handleModalStartDispatch = async () => {
|
||||
console.log('[index.vue] 弹窗中点击一键启动 - 使用智能路径规划')
|
||||
showStretchableModal.value = false
|
||||
handleStartDispatch({
|
||||
planName: '智能应急方案',
|
||||
plan: disasterData.forceDispatch.value.plan,
|
||||
responseLevel: disasterData.forceDispatch.value.responseLevel,
|
||||
estimatedClearTime: disasterData.forceDispatch.value.estimatedClearTime,
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
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. 清除/隐藏其他所有标记
|
||||
// 1.1 隐藏模拟点位(soldier/device)和路径起点标记
|
||||
// 2. 显示接口标记
|
||||
// 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) => {
|
||||
if (entity.properties) {
|
||||
const props = entity.properties
|
||||
const type = props.type?.getValue()
|
||||
if (type === 'soldier' ||
|
||||
type === 'device' ||
|
||||
if (props.type?.getValue() === 'soldier' ||
|
||||
props.type?.getValue() === 'device' ||
|
||||
props.isPathStartMarker?.getValue()) {
|
||||
entity.show = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 1.2 隐藏应急资源标记(养护站等)
|
||||
// 2. 隐藏接口标记
|
||||
hideMarkers()
|
||||
|
||||
console.log('[index.vue] 已清除其他标记')
|
||||
// 3. 隐藏范围圈
|
||||
hideRangeCircle()
|
||||
|
||||
// 2. 加载全部储备中心/预置点到地图(不限制距离)
|
||||
console.log('[index.vue] 加载全部储备中心/预置点到地图...')
|
||||
await loadReserveCentersAndPresets(DISASTER_CENTER.lon, DISASTER_CENTER.lat, true)
|
||||
console.log('[index.vue] 已隐藏所有标记点和范围圈')
|
||||
}
|
||||
|
||||
// 3. 加载范围内的储备中心/预置点数据(用于面板显示和统计)
|
||||
console.log('[index.vue] 加载范围内储备中心/预置点数据(用于面板显示)...')
|
||||
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,
|
||||
},
|
||||
/**
|
||||
* 在选中的应急点位置显示人员/装备初始标记
|
||||
* @param {Array} personnelPoints - 选中的人员应急点
|
||||
* @param {Array} equipmentPoints - 选中的装备应急点
|
||||
*/
|
||||
const showEmergencyPointMarkers = (personnelPoints, equipmentPoints) => {
|
||||
const viewer = mapStore.viewer
|
||||
if (!viewer) return
|
||||
|
||||
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)) {
|
||||
const transformedStations = disasterData.transformReserveDataToStations(
|
||||
rangeResponse.data,
|
||||
{ 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),
|
||||
// 添加人员标记
|
||||
personnelPoints.forEach((point, index) => {
|
||||
viewer.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat, 0),
|
||||
billboard: {
|
||||
image: soldierIcon,
|
||||
width: 36,
|
||||
@ -795,21 +1044,19 @@ const showAllMarkersAndRange = async () => {
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY
|
||||
},
|
||||
properties: new Cesium.PropertyBag({
|
||||
type: 'centerPersonnel',
|
||||
name: '应急人员'
|
||||
type: 'emergencyPersonnel',
|
||||
name: point.name || `应急人员${index + 1}`,
|
||||
emergencyPointId: point.id
|
||||
}),
|
||||
show: true
|
||||
})
|
||||
console.log('[index.vue] 已添加中心点人员标记:', personnelEntity.id)
|
||||
} else {
|
||||
existingCenterPersonnel.show = true
|
||||
console.log('[index.vue] 显示已存在的中心点人员标记')
|
||||
}
|
||||
console.log(`[index.vue] 已添加人员标记: ${point.name}`)
|
||||
})
|
||||
|
||||
// 添加装备点位(如果不存在)
|
||||
if (!existingCenterEquipment) {
|
||||
const equipmentEntity = viewer.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(DISASTER_CENTER.lon - offset, DISASTER_CENTER.lat, 0),
|
||||
// 添加装备标记
|
||||
equipmentPoints.forEach((point, index) => {
|
||||
viewer.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat, 0),
|
||||
billboard: {
|
||||
image: deviceIcon,
|
||||
width: 36,
|
||||
@ -819,77 +1066,17 @@ const showAllMarkersAndRange = async () => {
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY
|
||||
},
|
||||
properties: new Cesium.PropertyBag({
|
||||
type: 'centerEquipment',
|
||||
name: '应急装备'
|
||||
type: 'emergencyEquipment',
|
||||
name: point.name || `应急装备${index + 1}`,
|
||||
emergencyPointId: point.id
|
||||
}),
|
||||
show: true
|
||||
})
|
||||
console.log('[index.vue] 已添加中心点装备标记:', equipmentEntity.id)
|
||||
} else {
|
||||
existingCenterEquipment.show = true
|
||||
console.log('[index.vue] 显示已存在的中心点装备标记')
|
||||
}
|
||||
console.log(`[index.vue] 已添加装备标记: ${point.name}`)
|
||||
})
|
||||
|
||||
// 强制渲染场景
|
||||
viewer.scene.requestRender()
|
||||
|
||||
// 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] 快速响应标记隐藏完成')
|
||||
console.log('[index.vue] 应急点标记显示完成')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user