作者:焦以焜

前言

我们之前已经分享过柱状图,折线图,饼图,并且留下了关于如何实现交互的悬念,在这篇文章中,我将在分享如何实现散点图的同时,分享实现用户与交互的思路。

散点图

我们在讲柱状图时,已经详细的描述了如何绘制我们的坐标轴,折线图的坐标轴与柱状图坐标轴的绘制方法几乎相同,所以在本文我们将不会讨论。关于坐标轴绘制的讲解与箭头绘制的讲解,大家可以查看柱状图绘制:

我们再次声明,非常希望读者可以完全读懂柱状图的这一期文章,因为在大部分图表的绘制中使用到的坐标点,都在该篇文章中详细的解释过,这对理解本文一下内容或今后的内容都起到了至关重要的作用。

数据点的绘制

我们之前所分享的柱状图,折线图与饼图,均只拿了一组数据来进行举例,而在散点图中,应该至少有两组数据。与柱状图和折线图不同,我们还需要给出散点图中的每个数据点半径的算法,代码如下:

drawData() {
        const el = this.$element('the-canvas');
        const context = el.getContext('2d');
        let data = this.option.data; //获取数据集
        let xLength = (this.option.chartZone[2] - this.option.chartZone[0]);
        let yLength = (this.option.chartZone[3] - this.option.chartZone[1]);
        let gap = xLength / this.option.xAxisLable.length;

        //遍历两组数据年份
        for (let i = 0; i < data.length; i++) {
            let x, y, r, c;
            context.fillStyle = this.option.colorPool[i]; //从颜色池中选取颜色
            context.globalAlpha = 0.8; //为避免点覆盖,采取半透明绘制
            //遍历各个数据点
            for (let j = 0; j < data[i].length; j++) {
                //计算坐标 由于举例的数据太大,我做了微调的处理,但是这违背了封装性且为魔鬼数字,不推荐
                x = this.option.chartZone[0] + xLength * data[i][j][0] / 70000;
                y = this.option.chartZone[3] - yLength * (data[i][j][1] - 55) / (85 - 55);

                //散点图半径算法
                //直接数值
                r = data[i][j][2] * 5 / 100000000;
                //求对数
                r = Math.log(data[i][j][2]);
                //开根号
                r = Math.pow(data[i][j][2], 0.4) / 100;

                let singleData = {
                    position: [x, y], radius: r, color: this.option.colorPool[i]
                }
                //将所有的数据点保存
                this.allData.push(singleData)
                //绘制散点
                context.beginPath();
                context.arc(x, y, r, 0, 2 * Math.PI, false);
                context.fill();
                context.closePath();
            }

        }
        context.restore()
    },

实现交互

我们希望实现的效果为:点击一个数据点后,该数据点变大且有动画效果最好,而点击空白处后该数据点变回原样。

如下图所示:
屏幕录制 20220403 下午5.29.15_.gif
这个功能可以分成三步:

  1. 找到鼠标点击位置的数据点
  2. 将该数据点放大
  3. 点击空白处后将数据点缩小回原大小

找到鼠标点击位置的数据点

遍历所有的数据点,鼠标点击的坐标到某个数据点的距离若小于或等于该数据点的半径,则可以认定鼠标在该数据点上。

将该数据点放大

假设我们希望放大后的数据点比原数据点的半径大15px,我们可以直接以原数据点为圆心,画一个原数据点半径+15的圆,直接覆盖住原数据点。我们也可以写一个for循环,循环i = 300 次,每次都延时300毫秒画一个 i * 0.05 + 原数据点半径 的圆,以实现动画效果。注意,我们的操作只是在视觉上给了用户一种对原数据点操作了的错觉,我们一直没有对原数据点进行操作。如果我们仔细的看上面的效果图会发现,变大的数据点颜色变得更深了,那是因为我们画了300个圆,而这些圆虽然透明度和颜色相同,但是重叠在一起之后使得整体的颜色改变了。

将数据点缩小

我们可以很容易的想到以下两种方法来实现缩小的需求:

  1. 我们可以通过画一个和原来半径相同,圆心坐标相同的圆来实现吗?

如果这样操作得出的效果只是在放大的圆的基础上再画了一个小的圆:因为大圆不会因为画了一个小圆就消失了。在canvas上画的内容就像我们在一张白纸上用水彩笔画画,除非用涂改液将画的内容涂掉,否则图画将一直在纸上存在(其实用涂改液也只是将纸上的某一部分与背景颜色相同,用涂改液也只是在视觉上让我们觉得画的内容消失了,但其实它仍然存在着)。

  1. 我们可以通过把多余的那部分截掉来实现吗?

将变大的圆截成一个原大小的圆确实实现了一部分我们的需求:从大变小。可是透明度却回不去了,在视觉效果上,圆被改变了;如果散点图中该圆旁边有其它的相交的圆,那也将会受到影响。

我们可以换一个思考的场景:假设我们现在需要把某张照片中的一个人给P掉,只需要一张角度和场景完全一样且没有该人物的图片,在这张图片上截取一个与目标人物位置与形状完全相同的部分,将该部分贴在照片上,这样照片上我们希望被p掉的这个人就被覆盖了。

同理:我们在绘制该图表的同时,就可以通过toDataURL这个API,将该canvas图表转换成一张图片并保存起来。

 draw() {
        this.drawBackground()
        this.drawAxis()
        this.drawYLables()
        this.drawXLables()
        this.drawData()
        this.drawArrow()
        this.drawArrowY()
   //将canvas转成webp格式的图片
        const el = this.$element('the-canvas');
        this.dataURL = el.toDataURL("image/webp", 1)
        console.log("dataurl:" + this.dataURL)
        this.canvas = new Image()
        this.canvas.src = this.dataURL
    }

如果我们点击了某个数据点使它放大了,如下图:

A1.png

此时我们就计算出它的左上角的位置坐标:(x-r-15, y-r-15),x和y为圆心的坐标,r为放大前圆的半径,15是我们放大的长度。

这时候,我们在刚才保存好的原图图片上,相同的位置截取一个相同大小的正方形,贴在此时的canvas上,覆盖住放大的圆形。

A2.png
注意:我们已经从视觉上实现了将数据点缩小,但是此时数据点上的圆是图片格式,是我们从图片上截下来贴在canvas上的图片,而不是canvas,我们不能再对它进行操作了,所以我们还需要再在该圆的圆心位置画一个透明且大小与原数据点一样的圆形,以便我们能够再次将该数据点点击放大。

代码如下:

hover(e) {
        const el = this.$element('the-canvas');
        const context = el.getContext('2d');
        //获取点击的坐标
        this.touchX = e.touches[0].globalX
        this.touchY = e.touches[0].globalY
        for (let i = 0; i < this.allData.length; i++) {
             //遍历所有的数据点,判断点击的位置是否在某个数据点上
            if (Math.pow((this.touchX - this.allData[i].position[0]), 2) + Math.pow((this.touchY - this.allData[i].position[1]), 2) <= Math.pow((this.allData[i].radius), 2)) {
                context.fillStyle = this.allData[i].color;
                context.globalAlpha = 0.3;
                this.hoverData = {
                    x: this.allData[i].position[0],
                    y: this.allData[i].position[1],
                    r: this.allData[i].radius,
                    color: this.allData[i].color
                }
                let step = 0.05
                //如果点击的位置在数据点上,则将该数据点放大
                for (let j = 0; j < 300; j++) {
                  //用setTimeout实现动画效果
                    setTimeout(() => {
                        context.beginPath();
                      //画圆
                        context.arc(this.allData[i].position[0], this.allData[i].position[1], this.allData[i].radius + j * step, 0, 2 * Math.PI, false);
                        context.fill();
                        context.restore()
                        context.closePath();
                    }, 300)
                }
            } else {
              //如果不在数据点上,且没有点击过数据点,则不操作
                    context.globalAlpha = 1
                    console.log("this.hoverData:" + JSON.stringify(this.hoverData))
                    let x = this.hoverData.x
                    let y = this.hoverData.y
                    let r = this.hoverData.r
                    console.log("此时的x为:" + x)
              //将图片上的部分裁剪下来贴在cavnas规定的位置上
                    context.drawImage(this.canvas, x - r - 15, y - r - 15, 2 * (r + 15), 2 * (r + 15), x - r - 15, y - r - 15, 2 * (r + 15), 2 * (r + 15))
                    context.save()
                    context.beginPath()
              //最后画一个圆形覆盖住贴过来的图片上的圆
                    context.arc(x, y, r - 15, 0, 2 * Math.PI, false)
                    context.closePath()
                    context.fillStyle = "black"
                    context.globalAlpha = 0
                    context.fill()
                    context.restore()
            }
        }

    },

然而该方法仍然有不尽人意的地方:从下图可以看见,通过drawImage剪切过来的图片与原canvas相比有明显的锯齿,清晰度有差异

缺点.png

总结

本文只提供了一种思路供大家实现这种交互的需求,而且是不够完善的,假如我们点击了两圆相交的位置该如何解决?假设点击了某个内含的圆,交互方式应该如何定义?这些具体的情况希望大家能够积极发帖或者参与讨论,让canvas数据图表系列的话题不要停滞,在提高自己的同时也能够充实社区的知识储备。而本篇文章也是OpenHarmony的JS canvas数据可视化系列的最后一篇文章,谢谢大家。

更多原创内容请关注:深开鸿技术团队

入门到精通、技巧到案例,系统化分享HarmonyOS开发技术,欢迎投稿和订阅,让我们一起携手前行共建鸿蒙生态。

想了解更多关于鸿蒙的内容,请访问:

51CTO和华为官方合作共建的鸿蒙技术社区

https://ost.51cto.com/#bkwz

::: hljs-center

21_9.jpg

:::