From 5f56c28eb08875d8dd0d7bfd75439476a3695668 Mon Sep 17 00:00:00 2001 From: syruan <15555146157@163.com> Date: Fri, 21 Nov 2025 10:28:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=BA=BF=E5=BD=A2=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=EF=BC=8C=E6=9B=B2=E7=BA=BF=E5=B9=B3=E6=BB=91=EF=BC=8C?= =?UTF-8?q?=E8=BD=A6=E5=A4=B4=E6=96=B9=E5=90=91=E8=B7=9F=E9=9A=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/jtt808/map/index.vue | 592 +++++++++++++++++++++++++++++---- 1 file changed, 536 insertions(+), 56 deletions(-) diff --git a/src/views/jtt808/map/index.vue b/src/views/jtt808/map/index.vue index cf85347..e05129d 100644 --- a/src/views/jtt808/map/index.vue +++ b/src/views/jtt808/map/index.vue @@ -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 + + // 波纹半径从10px扩散到35px + const minRadius = 10 + const maxRadius = 35 + const radius = minRadius + (maxRadius - minRadius) * phase + + // 透明度从0.8逐渐减小到0 + 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 } },