
车辆移动轨迹回放 + 报警事件标注 + 移动动画控制 + 地图渲染 + 高精度底图加载
高德地图2.0版本的vue3对应封装文档 https://vue-amap.guyixi.cn/
html
<template>
<el-dialog v-model="visible" title="轨迹回放" width="80%" destroy-on-close>
<template #header="{ close, titleId, titleClass }">
<div class="my-header">
<span>查看轨迹</span>
<span>(仅显示报警开始前一分钟到报警结束后一分钟数据)</span>
</div>
</template>
<!-- 地图主体 -->
<div style="height: 500px; width: 100%;" v-if="visible" v-loading="loading">
<el-amap :zoom="zoom" :center="center" :resizeEnable="true" :rotation="rotation">
<!-- 弹框 Marker(报警开始) -->
<!-- 点击确定后继续播放动画 -->
<el-amap-marker :visible="showStartInfo" :position="[startLng, startLat]"
anchor="bottom-center" :z-index="999" :clickable="false">
<template #default>
<div class="alert" style="cursor: default;">
<div class="alert-close">
<el-icon :size="16" @click="showStartInfo = false">
<Close />
</el-icon>
</div>
<div class="alert-title">开始报警</div>
<div class="alert-content">车辆编号: {{ vehicleNumber }}</div>
<div class="alert-content">开始报警时间: {{ beginTime }}</div>
<div class="alert-btn">
<el-button size="small" type="primary" @click="closeStartInfo">确定</el-button>
</div>
</div>
</template>
</el-amap-marker>
<!-- 弹框 Marker(报警结束) -->
<el-amap-marker :visible="showEndInfo" :position="[endLng, endLat]"
anchor="bottom-center" :z-index="999" :clickable="false">
<template #default>
<div class="alert" style="cursor: default;">
<div class="alert-close">
<el-icon :size="16" @click="showEndInfo = false">
<Close />
</el-icon>
</div>
<div class="alert-title">结束报警</div>
<div class="alert-content">车辆编号: {{ vehicleNumber }}</div>
<div class="alert-content">结束报警时间: {{ endTime }}</div>
<div class="alert-btn">
<el-button size="small" type="primary" @click="closeEndInfo">确定</el-button>
</div>
</div>
</template>
</el-amap-marker>
<!-- 报警开始点 -->
<el-amap-marker v-if="startLng && startLat" :position="[startLng, startLat]" anchor="bottom-center" />
<!-- 报警结束点 -->
<el-amap-marker v-if="endLng && endLat" :position="[endLng, endLat]" anchor="bottom-center" />
<!-- 小车已经走过的轨迹(绿色) -->
<el-amap-polyline v-if="passedPath.length > 0"
:path="passedPath" stroke-color="#9ee492" :strokeOpacity="1"
:stroke-weight="6" showDir lineJoin="round" :z-index="11" />
<!-- 完整轨迹(蓝色) -->
<el-amap-polyline v-if="trackPath.length > 0"
:path="trackPath" stroke-color="#28F" :strokeOpacity="1"
:stroke-weight="6" showDir lineJoin="round" :z-index="10" />
<!-- 高精度瓦片底图 -->
<el-amap-layer-custom-xyz
:url="baseUrl + '高精度地图路径?x={x}&y={y}&z={z}'"
proj="wgs84" :z-index="9" />
<!-- 小车 Marker -->
<el-amap-marker :position="currentMarker" :icon="myIcon"
ref="markerRef" :offset="[-6.5, -2]" />
</el-amap>
</div>
<!-- 底部控制栏:播放、暂停、继续、倍速 -->
<div class="input-card">
<div class="divCss">
<el-button @click="startAnimation">开始动画</el-button>
<el-button @click="pauseAnimation">暂停动画</el-button>
<el-button @click="resumeAnimation">继续动画</el-button>
</div>
<!-- 切换速度(改变 moveAlong duration)-->
<div class="div2Css">
<el-radio-group v-model="radio">
<el-radio :label="300">1X</el-radio>
<el-radio :label="60">5X</el-radio>
<el-radio :label="30">10X</el-radio>
<el-radio :label="6">50X</el-radio>
</el-radio-group>
</div>
</div>
</el-dialog>
</template>
<script lang="ts" setup>
import { ElAmap, ElAmapMarker, ElAmapPolyline, ElAmapLayerCustomXyz } from '@vuemap/vue-amap';
import { queryAlarmRecordData } from '@/api/base/fenceAlarmRecord'
import offCar from '@/assets/map/car-offline.png'
import gcoord from "gcoord";
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const baseUrl = import.meta.env.VITE_APP_MAP1_API
// 显示/隐藏弹窗
const visible = ref(false)
// 地图基础属性
const zoom = ref(16.2)
const rotation = ref(58.5)
const center = ref<[number, number]>([125.02806, 43.995978])
// 原始轨迹、转换后轨迹、小车当前点、已走轨迹
const trackData = ref<any[]>([])
const trackPath = ref<[number, number][]>([])
const currentMarker = ref<[number, number] | null>(null)
const passedPath = ref<[number, number][]>([])
// 报警信息
let startLng = ref(0)
let startLat = ref(0)
let endLng = ref(0)
let endLat = ref(0)
let showStartInfo = ref(false)
let showEndInfo = ref(false)
let beginTime = ref('')
let endTime = ref('')
let vehicleNumber = ref('')
// 播放速度
let radio = ref(300)
// 小车图标(只创建 1 次)
const myIcon = new AMap.Icon({
image: offCar,
size: new AMap.Size(12, 24),
imageSize: new AMap.Size(12, 24)
})
// 打开弹窗 → 获取轨迹
const open = (val: any) => {
visible.value = true
getAlarmRecordData(val)
}
const loading = ref(false)
// 请求轨迹数据并处理
const getAlarmRecordData = async (val: any) => {
loading.value = true
try {
beginTime.value = val.beginTime
endTime.value = val.endTime
vehicleNumber.value = val.vehicleNumber
// 请求后端轨迹
const res = await queryAlarmRecordData({ id: val.id });
if (!res.data || res.data.length === 0) {
proxy?.$modal.msgWarning(res.msg);
visible.value = false;
return;
}
const processed: any[] = [];
let lastSecond = '';
// 轨迹处理:WGS84 → GCJ02;同一秒只保留第一条
res.data.forEach(item => {
const [lon, lat] = gcoord.transform([item.lon, item.lat], gcoord.WGS84, gcoord.GCJ02);
const tmSecond = item.tm.split('.')[0];
if (tmSecond !== lastSecond) {
processed.push({ ...item, lon, lat, tm: tmSecond });
lastSecond = tmSecond;
}
});
// 小车完整轨迹
trackData.value = processed;
trackPath.value = processed.map(item => [item.lon, item.lat]);
// 小车初始位置
currentMarker.value = trackPath.value[0];
passedPath.value = [];
// 标记报警点
const startPoint = processed.find(i => i.tm === val.beginTime)
if (startPoint) {
startLng.value = startPoint.lon
startLat.value = startPoint.lat
}
const endPoint = processed.find(i => i.tm === val.endTime)
if (endPoint) {
endLng.value = endPoint.lon
endLat.value = endPoint.lat
}
} finally {
loading.value = false
}
}
// 小车 Marker 实例
const markerRef = ref<any>(null)
const animationStarted = ref(false)
// 开始播放轨迹动画
const startAnimation = () => {
if (!trackPath.value.length) return;
const marker = markerRef.value?.$$getInstance()
if (!marker) return;
// 每次播放前重置位置和轨迹
marker.setPosition(trackPath.value[0])
passedPath.value = []
// 清除之前的 moving 事件,防止重复触发
marker.clearEvents('moving')
let startTriggered = false
let endTriggered = false
// 开始移动
marker.moveAlong(trackPath.value, {
duration: radio.value,
autoRotation: true,
})
// 绑定 moving 事件(实时更新轨迹 + 触发报警)
marker.on('moving', (e: any) => {
passedPath.value = e.passedPath
const idx = e.passedPath.length - 1
const tm = trackData.value[idx]?.tm
// 进入报警开始时间 → 暂停
if (!startTriggered && tm === beginTime.value) {
startTriggered = true
marker.pauseMove()
showStartInfo.value = true
}
// 进入报警结束时间 → 暂停
if (!endTriggered && tm === endTime.value) {
endTriggered = true
marker.pauseMove()
showEndInfo.value = true
}
})
animationStarted.value = true
}
// 暂停动画
const pauseAnimation = () => {
markerRef.value?.$$getInstance()?.pauseMove()
}
// 继续动画
const resumeAnimation = () => {
if (!animationStarted.value) return;
markerRef.value?.$$getInstance()?.resumeMove()
}
// 重置动画(切换速度/关闭弹窗时调用)
const resetMarker = () => {
const marker = markerRef.value?.$$getInstance()
if (!marker) return;
marker.pauseMove()
marker.clearEvents('moving')
marker.stopMove()
passedPath.value = []
if (trackPath.value.length > 0) {
marker.setPosition(trackPath.value[0])
currentMarker.value = trackPath.value[0]
}
animationStarted.value = false
showStartInfo.value = false
showEndInfo.value = false
}
// 切换速度 → 重置动画重新播放
watch(radio, () => resetMarker())
// 弹窗关闭 → 重置
watch(visible, (val) => !val && resetMarker())
// 点击报警弹框的"确定"按钮 → 继续动画
const closeStartInfo = () => {
showStartInfo.value = false
resumeAnimation()
}
const closeEndInfo = () => {
showEndInfo.value = false
resumeAnimation()
}
defineExpose({ open })
</script>
<style scoped lang="scss">
.el-dialog__body {
padding: 0;
}
.my-header {
span:nth-child(2) {
font-size: 12px;
}
}
.input-card {
display: flex;
justify-content: space-around;
align-items: center;
margin: 10px;
flex-direction: column;
}
.div2Css {
margin-top: 15px;
}
.alert {
font-size: 12px;
width: 210px;
background: #fff;
padding: 10px;
border-radius: 4px;
&-title {
font-size: 13px;
font-weight: bold;
color: #ff0000;
margin-bottom: 10px;
margin-top: -15px;
}
&-content {
margin-bottom: 3px;
}
&-btn {
text-align: right;
}
&-close {
text-align: right;
.el-icon {
cursor: pointer;
}
}
}
</style>