uniapp上手机端二维图片轨迹路线回放
📅 2026/7/29 22:20:44
👁️ 阅读次数
📝 编程学习
<template> <view class="box"> <!-- 地图容器:背景底图 + 轨迹画布重叠 --> <view class="trajectory-card"> <!-- 底层场地背景图 --> <image class="map-bg" src="/static/ceshi.png" mode="aspectFill"></image> <!-- 顶层画布:绘制轨迹、人物;绑定点击事件拿物理坐标 --> <canvas id="trackCanvas" canvas-id="trackCanvas" class="track-canvas" @tap="handleCanvasTap"></canvas> </view> <!-- 回放控制按钮组 --> <view style="display:flex;gap:20rpx;margin:30rpx"> <van-button @click="handleClick">重新播放</van-button> <van-button type="primary" @click="pausePlay">{{ isPause ? '继续' : '暂停' }}</van-button> </view> <!-- 回放进度条 --> <view style="margin:0 20rpx"> <view>当前绘制进度:{{ progressNum }}%</view> <view style="width:100%;height:20rpx;background:#eee;border-radius:10rpx;margin-top:10rpx;"> <view style="height: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是接口里返回的数据
编程学习
技术分享
实战经验