发布于 

企业可视化大屏:基于ECharts实现的水球气泡散点图

项目简介

预览地址

项目效果
项目效果

撇开列表/进度条/环形图这种比较一般的图表不看,
整个项目的主要难点就在于散点图、散点图和它之后与圆环图的交互,这也是它的主要亮点。

项目难点

项目难点

拿到设计图之后,首先注意到几个难点:

  • 水球气泡散点图
    • 如何实现单个水球图
    • 水球图如何映射到坐标系中
  • 水球+外环图
    • 如何实现这个图
    • 怎么实现和外侧水球图的交互动画
  • 任务进度/工时趋势时间线图

技术选型

开发框架 Vue3+Vite

都是我用的比较熟的:

资源加载

可视化大屏对性能要求较高,
因此图片尽量选取webp或者svg格式的图片

据说还有一种方法:为了避免频繁的图片资源请求,将图片都用dataURL的格式加载

网络请求 axios

因为这个项目对实时性要求并不高,因此采用客户端向服务端定时请求的模式,
但是有一点就是当数据请求不来时(可能是后台服务500,或者返回空数据),
这里就有三种处理方式:

  • 什么都不处理,只对报错进行catch,这时页面会空白,用户体验会降低
  • 用loading页作遮罩
  • 使用Service Worker,可以把上次请求的结果作为本次的结果返回,页面内数据不变

看了mdn关于 Service Worker 的介绍,主要是面向PWA应用的,所以这里决定采用使用loading页作遮罩的方式。

可视化图表开发过程中的数据请求问题

可视化图表开发的两个重点就是数据和图表,
在开发过程中一般我都会模拟一些图表绘制的数据,并把图绘制出来,
但是接口一旦对接之后就会出现各种各样奇怪的问题。

可视化图表的数据处理真的非常重要!非常重要!非常重要!

数据处理的几个可能出现的问题如下:

  • 后台提供数据和前端数据不一致:
    • 在请求到后台数据,和得到最终绘制出图表数据之间,尽量比较清晰集中的添加一层数据处理层,不要把数据处理分散到各种各样奇怪的地方
  • 考虑数据极端情况:
    • 除数为0、数组长度为0等等一类情况都需要被考虑在内,这些bug可能会藏得很深,到很后期的时候才显现出来
  • 贯穿整个开发流程的前端独立测试
    • 前期使用静态数据还好,但是如果切换为后台数据,中间逻辑大概率要调整,绘制逻辑也变得越来越复杂
    • 这个时候,如果要涉及到一些细节部分的修改,可能就会非常复杂,比如我想要让图表某个类别显示某个颜色啦,如果好巧不巧后端就不返回这个类别,那就看不到效果
    • 所以就算是数据已经从接口中获取了,也一定要做好测试数据的生成,将测试数据和后台数据同步
    • 后台服务断开时,前端要有离线模式继续支撑开发
图表开发的数据源

开发依赖数据进行展示的可视化项目,页面的展示依赖数据驱动。
图表的开发比较特殊,比如ECharts,在使用vite提供的开发环境下,修改代码触发的热更新无法驱动页面ECharts图表的重绘,

所以就需要每次都手动刷新页面查看修改后的效果,
更致命的是,越到后期,图表的微调就有可能越复杂,
这时如果图表的数据源已经使用接口数据了,就需要等待接口数据到来(数据量少还好,数据量多简直是折磨)

以前参与开发的一个卫星轨迹图就有这种情况,上百条卫星带着各自在时间段里的轨迹数据
对卫星点迹进行微调时,要用3-7s获取后台数据,再渲染,再查看效果,好窒息

这个时候一般都会搭建一个临时的服务器,模拟后台接口,返回固定数据

图表技术:ECharts

一开始选用ECharts,是考虑到开发工期比较急,挑个比较熟的库就行。
但是随着绘制的进行,还是觉得如果选用 D3 ,可能更适合这个项目(可惜我对D3不是很熟)
但是我对ECharts的一些基础联动,比如dataZoom、动画、事件绑定、创建销毁之类的,我都比较熟悉

总体来看,如果我对D3的熟悉程度允许的话,应该会选择D3。
但是目前来看ECharts绘制出来的效果也还可以(就是中间有点小差错)

图表的选择

事实上,这个项目最后的大部分效果都能用ECharts实现,
平面绘制,大多数涉及到的效果无非就是:尺寸、色彩、布局,
静态页面的绘制一般涉及到的,大多数主流图表库都能够实现。

当我说图表的选择时,实际上一般需要考虑的,是这个图表要实现的交互的上限是什么。

因此首先就要先熟悉各个图表库的交互的特点,这个并不简单,
但是我这里可以简化思维:

如果产品经理和设计根本没设计交互,用ECharts,因为ECharts的交互和动画效果最为主流接受
如果设计了交互,但是交互都比较保守,用ECharts,理由还是一样
如果设计的交互非常新颖,ECharts已经满足不了,用自由度最高的,D3

当然还有HighChart、AntV等等其它的选择,
但有的时候真的不想考虑太多,顺手就行。

但是这次的设计有一个点,用ECharts还是没有办法实现的,就是半透明颜色之间交叠时出现的加深效果:

设计图
设计图
实际效果
实际效果

可以看到设计图中,颜色交叠处有种ps中正片叠底/加深图层的感觉,
但是实际的图表绘制中,颜色的交叠部分是上层的颜色覆盖下层的颜色,

ECharts实际是提供混合模式配置项的,也就是blendMode

不开启blendMode
不开启blendMode
全开启blendMode
全开启blendMode
下层图层开启blendMode
下层图层开启blendMode
上层图层开启blendMode
上层图层开启blendMode

但非常坑爹的是,blendMode只支持2个值:

  • lighter 变亮
  • source-over 默认值

并且只有在渲染器renderer为canvas类型时,blendMode才生效

因为这里我用的是svg,所以大概率是没戏了。
但是还有一种方案:

绘制两层图层,
一层用canvas绘制,用于颜色控制,
一层用svg绘制,用于交互配置,

不过太麻烦了,查看echarts-liquid库源码,github上已经很久没有更新过了,
实际上结构并不复杂,如果花这么多时间基于现有的库去实现这样一种颜色混合的功能,说实话还不如研究一下如何自定义ECharts类型简单

这里的label和text的颜色有一个反相效果(svg渲染中),
实际上是两个文字叠在一起,一层作为底层,一层作为上层,上层文字被水波图形给剪掉了
但是能看到,因为clip-path指定的仅仅是对应series中的水波path,
这里也能看出canvas和svg处理图像的不同,
svg没有颜色缓冲区的概念,更强调元素之间的绑定关系
canvas会把画布上的颜色作为缓冲区进行维护,后加的颜色会在之前计算的颜色的基础上进行混合

时间线表技术:Vis-Timeline

选用 vis-timeline ,主要是考虑到它自带缩放和滚动的时间轴组件

动画:GSAP

没法用css动画插值的属性,可以用 GSAP 处理。
用来处理不熟悉的svg动画也很方便

注意事项

可视化大屏相对一般项目有几点需要注意的:

  • 自适应
  • 样式性能
  • resize
  • 动画
自适应

用rem!用rem!用rem!
rem依赖根节点的font-size进行尺寸判断,
因此仅需要在首次进入页面和之后resize时动态修改根节点的font-size进行重新计算:

1
2
3
4
5
6
7
8
9
10
11
// 此处最大适配宽度3840,最小适配宽度1024
export function resize(){
return new Promise((resolve,reject)=>{
let rootWidth = document.documentElement.clientWidth || document.body.clientWidth
let rootDom = document.querySelector('html')
let k = 16/1920
let b = 16 - 1920*k
rootDom.style.fontSize = (k * rootWidth + b) + 'px'
})
}
window.addEventListener('resize',resize)

我用的是Vue框架,组件的mount一定要在resize之后进行,
否则一些echarts表格开始绘制时,正确的rem大小还没有被计算出来:

1
2
3
resize().then(res=>{
app.mount('#app')
})

另外,需要对网页能兼容的最大尺寸和最小尺寸进行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
.wrapper{
background: #e9effa;
width: 100%;
height:67.5rem;
min-width: 1024px;
min-height:768px;
max-width: 3840px;
max-height:1838px;
display: flex;
flex-direction: column;
position:absolute;
overflow-x: hidden;
}

将rem的动态范围也限制在这个范围之内,
和《CSS揭秘》这本书作者强调的一样,样式编写需要DRY(Don’t Repeat Yourself)
能用动态单位(em、rem、vh、vw)就用动态单位,
灵活使用css变量、函数、响应式布局

如果你只有通过修改大量css代码才能实现页面适配,
那就要反思一下是不是你的页面实现存在问题。 ————《CSS揭秘》

根据尺寸自适应页面大小
根据尺寸自适应页面大小
样式性能
  • css原生变量非常好用
  • 背景图片尽量选用svg或者webp格式
  • 直接使用dataurl编码的图片,无需请求资源,性能更高
  • 通过动态计算的配色方案往往效果更好,并且可维护性更好
  • hsla非常好用
resize

resize实际上就是另一种意义上的“自适应”,
但是不同点在于我用了echarts,ECharts的resize需要手动触发,
不仅如此,用css的rem控制的页面属性在resize时能够自适应,但是echarts里option的配置基本在创建的时候就写死了,
我的做法是在window.resize时,重新计算option里面所有涉及单位的属性,
不知道有没有更好的方法。

anime

不仅仅是可视化大屏,任何有很多动效需要配合的项目都是如此,那就是需要对这些被打的乱七八糟的动画帧进行统一管理,
否则后期对某个动画帧的改变,会波及到整个页面其它动画的效果。

水球气泡散点图

如何实现

基本思路

首先抛开花里胡哨的包装,单看它要实现什么:

  • X轴:任务数量
  • Y轴:参与人数
  • 水波高度:任务进度
  • 气泡半径:项目工时
  • 气泡颜色:项目状态

也就是说一张图展示5个要素,
根据X轴、Y轴坐标进行映射,是散点图的特性,
根据水波高度判断进度,是水球图的特性,
根据工时对半径进行控制,是气泡图的特性,但是散点图也支持

三种方式

所以最接近我们要绘制的目标的实际是散点图,
但是散点图上需要有一个类似水球图的波浪动画,因此有如下三种方式:

  • 利用散点图的symbol属性
    • symbol属性支持DataURL类型的数据,因此完全可以对散点图的节点进行自定义化
    • 但是问题在于如果是吧另一张echarts图表的绘制结果canvas/svg,转化为dataurl作为新节点的symbol,这是一张写死的图片,交互性非常有限,因此pass
    • 改进方式:不利用echarts实现的图表,而是自定义svg,将动画效果和交互都作为svg的一部分转换为dataUrl,保留交互性和动画
  • 利用echarts的custom图表
    • 知道有这种方式,但是不熟,所以pass
  • 利用echarts的水球图
    • echarts本身支持水球图,可以将水球图作为散点映射到坐标系上,似乎只在坐标映射上有工作量,最终选择这种方式

坐标系映射

这里的思路就是把坐标系和上面的散点图从绘制层面完全分离,
不用ECharts提供的坐标系和数据的联动,
而是手动实现中间的一些联动,
也是考虑到这张图在坐标系上需要实现的联动效果不多,如果还要加上datazoom之类的坐标轴缩放、平移联动,可能会更加复杂

拿到一堆数据后,需要做如下操作:

  • 计算出这批数据映射出球体的包围盒,得到整张图的left、right、top、bottom边界
  • 根据边界值,动态计算出坐标系的边界
  • 拿到边界,绘制出坐标系
  • 再将数据的x、y值在坐标系中的位置映射为px定位值,这里用的是ECharts提供的convertToPixel函数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    function convertAxisToPixel(data){
    return data.map(node=>{
    node.center = chart.convertToPixel(
    {xAxisIndex:0, yAxisIndex:0},
    [node.taskNum, node.peopleNum]
    )
    return node
    })
    }
    最终就能将水球映射到页面中了,但是这种方法也有弊病:
  • 性能问题,每一个水球都是一个独立的series,和真正的散点图的性能相比很低
  • 坐标系边缘的计算比较复杂,涉及到更多数据处理的逻辑
坐标系边界检测

这里没有使用echarts自带的坐标系,手动实现坐标系的自适应,并非最初想的这么容易。

由于球体和坐标系不在一张画布上,
所以要知道球体是否出界,就必须知道画布的包围盒和坐标单位

以计算右边缘坐标为例,基本的实现思路就是:

初始化坐标轴右边缘为0
开始遍历每个点
计算点的右边缘
如果点的右边缘超过当前坐标轴右边缘
需要向坐标轴右侧添加单位(注意,在添加单位的同时,整体坐标单元也会缩小)
计算出添加的单位数量
右边缘加上添加数量,重新计算坐标单位
遍历结束,计算完毕

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
data.forEach((node, index)=>{
// 计算x轴,y轴范围
let r2px = node.mapRadius/2/100*content_h // 半径
let x = (node.x - axis_range.x[0]) * x_unit
let y = (axis_range.y[1] - node.y) * y_unit
let left = x - r2px - gap_left
let right = x + r2px + gap_right
let top = y - r2px - gap_top
let bottom = y + r2px + gap_bottom

if(left<0){ // 左侧超过
let n = axis_range.x[1] - axis_range.x[0]
let left_add = Math.ceil(((node.x - axis_range.x[0]) * content_w -r2px*n)/(r2px - content_w))
axis_range.x[0] -= left_add
x_unit = countUnit(content_w, axis_range.x[1] - axis_range.x[0] )
}
if(right>content_w){ // 右侧超过
let n = axis_range.x[1] - axis_range.x[0]
let right_add = Math.ceil((content_w * n - (node.x - axis_range.x[0]) * content_w - r2px*n) / (r2px - content_w))
axis_range.x[1] += right_add
x_unit = countUnit(content_w, axis_range.x[1] - axis_range.x[0] )
}
if(top<0){ // 上侧超过
let n = axis_range.y[1]-axis_range.y[0]
let top_add = Math.ceil(
(r2px*n - content_h*axis_range.y[1] + content_h * node.y) / (content_h - r2px)
)
axis_range.y[1] += top_add
y_unit = countUnit(content_h, axis_range.y[1] - axis_range.y[0] )
}
if(bottom>content_h){ // 下侧超过
let n = axis_range.y[1]-axis_range.y[0]
let bottom_add = Math.ceil(
(content_h*axis_range.y[1] - content_h*node.y)/(content_h-r2px) - n
)
axis_range.y[0] -= bottom_add
y_unit = countUnit(content_h, axis_range.y[1] - axis_range.y[0] )
}
// 推入均匀模式下的x、y值
x_category.push(node.x)
y_category.push(node.y)
})
数据重叠问题

可视化图表开发,数据非常重要,图表是用来突显数据的特性的,但如果数据没有这种特性,图表的优势就很难得到发挥,有时甚至给人的观感会非常糟糕

这个图表的设计就是个例子,这种图用于展示那些在x轴、y轴上分散比较均匀,并且彼此很难重叠的数据较好,
但是这里设计的是参与人数和任务数量,
企业的项目一般都趋于同质化,大多数项目的参与人数和任务数量都差不多,所以最终真实数据填入后,一定会映射出非常密集的效果。

但这是设计层面的问题,那么如何在知道点可能会变得很密集的情况下,
从矢量层面也好,从视觉层面也好,将图表的效果优化呢?

坐标轴模式转换

好在产品经理提出一张图最多只绘制20个球(后面改成了15个),有了数量控制,就能提供一种均匀模式,

之前之所以不均匀的原因就在于x轴、y轴都选用的是数据连续类型,数据是在0-max之间连贯映射的,
因此还可能出现噪点数据,破坏整个映射的效果,

均匀模式就是将x轴、y轴设置为离散类型,仅仅是按照从小到大的顺序排序,

下面是连续模式和离散模式之间的对比:

离散模式(先)&& 连续模式(后)
离散模式(先)&& 连续模式(后)
半径散射

这里水球的半径需要能够反映权重的大小,
最初的做法是将最大半径和最小半径分别设置为高度的50%和10%,
将水球的权重(0-1)线性映射到这个范围之中:

1
2
3
let minRadius = 10, maxRadius = 50
// node.effect为权重
let radius = node.effect * (maxRadius - minRadius) + minRadius // 线性映射结果

结果很快发现有问题:

如果数据也是线性分布的,那效果还好,但抛去这个几乎不会出现的线性分布,其他时候效果都是很灾难的

  • 数据离散度高
    • 数据偏高,大球重叠 [y = (max-min)x^2 + min]
    • 数据偏小,小球重叠 [y = (max-min)x^(1/3) + min]
  • 数据离散度低
    • 一般数据都是挤在中间,导致不大不小的数据重叠在一起,最灾难的情况
      • gt = 大于平均值的数据数量
      • lt = 小于平均值的数据数量
      • n = 数据总数量
      • a = (gt - lt)/n
      • y = a·x^2+(max-a-min)x+min
如何计算数据的离散程度

如何判断一组数据离散程度的高低?
这里使用的是标准差方法:

1
2
3
4
5
6
7
8
9
let variance = data.reduce((a,b)=>{
return a + Math.pow(b-ave, 2)
},0)/data.length // 方差
let standard_var = Math.sqrt (variance) // 标准差
let effect = standard_var/ave

let SDR = effect > 0.25 // true表示数据相差大,false表示数据相差小

console.log(`标准差:${standard_var},标准差大于指定值:${SDR},数据偏向:${dir}`)

因此也就是使用幂函数的方式进行映射处理

幂函数映射
幂函数映射

最终的映射效果要好一些:

颜色划界

如果相邻两个元素的颜色能区分开,那是最好不过的了,
(本项目中,最终终于把颜色从图例的意义中分离出来,用于区分相邻2个元素的颜色了)

水球选中

当前问题

这张图绘制到现在都还算比较顺利,
问题就出在这里了,因为我现在想要选中水球,但是我们此刻希望把它当做一个整体进行选中,这也是遵从散点图的symbol选中特性的一种交互,
但是水球图的弊病现在暴露出来了:它的选中机制在水波上,没有提供整体选中的配置项(WTF)
不仅如此,就连用echarts.on绑定的任何鼠标事件,竟然也只能由水波触发,那这用户肯定不能接受。

解决方法

奇怪的是,虽然我鼠标移入球体(没到水波部分)时,虽然鼠标事件没有被触发,但是鼠标的pointer样式却改变了,
因此一定是有某种判断鼠标移入的方式的,在zrender/lib/Handler.js中,找到了对鼠标pointer进行修改的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Handler.prototype.mousemove = function (event) {
var x = event.zrX; // 鼠标x
var y = event.zrY; // 鼠标y
var isOutside = isOutsideBoundary(this, x, y); // 判断鼠标是否落在包围盒外
var lastHovered = this._hovered; // 上一次hover的位置
var lastHoveredTarget = lastHovered.target; // 上一次hover的目标
if (lastHoveredTarget && !lastHoveredTarget.__zr) {
lastHovered = this.findHover(lastHovered.x, lastHovered.y);
lastHoveredTarget = lastHovered.target;
}
// 如果落在包围盒外,创建一个新的hover对象,反之,找到当前hover对象
var hovered = this._hovered = isOutside ? new HoveredResult(x, y) : this.findHover(x, y);
var hoveredTarget = hovered.target; // hover目标
var proxy = this.proxy;
proxy.setCursor && proxy.setCursor(hoveredTarget ? hoveredTarget.cursor : 'default');
if (lastHoveredTarget && hoveredTarget !== lastHoveredTarget) {
this.dispatchToElement(lastHovered, 'mouseout', event);
}
this.dispatchToElement(hovered, 'mousemove', event);
if (hoveredTarget && hoveredTarget !== lastHoveredTarget) {
this.dispatchToElement(hovered, 'mouseover', event);
}
};

实际上能看到一个isOutsideBoundary函数是用于做鼠标移入判断的,
于是我就想手动实现一个同样的包围盒判断,
但是这个时候,canvas绘图的弊病就出来了,就是较低的dom交互自由度,
我的所有水球都是绘制在一张canvas上的,这就意味着我需要获取到每个形状的元信息,经过非常复杂的判断才能知道我鼠标点击的是什么!
还好EChats还提供了svg绘制的选项,用svg实现dom交互就简单多了:

1
echarts.init(chartDom,'',{renderer:'svg'})

我是可以获取到自己点击的元素了,但是怎么知道点击的是哪一个水球呢?
打开chrome控制台查看dom树,能发现echarts使用svg绘制多个series的一个规律:
它会把所有series绘制在一个svg内部的一个g中,并且按照z顺序进行从上到下的绘制,
也就是说,如果它绘制一个水球需要16个标签,总绘制20个水球图,那么g中就会有16*20=320个标签

一个水球16个标签
一个水球16个标签

事实上,这里绘制标签的数量不一定是16,期间可能有涉及到富文本的标签(多个text标签),为实现阴影效果的标签,等等,
但重点是绘制每个水球的标签数量都是固定的,且顺序不变,这就好像WebGL绘图中常用的ArrayBuffer一样,用索引进行区分。

所以这里只需要根据绘制每个水球的标签数n,和鼠标移入标签在父元素g中的顺序,就能获知点击的是第一个水球:

1
2
3
4
5
6
7
8
9
10
11
const svg = document.querySelector(`#${props.domId} svg`)
const svg_g = document.querySelector(`#${props.domId} svg g`)
const svg_children = Array.from(svg_g.children)
const domNum = 16; // 一个svg包含16个标签
let index = svg_children.indexOf(e.target)
if(index<0){
let parent = e.target.parentNode
index = svg_children.indexOf(parent)
let seriesIndex = Math.floor(index/domNum)
currentSeriesIndex = seriesIndex // 当前被触发的水球索引
}

能获取到水球索引,但是这时还有问题,就是存在一些干扰因素,

  • 水球的阴影标签会导致实际的鼠标触发范围要大很多
  • 水球的波浪标签是一个被clip的path,实际的尺寸也要宽很多

所以应该把这两个元素的鼠标事件禁掉(pointer-events:none),好在这两个效果都是用g标签实现的,和其它元素很好区分:

1
2
3
4
5
// 对svg事件进行处理 波浪和阴影不可点击
function svgEventHandle(svg){
const allG = svg.querySelector('g').querySelectorAll('g')
allG.forEach(g=>g.style.pointerEvents = 'none')
}

最后还有一个问题,echarts使用path绘制圆球,但是path最终的dom监听区域是一个方形,将这个圆球包围住,
所以需要根据path的包围盒手动判断鼠标是否移入的是包围盒内的圆球区域,仅有移入圆球区域时才判断当前水球被选中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
currentSeriesIndex = undefined
currentRect = undefined
svg.style.cursor = 'default'
if(index>=0){ // svg dom判断点击到了东西
let seriesIndex = Math.floor(index/domNum)
let path = svg_children[seriesIndex * domNum] // 目标series的范围圆
let rect = path.getBoundingClientRect() // 范围圆的包围盒子
let x = e.x , y = e.y;
if(x>=rect.left && x<=rect.right && y>=rect.top && y<=rect.bottom){ // 目标在包围盒子内
let radius = rect.width/2 // 包围圆半径
let cx = rect.right - radius, cy = rect.bottom - radius // 圆心绝对位置
let dx = Math.abs(x - cx) , dy = Math.abs(y - cy)
let diff = Math.sqrt(dx*dx + dy*dy)
if(diff <= radius){
svg.style.cursor = 'pointer'
currentSeriesIndex = seriesIndex // 当前激活水球
currentRect = rect // 当前包围盒
}
}
}

辐射水球图

如何实现

pie+liquid

乍一看这个图,似乎很像旭日图,因为中心球四周的弧度辐射出去的半径长短不一,
但是旭日的半径延长出去是有层级关系的意义的,这里则不是,
因此这里采用的是弧度饼图的方式,
即有几个圆弧,就要在option.series中塞入几个元素,
然后计算出每个圆弧的初始角度和结束角度。

这里还有一个需要注意的是每一段圆环的渐变色,
ECharts是提供渐变色的,在线性渐变中,设置渐变色的2端只能通过指定绘制元素上的一条方向向量的方式进行绘制

就算是这里用的饼图也是一样,设置radial渐变也是以每个弧段以自己的中心做radial渐变

所以这里每个弧段渐变色的方向向量都需要手动计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

let minRadius = innerRadius + 1 // 最小外半径(40 - 80)
let maxRadius = 75 // 最大外半径
let angleDiff = 135 // 角度偏移
data.forEach((node,index)=>{
let temp = [0,1].includes(index)?(index+1):index
const i = (temp+1)%pieColor.length
const color = pieColor[i]
let pieOption = copy(pieOptionTemp)
/** 计算外半径 */
const outerRadius = useEffectMap(node.valueEffect, minRadius, maxRadius)
pieOption.radius[1] = `${outerRadius}%`

/** 计算起始角度 */
pieOption.startAngle = - index * (angle + gap) + angleDiff
// let realAngle = angle>20? 20:angle // 真正的跨越角度
pieOption.endAngle = pieOption.startAngle - angle

// 行高应该为弧度的长度
let height = outerRadius * canvasSize[1]/100 * Math.sin(angle/180*Math.PI/2)
pieOption.label.lineHeight = height
pieOption.label.height = height

let middle = (pieOption.endAngle + pieOption.startAngle)/2

/** 计算渐变 */
let x = Math.cos(-middle/180*Math.PI)
let y = Math.sin(-middle/180*Math.PI)
pieOption.itemStyle.color.x = x/2+0.5
pieOption.itemStyle.color.y = y/2+0.5
pieOption.itemStyle.color.x2 = -x/2+0.5
pieOption.itemStyle.color.y2 = -y/2+0.5

// 外圈颜色
pieOption.itemStyle.color.colorStops[0].color = getHSL(color.color, color.alpha)
// 内圈颜色
pieOption.itemStyle.color.colorStops[1].color = getHSL(color.color, 30)

options.push(pieOption)
})

还有一点需要注意,就是当辐射弧段过多时,如果设置固定的字体大小,就会出现label挤在一起的情况,
所以这里的label字体大小也需要动态计算

过渡动画

点击水球后,水球从图上位置浮现出一个一模一样的替身,然后迁移到画布[30%, 50%]的位置。

如何实现这个效果?

实际上就是计算被点击点和目标点位之间的dx、dy,然后对辐射图的画布进行平移。
使用gsap进行css transform插值,

包围盒参与计算的注意事项

这里计算实际的偏移量,使用到了包围盒,即被点击球体的包围盒。
目标点位是[30%, 50%],它的px坐标也比较好计算:

1
2
3
4
5
6
7
8
9
10
11
12
// 获取画布饼图展示的中心点 [30%, 50%]
function getCanvasPieCenter(){
const target = document.getElementById(props.domId)
const boundBox = target.getBoundingClientRect()
const grid = props.grid
let canvasWidth = boundBox.width - grid.left - grid.right // 画布宽度
let canvasHeight = boundBox.height - grid.top - grid.bottom // 画布高度
canvasSize = [canvasWidth,canvasHeight]
let halfWidth = canvasWidth * 0.3, halfHeight = canvasHeight * 0.5 // 一半的宽高
center[0] = halfWidth + boundBox.left + grid.left
center[1] = halfHeight + boundBox.top + grid.top + window.scrollY
}

这里得出的是目标点相对整个page(包括所有可滚动区域)的坐标,
但是包围盒是相对view视口的,所以在页面有滚动时,一定要将scrollTop的值考虑在内,下面是包围盒中心点以及偏移距离的计算:

1
2
3
4
5
6
7
8
9
10
11
12
// 获取偏移量
function getRectCenter(){
// 当页面垂直滚动时,包围盒中心点会计算会忽略页面滚动值scrollTop
rectCenter = [ // 包围盒的中心点
boundRect.left + boundRect.width/2,
boundRect.top + boundRect.height/2 + window.scrollY
] // 目标圆当前中心点
const dX = center[0] - rectCenter[0]
const dY = center[1] - rectCenter[1]
// console.log(`centerX:${center[0]} centerY:${center[1]} rectCenterX:${rectCenter[0]} rectCenterY:${rectCenter[1]} dX:${dX} dY:${dY}`)
return [dX,dY]
}

任务进度/工时趋势时间线图

时间线图

至此,看似所有最关键的问题都被解决了,但现在页面的切图刺客来了,

以前对这种时间轴图表了解的确实不多,
这里就用的我唯一比较熟悉的 vis-timeline

之所以选择它,也是考虑到它提供的下面几个功能:

  • 时间轴缩放
  • 时间轴拖动
  • 时间单位自适应
爆改vis-timeline

vis-timeline不像echarts,它是基于dom的,所以css配置项全都丢给用户自己通过className进行配置,
这一点让vis-timeline的样式自由度变得非常高,
vis-timeline本身应该是支持用html定制其中的内容的,
但是无奈这一点是我开发一半之后才在文档的犄角旮旯里找到的(vis-timeline的文档精简到一页就没了)
所以我使用的方法就是简单粗暴的dom操作。

这要从vis-timeline的一个钩子函数说起——onInitialDrawComplete
这个函数代表时间轴表已经绘制完毕了,我需要根据之前标识的className,
找到对应的dom,然后手动给里面加东西,
vis-timeline此时的作用就像是提供一个模具,至于里面是什么,我自己添加。

1
2
3
4
5
6
7
8
9
10
  let options = {
onInitialDrawComplete:()=>{ // 绘制结束的回调
setProgressStyle() // 自定义进度图样式
setHeadStyle() // 自定义表头样式
addEvent() // 添加事件
loading.value = false
},
// ...
}
timeline = new vis.Timeline(targetDom, itemData, groupData, options)
懒加载带来的问题

vis-timeline中,视口范围内的概念非常重要,
时间单位跟随视口范围内的数据进行自适应,
同时视口范围还规定了需要处理的数据有哪些。
也就是懒加载,能够减少渲染时需要处理的数据数量。

但是这也是比较坑爹的,这就说明一旦我规范了视口的大小,在图表实例化结束后,只有视口内的dom会被渲染出来,
那么我就必须要在鼠标拖动或缩放到其它范围时,对dom进行重新填充操作,
也就是配合vis-timeline的懒加载进行dom操作(噩梦)。

但是这里水平和垂直的懒加载策略也不同:

  • 垂直懒加载,没有滚动到的group在压根就不存在dom树中
  • 水平懒加载,没有滚动到的item存在在dom树中,但是被通过css的transform属性移动到看不见的地方,并且vis-timeline将这些被隐藏的元素移动到父容器的后列(dom树顺序被修改了)
    针对这两点,采取如下策略:
  • 垂直懒加载
    • 直接一次性将所有高度都绘制出来,不要懒加载了
    • 至于滚动,通过css实现
    • 注意点:这里图表内有一个下拉操作,所以容器实际高度需要进行动态计算
  • 水平懒加载
    • 首先和垂直一样,时间范围首先拉倒最大
    • 至于下拉的柱状图的渲染,需要在下拉时,根据元素的transform判断这个元素是否被隐藏了,获取到没有被隐藏的元素
    • 对没有被隐藏的元素进行操作
未修复时,懒加载导致的dom计算错误
未修复时,懒加载导致的dom计算错误
修复后
修复后

此处时间轴开发过程问题百出,代码也是修修补补,拆了东墙补西墙,
尽管最终实现了产品想要的效果,但是从代码层面上的可复用性不高

其他

无限滚动

功能描述

列表栏无限滚动,鼠标移入后停止滚动,鼠标移出继续滚动

实现方式
  • gsap插值控制dom的scrollTop
  • 列表尾部加上几个重复项,以实现循环假象
    • 比如容器视口内展示5个,列表数据为[1,2,3,4,5,6,7,8,9,10]
    • 列表数据应当填充为[1,2,3,4,5,6,7,8,9,10,1,2,3,4,5]
  • 用户鼠标控制动画
    • mouseenter tween.pause暂停动画
    • mouseleave tween.play继续动画

鼠标控制动画看似没有什么问题,但是这里问题就来了:

当用户鼠标移入滚动dom时,dom的scrollTop被改动了,
此时鼠标移出,tween如果接着之前的位置向下滚动,肯定是不行的
必须得接着用户滚动到的位置继续滚动

所以在动画暂停滚轮自定义滚动之后,需要重新创建一个tween,
它的目标值不变,依旧是容器的scrollHeight-scrollTop,
但是它的持续时间需要按照比例计算了,
得是当前动画滚动距离/初始动画滚动距离*初始动画持续时间,
然后再这个半截动画结束之后,将容器scrollTop置顶,重新发起循环的完整滚动动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
watch(loading,(nv,ov)=>{
let dom = document.querySelector('#hot-kanban')
if(nv == false){
dom.scrollTop = 0
let scroll_h = dom.scrollHeight
let dom_h = dom.clientHeight
let offset = scroll_h - dom_h
let delay = 3
let duration = 30
if(offset>0){
let initTweenOption = {
scrollTop:offset,
duration:duration,
ease:'none',
}
tween = gsap.to(dom,{
...initTweenOption,
delay:delay,
})
dom.onmouseenter = e =>{
tween.pause() // 动画暂停
dom.onscroll = e =>dom.scrollTop == offset && (dom.scrollTop = 0) // 开始监听用户滚动事件
}
dom.onmouseleave = e => {
let current_offset = offset - dom.scrollTop
let radio = current_offset / offset * duration
tween = gsap.to(dom,{ // 重新开启动画
scrollTop:offset,
duration:radio,
ease:'none',
onComplete:()=>{
dom.scrollTop = 0
tween = gsap.to(dom,initTweenOption)
tween.play()
tween.repeat(-1)
}
})
tween.play()
}
tween.repeat(-1)
}
}else{
tween && tween.kill()
tween = null
dom.onmouseenter = undefined
dom.onmouseleave = undefined
dom.onscroll = undefined
}
})

防遮遮挡浮窗&&字体背景反相处理

正常情况下
正常情况下
浮窗不遮挡上面字体
浮窗不遮挡上面字体
字体在空间不足时反相处理
字体在空间不足时反相处理

这里的字体和背景颜色反相处理,使用到css的mix-blend-mode属性

loading效果

这里的效果都是用css的filter实现的,
父容器的contract和子容器的blur结合,能实现液体融合的效果,非常有趣,
另外,用毛玻璃对彩色背景进行遮罩也能实现比较高级的效果。

结语

上面记录了很多问题的解决方案,但大体看来,

  • 要么是在手动拓展组件无法带来的效果,
    • echarts有这个图吗→如何实现→如何自定义交互→如何实现颜色混合
  • 要么是修复组件特性带来的bug,
    • vis-timeline怎么会导致我找不到目标dom→原来是懒加载导致的→如何修复

再回顾这些,最初选择图表库是为了节省时间,
但是最终为了弥补库无法实现的效果,花费的时间和书写的代码可能还要多得多
(写水球散点图的代码比echarts-liquid这个库的代码还要多,还不如研究研究echarts的custom类)

最重要的还是把svg、canvas、dom、数学这些基础知识平时夯基础。

后记

VisualMap

之后又仔细阅读了一下echarts官网关于dataset和visualMap部分的介绍,
发现当前的这种实现气泡图的方法,确实比较生硬,
比如数据映射,visualMap可以非常简单的就实现某个属性根据权重在某个范围内的映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
option = {
dataset:{
source:[
// x y size
[12, 323, 400, 11.2],
[23, 167, 300, 8.3],
[81, 284, 100, 12],
[91, 413, 450, 4.1],
[13, 287, 140, 13.5],
[50, 250, 310, 15.5],
]
},
visualMap:{
show:false,
dimension:3, // 映射维度
min:2,
max:15,
inRange:{
symbolSize:[5,60] // 气泡尺寸的范围
}
},
xAxis:{},
yAxis:{},
series:[
{type:'scatter', encode:{x:0,y:1}},
{type:'scatter', encode:{x:0,y:2}},
]
}

坐标映射bug

手动实现坐标映射(仔细想想果然还是没有必要吧),
之后测试过程中还是发现有bug,
这是因为是一次遍历所有水球计算出上下左右边界的,遍历过程中可能出现这样的情况:

  • 水球A下边界出界,坐标系y轴下面加上几个单位,继续遍历
  • 水球B上边界出界,坐标系y轴上面加上几个单位
  • 此时由于单位变多,单位绝对长度变小,水球A又被挤出下边界了

针对这个bug,可以将球体根据半径由小到大排序,从小的开始计算添加单位,这样更不容易出现在这种bug,
也可以循环遍历,直到遍历结果为没有一个水球出界为止。