支持线形动画,曲线平滑,车头方向跟随

This commit is contained in:
syruan 2025-11-21 10:28:08 +08:00
parent e3c50d0ec1
commit 5f56c28eb0
1 changed files with 536 additions and 56 deletions

View File

@ -1171,6 +1171,7 @@ import {
getTerminalGeofenceStatus,
getTerminalsInGeofence
} from '@/api/jtt808/geofence'
import { isPointInGeofence } from '@/utils/geofence'
import { formatDateTimeForAPI, formatDateTime, getStartOfToday, getEndOfToday } from '@/utils/dateUtil'
export default {
@ -1199,7 +1200,14 @@ export default {
currentPoint: null,
playedPath: [],
animationMarker: null,
playedPolyline: null
playedPolyline: null,
speedLabel: null,
currentAngle: 0,
isInGeofence: false, //
geofenceAlertActive: false, //
alertAnimationTimer: null, //
rippleAnimationTimer: null, //
rippleProgress: 0 // (0-1)
},
//
searchForm: {
@ -3191,6 +3199,54 @@ export default {
this.$message.success('轨迹回放已准备就绪,点击播放开始回放')
},
// 使线
smoothPathWithBezier(points) {
if (points.length < 3) return points
const smoothedPoints = []
smoothedPoints.push(points[0]) //
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(0, i - 1)]
const p1 = points[i]
const p2 = points[i + 1]
const p3 = points[Math.min(points.length - 1, i + 2)]
// 使 Catmull-Rom 线
const segments = 10 // 10
for (let t = 0; t <= segments; t++) {
const u = t / segments
const point = this.catmullRomSpline(p0, p1, p2, p3, u)
smoothedPoints.push(point)
}
}
smoothedPoints.push(points[points.length - 1]) //
return smoothedPoints
},
// Catmull-Rom 线
catmullRomSpline(p0, p1, p2, p3, t) {
const t2 = t * t
const t3 = t2 * t
const lng = 0.5 * (
(2 * p1.getLng()) +
(-p0.getLng() + p2.getLng()) * t +
(2 * p0.getLng() - 5 * p1.getLng() + 4 * p2.getLng() - p3.getLng()) * t2 +
(-p0.getLng() + 3 * p1.getLng() - 3 * p2.getLng() + p3.getLng()) * t3
)
const lat = 0.5 * (
(2 * p1.getLat()) +
(-p0.getLat() + p2.getLat()) * t +
(2 * p0.getLat() - 5 * p1.getLat() + 4 * p2.getLat() - p3.getLat()) * t2 +
(-p0.getLat() + 3 * p1.getLat() - 3 * p2.getLat() + p3.getLat()) * t3
)
return new AMap.LngLat(lng, lat)
},
// 线
drawFullTrackLine(trackData) {
// 线
@ -3203,17 +3259,20 @@ export default {
this.polyline = null
}
const path = trackData.map(point =>
const path = trackData.map(point =>
new AMap.LngLat(parseFloat(point.longitude), parseFloat(point.latitude))
)
// 使线
const smoothedPath = this.smoothPathWithBezier(path)
// 线
this.polyline = new AMap.Polyline({
path: path,
path: smoothedPath,
strokeColor: '#E0E0E0',
strokeWeight: 3,
strokeOpacity: 0.6,
strokeStyle: 'dashed'
strokeStyle: 'solid'
})
this.map.add(this.polyline)
@ -3222,56 +3281,332 @@ export default {
//
createAnimationMarker(firstPoint) {
const position = new AMap.LngLat(
parseFloat(firstPoint.longitude),
parseFloat(firstPoint.longitude),
parseFloat(firstPoint.latitude)
)
//
this.trackPlayback.isInGeofence = this.checkGeofenceStatus(
parseFloat(firstPoint.latitude),
parseFloat(firstPoint.longitude)
)
//
this.trackPlayback.animationMarker = new AMap.Marker({
position: position,
icon: this.createCarIcon(),
title: '当前位置',
anchor: 'center'
anchor: 'center',
offset: new AMap.Pixel(0, 0)
})
this.map.add(this.trackPlayback.animationMarker)
//
this.createSpeedLabel(position, firstPoint.speed || 0)
//
this.trackPlayback.playedPath = [position]
this.updatePlayedPolyline()
},
//
createCarIcon() {
//
createSpeedLabel(position, speed) {
//
if (this.trackPlayback.speedLabel) {
this.map.remove(this.trackPlayback.speedLabel)
}
//
const speedText = new AMap.Text({
text: `${Math.round(speed)} km/h`,
position: position,
anchor: 'center',
offset: new AMap.Pixel(0, -30), //
style: {
'background-color': 'rgba(0, 0, 0, 0.75)',
'border': '1px solid #1890FF',
'border-radius': '4px',
'color': '#FFFFFF',
'font-size': '12px',
'padding': '2px 6px',
'white-space': 'nowrap'
}
})
this.trackPlayback.speedLabel = speedText
this.map.add(speedText)
},
//
updateSpeedLabel(position, speed) {
if (this.trackPlayback.speedLabel) {
this.trackPlayback.speedLabel.setPosition(position)
this.trackPlayback.speedLabel.setText(`${Math.round(speed)} km/h`)
}
},
//
calculateAngle(startLng, startLat, endLng, endLat) {
const deltaLng = endLng - startLng
const deltaLat = endLat - startLat
// 使 atan2
const angleRad = Math.atan2(deltaLng, deltaLat)
// 0-360
let angleDeg = (angleRad * 180) / Math.PI
// 0-360
if (angleDeg < 0) {
angleDeg += 360
}
return angleDeg
},
//
checkGeofenceStatus(lat, lng) {
if (!this.geofences || this.geofences.length === 0) {
return false
}
//
for (const geofence of this.geofences) {
if (geofence.status === 1) { //
if (isPointInGeofence(lat, lng, geofence)) {
return true
}
}
}
return false
},
//
triggerGeofenceAlert(isEntering) {
console.log(`🚨 围栏警报: ${isEntering ? '进入' : '离开'}围栏`)
//
if (this.trackPlayback.geofenceAlertActive) {
console.log('⏭️ 警报已激活,跳过重复触发')
return
}
//
this.trackPlayback.geofenceAlertActive = true
this.trackPlayback.rippleProgress = 0
//
if (this.trackPlayback.alertAnimationTimer) {
clearTimeout(this.trackPlayback.alertAnimationTimer)
}
if (this.trackPlayback.rippleAnimationTimer) {
clearInterval(this.trackPlayback.rippleAnimationTimer)
}
// 60fps
this.trackPlayback.rippleAnimationTimer = setInterval(() => {
if (!this.trackPlayback.geofenceAlertActive) {
clearInterval(this.trackPlayback.rippleAnimationTimer)
return
}
// 0-1
this.trackPlayback.rippleProgress += 0.02 // 2%
if (this.trackPlayback.rippleProgress >= 1) {
this.trackPlayback.rippleProgress = 0
}
//
if (this.trackPlayback.animationMarker) {
this.trackPlayback.animationMarker.setIcon(
this.createCarIcon(
this.trackPlayback.currentAngle,
true,
this.trackPlayback.rippleProgress
)
)
}
}, 1000 / 60) // 60fps
// 3
this.trackPlayback.alertAnimationTimer = setTimeout(() => {
this.trackPlayback.geofenceAlertActive = false
//
if (this.trackPlayback.rippleAnimationTimer) {
clearInterval(this.trackPlayback.rippleAnimationTimer)
this.trackPlayback.rippleAnimationTimer = null
}
//
if (this.trackPlayback.animationMarker) {
this.trackPlayback.animationMarker.setIcon(
this.createCarIcon(this.trackPlayback.currentAngle, false, 0)
)
}
console.log('✅ 警报结束')
}, 3000)
//
this.$message({
message: `⚠️ 车辆${isEntering ? '进入' : '离开'}电子围栏!`,
type: 'warning',
duration: 2000
})
},
//
createCarIcon(angle = 0, showAlert = false, rippleProgress = 0) {
const canvas = document.createElement('canvas')
canvas.width = 24
canvas.height = 24
canvas.width = 80 //
canvas.height = 80
const ctx = canvas.getContext('2d')
//
ctx.fillStyle = '#FF4444'
ctx.beginPath()
// 使
this.drawRoundedRect(ctx, 4, 6, 16, 12, 2)
ctx.fill()
// 齿
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
//
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(6, 8, 12, 3)
//
ctx.save()
ctx.translate(40, 40) //
//
ctx.fillStyle = '#FF4444'
//
if (showAlert && rippleProgress > 0) {
// 3
for (let i = 0; i < 3; i++) {
//
const phaseOffset = i * 0.33
let phase = (rippleProgress + phaseOffset) % 1
// 10px35px
const minRadius = 10
const maxRadius = 35
const radius = minRadius + (maxRadius - minRadius) * phase
// 0.80
const opacity = (1 - phase) * 0.8
//
ctx.strokeStyle = `rgba(255, 0, 0, ${opacity})`
ctx.lineWidth = 3
ctx.beginPath()
ctx.arc(0, 0, radius, 0, Math.PI * 2)
ctx.stroke()
//
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radius)
gradient.addColorStop(0, `rgba(255, 0, 0, ${opacity * 0.3})`)
gradient.addColorStop(0.7, `rgba(255, 77, 79, ${opacity * 0.2})`)
gradient.addColorStop(1, 'rgba(255, 0, 0, 0)')
ctx.fillStyle = gradient
ctx.beginPath()
ctx.arc(0, 0, radius, 0, Math.PI * 2)
ctx.fill()
}
//
const centerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, 12)
centerGradient.addColorStop(0, 'rgba(255, 0, 0, 0.6)')
centerGradient.addColorStop(0.5, 'rgba(255, 77, 79, 0.3)')
centerGradient.addColorStop(1, 'rgba(255, 0, 0, 0)')
ctx.fillStyle = centerGradient
ctx.beginPath()
ctx.arc(0, 0, 12, 0, Math.PI * 2)
ctx.fill()
}
//
if (angle !== 0) {
ctx.rotate((angle * Math.PI) / 180)
}
//
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'
ctx.shadowBlur = 4
ctx.shadowOffsetX = 1
ctx.shadowOffsetY = 2
//
ctx.fillStyle = '#1890FF'
ctx.strokeStyle = '#0050B3'
ctx.lineWidth = 1.5
//
ctx.beginPath()
ctx.moveTo(12, 2)
ctx.lineTo(8, 6)
ctx.lineTo(16, 6)
ctx.moveTo(-8, -12) //
ctx.lineTo(-8, 8) //
ctx.lineTo(-6, 12) //
ctx.lineTo(6, 12) //
ctx.lineTo(8, 8) //
ctx.lineTo(8, -12) //
ctx.closePath()
ctx.fill()
ctx.stroke()
//
ctx.shadowColor = 'transparent'
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
//
ctx.fillStyle = '#91D5FF'
ctx.fillRect(-6, -10, 12, 6)
// 线
ctx.strokeStyle = '#0050B3'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(-6, -7)
ctx.lineTo(6, -7)
ctx.stroke()
//
ctx.fillStyle = '#FADB14'
ctx.beginPath()
ctx.arc(-5, -12, 1.5, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(5, -12, 1.5, 0, Math.PI * 2)
ctx.fill()
//
ctx.fillStyle = '#434343'
//
ctx.fillRect(-9, -6, 2, 4)
//
ctx.fillRect(7, -6, 2, 4)
//
ctx.fillRect(-9, 4, 2, 4)
//
ctx.fillRect(7, 4, 2, 4)
//
ctx.fillStyle = '#FF4D4F'
ctx.strokeStyle = '#FFFFFF'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, -16) //
ctx.lineTo(-4, -12) //
ctx.lineTo(-1.5, -12) //
ctx.lineTo(-1.5, -10) //
ctx.lineTo(1.5, -10) //
ctx.lineTo(1.5, -12) //
ctx.lineTo(4, -12) //
ctx.closePath()
ctx.fill()
ctx.stroke()
ctx.restore()
return new AMap.Icon({
size: new AMap.Size(24, 24),
size: new AMap.Size(80, 80), //
image: canvas.toDataURL(),
imageOffset: new AMap.Pixel(0, 0)
imageOffset: new AMap.Pixel(0, 0),
imageSize: new AMap.Size(80, 80)
})
},
@ -3331,14 +3666,9 @@ export default {
if (!this.trackPlayback.hasData) return
this.trackPlayback.isPlaying = true
// 500ms
const baseInterval = 500
const interval = baseInterval / this.trackPlayback.speed
this.trackPlayback.timer = setInterval(() => {
this.playNextPoint()
}, interval)
//
this.playNextPoint()
this.$message.success('轨迹回放开始')
},
@ -3346,10 +3676,7 @@ export default {
//
pausePlayback() {
this.trackPlayback.isPlaying = false
if (this.trackPlayback.timer) {
clearInterval(this.trackPlayback.timer)
this.trackPlayback.timer = null
}
// isPlaying
},
//
@ -3366,6 +3693,8 @@ export default {
//
playNextPoint() {
if (!this.trackPlayback.isPlaying) return
if (this.trackPlayback.currentIndex >= this.trackPlayback.totalPoints - 1) {
//
this.pausePlayback()
@ -3380,27 +3709,130 @@ export default {
this.updateAnimationPosition()
},
//
// 使
updateAnimationPosition() {
const currentPoint = this.trackPlayback.currentPoint
const position = new AMap.LngLat(
if (!this.trackPlayback.animationMarker) return
const currentIndex = this.trackPlayback.currentIndex
//
const prevIndex = currentIndex - 1
if (prevIndex < 0) {
//
return
}
const prevPoint = this.trackPlayback.trackData[prevIndex]
const currentPoint = this.trackPlayback.trackData[currentIndex]
const startPosition = new AMap.LngLat(
parseFloat(prevPoint.longitude),
parseFloat(prevPoint.latitude)
)
const endPosition = new AMap.LngLat(
parseFloat(currentPoint.longitude),
parseFloat(currentPoint.latitude)
)
//
if (this.trackPlayback.animationMarker) {
this.trackPlayback.animationMarker.setPosition(position)
//
const angle = this.calculateAngle(
startPosition.getLng(),
startPosition.getLat(),
endPosition.getLng(),
endPosition.getLat()
)
//
this.trackPlayback.playedPath.push(position)
this.updatePlayedPolyline()
//
this.trackPlayback.currentAngle = angle
//
if (this.trackPlayback.isPlaying) {
this.map.setCenter(position)
//
const currentSpeed = currentPoint.speed || 0
// 1000ms
const baseDuration = 1000
const duration = baseDuration / this.trackPlayback.speed
const steps = 30 //
const stepDuration = duration / steps
let currentStep = 0
// 使 setTimeout
const animate = () => {
if (!this.trackPlayback.isPlaying || currentStep >= steps) {
//
this.trackPlayback.animationMarker.setPosition(endPosition)
//
if (this.trackPlayback.playedPath.length === 0 ||
!this.trackPlayback.playedPath[this.trackPlayback.playedPath.length - 1].equals(endPosition)) {
this.trackPlayback.playedPath.push(endPosition)
this.updatePlayedPolyline()
}
//
if (this.trackPlayback.isPlaying) {
this.map.setCenter(endPosition)
}
//
if (this.trackPlayback.isPlaying) {
setTimeout(() => {
this.playNextPoint()
}, 50)
}
return
}
currentStep++
const progress = currentStep / steps
// 线
const lng = startPosition.getLng() + (endPosition.getLng() - startPosition.getLng()) * progress
const lat = startPosition.getLat() + (endPosition.getLat() - startPosition.getLat()) * progress
const intermediatePosition = new AMap.LngLat(lng, lat)
//
this.trackPlayback.animationMarker.setPosition(intermediatePosition)
//
this.updateSpeedLabel(intermediatePosition, currentSpeed)
// 🔍 使
const wasInGeofence = this.trackPlayback.isInGeofence
const isNowInGeofence = this.checkGeofenceStatus(lat, lng)
//
if (wasInGeofence !== isNowInGeofence) {
this.trackPlayback.isInGeofence = isNowInGeofence
this.triggerGeofenceAlert(isNowInGeofence)
console.log(`🎯 围栏状态变化 - 位置: [${lng.toFixed(6)}, ${lat.toFixed(6)}], ${isNowInGeofence ? '进入' : '离开'}围栏`)
}
//
const showAlert = this.trackPlayback.geofenceAlertActive
if (!showAlert) {
//
this.trackPlayback.animationMarker.setIcon(
this.createCarIcon(angle, false, 0)
)
}
//
if (currentStep % 5 === 0) {
this.trackPlayback.playedPath.push(intermediatePosition)
this.updatePlayedPolyline()
}
//
if (this.trackPlayback.isPlaying && currentStep % 10 === 0) {
this.map.setCenter(intermediatePosition)
}
//
setTimeout(animate, stepDuration)
}
//
animate()
},
//
@ -3424,15 +3856,19 @@ export default {
//
onSpeedChange() {
if (this.trackPlayback.isPlaying) {
//
this.pausePlayback()
this.startPlayback()
}
//
//
},
//
onProgressChange(value) {
const wasPlaying = this.trackPlayback.isPlaying
//
if (wasPlaying) {
this.pausePlayback()
}
this.trackPlayback.currentIndex = value
this.trackPlayback.currentPoint = this.trackPlayback.trackData[value]
@ -3444,8 +3880,31 @@ export default {
parseFloat(point.latitude)
))
this.updateAnimationPosition()
// 使
const currentPoint = this.trackPlayback.currentPoint
const position = new AMap.LngLat(
parseFloat(currentPoint.longitude),
parseFloat(currentPoint.latitude)
)
if (this.trackPlayback.animationMarker) {
this.trackPlayback.animationMarker.setPosition(position)
this.map.setCenter(position)
}
//
if (this.trackPlayback.speedLabel) {
this.updateSpeedLabel(position, currentPoint.speed || 0)
}
this.updatePlayedPolyline()
//
if (wasPlaying) {
setTimeout(() => {
this.startPlayback()
}, 100)
}
},
//
@ -3471,12 +3930,26 @@ export default {
this.trackPlayback.animationMarker = null
}
//
if (this.trackPlayback.speedLabel) {
this.map.remove(this.trackPlayback.speedLabel)
this.trackPlayback.speedLabel = null
}
//
if (this.trackPlayback.playedPolyline) {
this.map.remove(this.trackPlayback.playedPolyline)
this.trackPlayback.playedPolyline = null
}
//
if (this.trackPlayback.alertAnimationTimer) {
clearTimeout(this.trackPlayback.alertAnimationTimer)
}
if (this.trackPlayback.rippleAnimationTimer) {
clearInterval(this.trackPlayback.rippleAnimationTimer)
}
//
this.trackPlayback = {
isVisible: false,
@ -3491,7 +3964,14 @@ export default {
currentPoint: null,
playedPath: [],
animationMarker: null,
playedPolyline: null
playedPolyline: null,
speedLabel: null,
currentAngle: 0,
isInGeofence: false,
geofenceAlertActive: false,
alertAnimationTimer: null,
rippleAnimationTimer: null,
rippleProgress: 0
}
},