异常项指向人体图
起因
作为一名开发,经常会面临领导提出的各种问题,作为优秀的前端(小菜鸡就是我),当然要做到遇事不慌,虚心为领导解决一切问题。这不我最近就接了这么一个需求,如下图。
没错,正如大家看到的,我需要把用户的体检异常项标红显示出来,很简单,可是我还要把异常项通过虚线的形式指向人体图的对应部位。懵了,这么麻烦,不想写呜呜呜。好了 调整好状态开搞!
思路及技术背景
这个项目是一个微信小程序的项目,因为考虑到需要跨平台打包,所以选择了uniapp
。
这个功能麻烦的点有三个:
- 如何精确找到异常项和对应器官的坐标
- 将两个点连接成线
- 制作线条的动画效果
针对第一点我的思路是定义一个大对象,大对象里定义一个个小对象,每个小对象里包含某个器官的名称及横纵坐标x,y
和器官区域的宽高width,height
(注意:每个对象里的横纵坐标就是对象里宽高区域坐上角的点的坐标,之所以要定义宽高是为了使用canvas进行绘制时先确定每个器官的大致区域),主要代码结构如下
parts: {
'肝': {
x: 130,
y: 120,
width: 30,
height: 30,
},
'肺': {
x: 130,
y: 80,
width: 30,
height: 50,
},
'心': {
x: 160,
y: 80,
width: 30,
height: 50,
},
}
......
接下来获取异常项对应器官的坐标,后端会发送完整的器官列表,通过bodyException
这个字段是否为1来表示该器官是否有异常,所以我定义了一个新的数组organErrorList
用来储存所有异常项的器官,通过对organErrorList
进行遍历,获取到器官名称和我定义的对象parts
里的名称进行匹配,就能得到异常项器官的横纵坐标x,y
。
const organErrorList = 后端数据.filter(i => i.bodyException == "1");
organErrorList.forEach(item => {
//part里包含横纵坐标
const part = this.parts[item.bodyName];
});
器官的坐标得到了,那接下来就是两边异常项的坐标了,我先展示下我的html结构
<ul class='bodys' id="bodys">
<canvas class="canvas" canvas-id="canvas" style="width: 100%; height: 100%;"></canvas>
<li class='left'>
<template v-for="(item, i) in obj.bodys.slice(0,Math.ceil(obj.bodys.length/2))">
<view class="apparatus" :id="item.bodyCode" :key="i"
:class="item.bodyException == '1'?'add':''">
{{ item.bodyName }}
</view>
</template>
</li>
<li class='img'>
<image :src="人体图图片地址" mode=""></image>
</li>
<li class='right'>
<template v-for="(item, i) in obj.bodys.slice(Math.ceil(obj.bodys.length/2),obj.bodys.length)">
<view class="apparatus" :id="item.bodyCode" :key="i" :class="item.bodyException == '1'?'add':''"
style="text-align: right;">
{{ item.bodyName }}
</view>
</template>
</li>
</ul>
我在上述代码中给两边每个器官项通过后台的唯一标识bodyCode
动态绑定了id,方便后面获取异常项的坐标。
在 Uniapp 中,如果需要获取组件的尺寸、位置、滚动信息等属性,可以使用 uni.createSelectorQuery()
方法来创建选择器对象,并使用该对象的方法来获取组件信息。
具体来说,可以在页面中使用 uni.createSelectorQuery()
方法来创建选择器对象,然后使用 select()
或 selectAll()
方法来选择组件。选择器对象还提供了一些其他的方法来获取组件信息,例如 boundingClientRect()
方法可以获取组件的位置、尺寸等信息,scrollOffset()
方法可以获取组件的滚动信息等。
接下来上代码
organErrorList.forEach(item => {
const part = this.parts[item.bodyName];
let parentX;
let parentY;
let sonX;
let sonY;
//获取每个异常项在可视化区域的坐标
this.query.select(`#${item.bodyCode}`).boundingClientRect((res) => {
console.log(res,'res')
sonX = res.left + 40;
sonY = res.top + 10;
}).exec(
//获取父元素在可视化区域的坐标
this.query.select('#bodys').boundingClientRect(parentRect=>{
parentX = parentRect.left;
parentY = parentRect.top;
const bodysWidth = parentRect.width;
this.doCanvas(sonX - parentX, sonY - parentY, part,this.ctx,bodysWidth)
})
)
});
为什么要用异常项的坐标减去父元素的坐标呢?因为接下来的步骤都是需要通过canvas去绘制的,因此我在html结构里定义了个canvas标签,你可以理解为bodys就是接下来的canvas画布,因为我对canvas标签设置了绝对定位,并且宽高和bodys的宽高一致,接下来所有元素的横纵坐标其实都是canvas里可视区域距离,但是我们通过uni.createSelectorQuery()
获取的是整个手机可视区域内的横纵坐标,因此需要通过异常项的坐标减去父元素的坐标才能获取画布内异常项的实际坐标点
如上第一个难点已解决,后面就是用canvas将点连接成线,并且实现动画效果
uniapp中通过uni.createCanvasContext('canvas', this);
创建画布然后绘画直线
doCanvas(x,y,part,ctx,bodysWidth) {
//part是对应器官的坐标点
//x,y是两边异常项的坐标点
let toX = x;
let toY = y;
const targetX = part.x + Math.abs(Math.floor((300 - bodysWidth)/2));
const targetY = part.y + 20;
ctx.moveTo(targetX, targetY);
ctx.lineTo(toX, toY); // 连接线的终点位置
ctx.strokeStyle = '#007AFF';
ctx.stroke();
ctx.beginPath();
ctx.arc(targetX, targetY, 5, 0, Math.PI * 2);
ctx.setFillStyle('#007AFF');
ctx.fill();
ctx.draw(true);
},
也许你会对Math.abs(Math.floor((300 - bodysWidth)/2))
有所疑惑,bodysWidth
是#bodys
的实际宽度,我在parts对象里定义的器官的横纵坐标距离是以手机尺寸为375×812的标准尺寸进行定义的,当手机尺寸大于或小于375的时候,便会出现错位的问题,所以我用手机尺寸375时获取的#bodys
的宽度300-实际不同手机上#bodys
的宽度除以2再取绝对值,就能解决不同手机上的错位问题。
最后如何实现动画效果
canvas动画核心思想:清除屏幕再重新绘制
所以只需要在遍历异常项数据之前先清空画布即可
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
不仅如此,我们还要巧妙地使用canvas里绘制虚线的属性setLineDash
,通过定时改变setLineDash里实线和虚线的长度来产生视觉上的动画效果。
项目完整逻辑代码如下
mounted() {
// 获取画布上下文
this.ctx = uni.createCanvasContext('canvas', this);
//获取元素dom
this.query = uni.createSelectorQuery().in(this);
this.query.select('#bodys').boundingClientRect(parentRect=>{
//获取不同手机屏幕里canvas画布的实际宽高方便清空画布
this.canvasWidth = parentRect.width;
this.canvasHeight = parentRect.height;
})
this.timmer && clearInterval(this.timmer)
this.timmer = setInterval(()=>{
this.drawOrganView()
},1500);
},
drawOrganView() {
const organErrorList = this.obj.bodys.filter(i => i.bodyException == "1");
//*********canvas动画核心思想:清除屏幕再重新绘制**************
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
this.startL = Math.floor(Math.random()*(6-3) + 3);
this.startR = Math.floor(Math.random()*(6-3) + 3);
// 遍历异常项,绘制目标点和连线
organErrorList.forEach(item => {
const part = this.parts[item.bodyName];
let parentX;
let parentY;
let sonX;
let sonY;
this.query.select(`#${item.bodyCode}`).boundingClientRect((res) => {
sonX = res.left + 40;
sonY = res.top + 10;
}).exec(
this.query.select('#bodys').boundingClientRect(parentRect=>{
parentX = parentRect.left;
parentY = parentRect.top;
const bodysWidth = parentRect.width;
this.doCanvas(sonX - parentX, sonY - parentY, part,this.ctx,bodysWidth)
})
)
});
},
doCanvas(x,y,part,ctx,bodysWidth) {
//part是对应器官的坐标点
//x,y是两边异常项的坐标点
let toX = x;
let toY = y;
const targetX = part.x + Math.abs(Math.floor((300 - bodysWidth)/2));
const targetY = part.y + 20;
ctx.setLineDash([this.startL,this.startR]);
ctx.moveTo(targetX, targetY);
ctx.lineTo(toX, toY); // 连接线的终点位置
ctx.strokeStyle = '#007AFF';
ctx.stroke();
ctx.beginPath();
ctx.arc(targetX, targetY, 5, 0, Math.PI * 2);
ctx.setFillStyle('#007AFF');
ctx.fill();
ctx.draw(true);
},
至此这个功能完成。