uniapp上手机端二维图片轨迹路线回放 template view classbox !-- 地图容器背景底图 轨迹画布重叠 -- view classtrajectory-card !-- 底层场地背景图 -- image classmap-bg src/static/ceshi.png modeaspectFill/image !-- 顶层画布绘制轨迹、人物绑定点击事件拿物理坐标 -- canvas idtrackCanvas canvas-idtrackCanvas classtrack-canvas taphandleCanvasTap/canvas /view !-- 回放控制按钮组 -- view styledisplay:flex;gap:20rpx;margin:30rpx van-button clickhandleClick重新播放/van-button van-button typeprimary clickpausePlay{{ isPause ? 继续 : 暂停 }}/van-button /view !-- 回放进度条 -- view stylemargin:0 20rpx view当前绘制进度{{ progressNum }}%/view view stylewidth:100%;height:20rpx;background:#eee;border-radius:10rpx;margin-top:10rpx; view styleheight:100%;background:#025ADD;border-radius:10rpx; :style{width:progressNum%}/view /view /view /view /template script import { uwbPosition } from ../../api/index.js // 场地四个角落对应的真实UWB物理坐标X东/Y北Y上大下小 const COORD { leftTopX: 0.35, leftTopY: 167.57, rightTopX: 83.27, rightTopY: 167.57, leftBottomX: 0.35, leftBottomY: 128.39, rightBottomX: 83.27, rightBottomY: 128.39 } export default { data() { return { delay: 30, // 轨迹点绘制间隔(ms)500ms画一个点位 timer: null, // 定时器标识用于逐帧渲染轨迹 canvasContainerSize: { width: 0, height: 0 }, // 画布真实宽高 trajectoryPoints: [], // 后端返回全部UWB轨迹点位数组 canvasCtx: null, // canvas绘图上下文所有绘制操作依赖它 currentIndex: 0, // 当前渲染到第几个轨迹点 progressNum: 0, // 回放进度百分比 0~100 isPause: false, // 新增暂停标记 false播放 true暂停 } }, // 页面初次渲染完成加载接口初始化画布 onReady() { // 请求后端UWB轨迹接口获取人员历史坐标 uwbPosition({ cardId: 614959, startTime: 2026-07-24 09:08:49, endTime: 2026-07-24 23:53:58 }).then(res { // 接口赋值轨迹数组无数据则为空数组 this.trajectoryPoints res.data.data || [] }) // 获取画布绘制上下文uni canvas固定写法 this.canvasCtx uni.createCanvasContext(trackCanvas, this) }, // 页面销毁清除定时器防止内存泄漏、后台持续渲染 onUnload() { if (this.timer) clearTimeout(this.timer) }, methods: { /** * 真实物理坐标(UWB) → 画布像素坐标 * param {Number} realX 真实X东 * param {Number} realY 真实Y北 * param {Number} canvasW 画布宽度 * param {Number} canvasH 画布高度 * returns {Object} {x,y} 画布像素点位 */ realToCanvas(realX, realY, canvasW, canvasH) { // 提取场地物理边界极值 const worldMaxX COORD.rightTopX; const worldMinX COORD.leftTopX; const worldMaxY COORD.leftTopY; const worldMinY COORD.leftBottomY; // 场地总物理宽、总物理高 const worldWidth worldMaxX - worldMinX; const worldHeight worldMaxY - worldMinY; // 计算X轴比例当前X占场地总宽度百分比 let xRatio (realX - worldMinX) / worldWidth; // Y轴翻转物理Y顶部大、画布Y顶部0反向换算比例 let yRatio (worldMaxY - realY) / worldHeight; // 限制比例0~1防止坐标超出画布看不见 xRatio Math.max(0, Math.min(1, xRatio)); yRatio Math.max(0, Math.min(1, yRatio)); // 比例 × 画布尺寸 最终画布像素 const canvasX xRatio * canvasW; const canvasY yRatio * canvasH; return { x: canvasX, y: canvasY } }, /** * 画布点击像素坐标 → 真实UWB物理坐标反向换算打点标定用 * param {Number} canvasX 画布点击X像素 * param {Number} canvasY 画布点击Y像素 * param {Number} canvasW 画布宽 * param {Number} canvasH 画布高 * returns {Object} {card_x,card_y} 真实物理坐标 */ canvasToReal(canvasX, canvasY, canvasW, canvasH) { const worldMaxX COORD.rightTopX; const worldMinX COORD.leftTopX; const worldMaxY COORD.leftTopY; const worldMinY COORD.leftBottomY; const worldWidth worldMaxX - worldMinX; const worldHeight worldMaxY - worldMinY; // 点击位置占画布比例 const xRatio canvasX / canvasW; const yRatio canvasY / canvasH; // 比例换算回真实物理XY const realX worldMinX xRatio * worldWidth; const realY worldMaxY - yRatio * worldHeight; return { card_x: realX, card_y: realY } }, // 画布点击触发弹出当前点位真实物理坐标 async handleCanvasTap(e) { // 先获取画布宽高 await this.getCanvasContainerRealSize() const { width, height } this.canvasContainerSize // e.detail.x/y 点击相对画布的像素坐标 const canvasX e.detail.x const canvasY e.detail.y // 反向换算物理坐标 const realPos this.canvasToReal(canvasX, canvasY, width, height) console.log(点击像素, canvasX, canvasY, 物理坐标, realPos) uni.showModal({ title: 点位物理坐标, content: X:${realPos.card_x.toFixed(2)} Y:${realPos.card_y.toFixed(2)} }) }, // 获取canvas画布渲染后的真实宽高rpx转像素实际尺寸 getCanvasContainerRealSize() { return new Promise(resolve { uni.createSelectorQuery().in(this) .select(#trackCanvas) .boundingClientRect(rect { // 存在尺寸则赋值保存 if (rect?.width) this.canvasContainerSize rect resolve() }).exec() }) }, // 暂停/继续切换 pausePlay() { // 切换暂停状态 this.isPause !this.isPause // 如果从暂停切为播放继续执行下一帧绘制 if (!this.isPause) { this.drawStep(this.canvasContainerSize.width, this.canvasContainerSize.height, this.allPointDataCache) } }, // 回放按钮点击入口初始化参数、转换全部坐标、启动逐帧绘制 async handleClick() { // 清除上一次回放定时器防止多重回放叠加 if (this.timer) clearTimeout(this.timer) this.timer null this.currentIndex 0 this.progressNum 0 this.isPause false // 重新播放默认解除暂停 // 获取画布尺寸 await this.getCanvasContainerRealSize() const { width, height } this.canvasContainerSize // 画布尺寸获取失败弹窗提示 if (!width || !height) return uni.showToast({ title: 画布获取失败, icon: none }) // 轨迹点位不足2个无法画线直接终止 if (this.trajectoryPoints.length 2) return uni.showToast({ title: 轨迹数据不足, icon: none }) // 一次性把所有后端物理坐标转为画布像素避免逐帧重复计算性能优化 const allPointData this.trajectoryPoints.map(item { const pos this.realToCanvas(Number(item.card_x), Number(item.card_y), width, height) return { ...item, px: pos } }) // 缓存全部点位暂停恢复时使用 this.allPointDataCache allPointData // 启动逐帧绘制 this.drawStep(width, height, allPointData) }, /** * 逐帧递归绘制轨迹核心渲染函数 * param {Number} canvasW 画布宽 * param {Number} canvasH 画布高 * param {Array} allPointData 全部转换完成的轨迹点位 */ drawStep(canvasW, canvasH, allPointData) { // 如果是暂停状态直接终止不再继续下一帧 if (this.isPause) return const allPx allPointData.map(d d.px) const total allPx.length // 计算当前回放进度百分比 this.progressNum Math.round((this.currentIndex / (total - 1)) * 100) // 截取从起点到当前索引的所有点位逐步延长轨迹线 const drawDataList allPointData.slice(0, this.currentIndex 1) const drawPoints drawDataList.map(d d.px) // 当前最新点位绘制人物头像 const lastData drawDataList[drawDataList.length - 1] const lastPos lastData.px const ctx this.canvasCtx // 清空整幅画布避免上次绘制残留 ctx.clearRect(0, 0, canvasW, canvasH) // 轨迹点2个才绘制连线 if (drawPoints.length 2) { ctx.beginPath() // 第一个点位移动画笔起点其余点位连线 drawPoints.forEach((p, i) i 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)) ctx.setStrokeStyle(#130C6D) // 轨迹线颜色 ctx.setLineWidth(2) // 线条宽度 ctx.lineCap round // 线条端点圆角 ctx.lineJoin round // 线条拐角圆角 ctx.stroke() // 执行绘制线条 } // 保存当前绘图样式防止文字/图片样式污染 ctx.save() // 人物头像图片路径 const avatarImg /static/xiaoren.png const imgW 24 // 头像宽 const imgH 24 // 头像高 // 头像坐标居中在轨迹点上 const imgX lastPos.x - imgW / 2 const imgY lastPos.y - imgH / 2 // 绘制头像图片 ctx.drawImage(avatarImg, imgX, imgY, imgW, imgH) // 人员名称文字配置 const userName lastData.name || 丁丁 ctx.font bold 14px sans-serif ctx.textAlign center // 文字居中对齐 const textX lastPos.x const textY imgY - 10 // 文字放在头像上方 // 黑色文字描边增强可读性 ctx.strokeStyle #000000 ctx.lineWidth 2 ctx.strokeText(userName, textX, textY) // 白色填充文字 ctx.fillStyle #ffffff ctx.fillText(userName, textX, textY) // 恢复绘图样式 ctx.restore() // 将所有绘制指令渲染到画布上 ctx.draw() // 未绘制完所有点位定时器递归调用自身继续绘制下一个点 if (this.currentIndex allPx.length - 1) { this.currentIndex this.timer setTimeout(() { this.drawStep(canvasW, canvasH, allPointData) }, this.delay) } else { // 全部点位绘制完成弹出成功提示 uni.showToast({ title: 轨迹绘制完成, icon: success }) } } } } /script style scoped .box { background: gray; height: 100vh; } .trajectory-card { margin: 20rpx 0; width: 100%; height: 600rpx; border-radius: 15rpx; position: relative; overflow: hidden; background: #fff; } /* 背景图、画布铺满容器完全重叠 */ .map-bg, .track-canvas { width: 100%; height: 100%; position: absolute; left: 0; top: 0; } /style/static/xiaoren.png是人物图片uwbPosition是接口里返回的数据