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

项目简介

预览地址

github地址

最终效果图如下

撇开列表/进度条/环形图这种比较一般的图表不看,

整个项目的主要难点就在于散点图、散点图和它之后与圆环图的交互,这也是它的主要亮点。

项目难点

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

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

技术选型

开发框架

都是我用的比较熟的:

网络请求

因为这个项目对实时性要求并不高,因此采用客户端向服务端定时请求的模式,

但是有一点就是当数据请求不来时(可能是后台服务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已经满足不了,用自由度最高的,D3

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

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

设计图要的效果 实际实现的效果

可以看到设计图中,颜色交叠处有种ps中正片叠底/加深图层的感觉,

但是实际的图表绘制中,颜色的交叠部分是上层的颜色覆盖下层的颜色,

ECharts实际是提供混合模式配置项的,也就是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进行重新计算:

js 复制代码
// 此处最大适配宽度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大小还没有被计算出来:

js 复制代码
resize().then(res=>{
  app.mount('#app')
})

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

css 复制代码
.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样式性能

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

水球气泡散点图

如何实现

基本思路

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

  • 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函数
javascript 复制代码
function convertAxisToPixel(data){
  return data.map(node=>{
    node.center = chart.convertToPixel(
        {xAxisIndex:0, yAxisIndex:0},
        [node.taskNum, node.peopleNum]
    )
    return node
  })
}

最终就能将水球映射到页面中了,但是这种方法也有弊病:

  • 性能问题,每一个水球都是一个独立的series,和真正的散点图的性能相比很低
  • 坐标系边缘的计算比较复杂,涉及到更多数据处理的逻辑

坐标系边界检测

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

由于球体和坐标系不在一张画布上,

所以要知道球体是否出界,就必须知道画布的包围盒和坐标单位

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

初始化坐标轴右边缘为0

开始遍历每个点

计算点的右边缘

如果点的右边缘超过当前坐标轴右边缘

需要向坐标轴右侧添加单位(注意,在添加单位的同时,整体坐标单元也会缩小)

计算出添加的单位数量

右边缘加上添加数量,重新计算坐标单位

遍历结束,计算完毕

javascript 复制代码
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)线性映射到这个范围之中:

javascript 复制代码
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

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

如何计算数据的离散程度

如何判断一组数据离散程度的高低?

这里使用的是标准差方法:

javascript 复制代码
    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进行修改的代码:

javascript 复制代码
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交互就简单多了:

javascript 复制代码
echarts.init(chartDom,'',{renderer:'svg'})

我是可以获取到自己点击的元素了,但是怎么知道点击的是哪一个水球呢?

打开chrome控制台查看dom树,能发现echarts使用svg绘制多个series的一个规律:

它会把所有series绘制在一个svg内部的一个g中,并且按照z顺序进行从上到下的绘制,

也就是说,如果它绘制一个水球需要16个标签,总绘制20个水球图,那么g中就会有16*20=320个标签

事实上,这里绘制标签的数量不一定是16,期间可能有涉及到富文本的标签(多个text标签),为实现阴影效果的标签,等等,

但重点是绘制每个水球的标签数量都是固定的,且顺序不变,这就好像WebGL绘图中常用的ArrayBuffer一样,用索引进行区分。

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

javascript 复制代码
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标签实现的,和其它元素很好区分:

javascript 复制代码
// 对svg事件进行处理 波浪和阴影不可点击
function svgEventHandle(svg){
  const allG = svg.querySelector('g').querySelectorAll('g')
  allG.forEach(g=>g.style.pointerEvents = 'none')
}

最后还有一个问题,echarts使用path绘制圆球,但是path最终的dom监听区域是一个方形,将这个圆球包围住,

所以需要根据path的包围盒手动判断鼠标是否移入的是包围盒内的圆球区域,仅有移入圆球区域时才判断当前水球被选中:

javascript 复制代码
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 // 当前包围盒
    }
  }
}

辐射水球图

如何实现

liquid+pie

乍一看这个图,似乎很像旭日图,因为中心球四周的弧度辐射出去的半径长短不一,

但是旭日的半径延长出去是有层级关系的意义的,这里则不是,

因此这里采用的是弧度饼图的方式,

即有几个圆弧,就要在option.series中塞入几个元素,

然后计算出每个圆弧的初始角度和结束角度。

这里还有一个需要注意的是每一段圆环的渐变色,

ECharts是提供渐变色的,在线性渐变中,设置渐变色的2端只能通过指定绘制元素上的一条方向向量的方式进行绘制

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

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

javascript 复制代码
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坐标也比较好计算:

javascript 复制代码
// 获取画布饼图展示的中心点 [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的值考虑在内,下面是包围盒中心点以及偏移距离的计算:

javascript 复制代码
// 获取偏移量
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此时的作用就像是提供一个模具,至于里面是什么,我自己添加。

javascript 复制代码
  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计算错误 修复后

此处时间轴开发过程问题百出,代码也是修修补补,拆了东墙补西墙,

尽管最终实现了产品想要的效果,但是从代码层面上的可复用性不高

其他组件

无限滚动

功能描述: 列表栏无限滚动,鼠标移入后停止滚动,鼠标移出继续滚动

实现方式:

  • 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置顶,重新发起循环的完整滚动动画:

javascript 复制代码
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效果

CodePen

参考:Chokcoco

CodePen

这里的效果都是用css的filter实现的,

父容器的contract和子容器的blur结合,能实现液体融合的效果,非常有趣,

另外,用毛玻璃对彩色背景进行遮罩也能实现比较高级的效果。

结语

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

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

再回顾这些,最初选择图表库是为了节省时间,

但是最终为了弥补库无法实现的效果,花费的时间和书写的代码可能还要多得多

(写水球散点图的代码比echarts-liquid这个库的代码还要多,还不如研究研究echarts的custom类)

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

相关推荐
编程猪猪侠26 分钟前
Tailwind CSS 自定义工具类与主题配置指南
前端·css
qhd吴飞30 分钟前
mybatis 差异更新法
java·前端·mybatis
YGY Webgis糕手之路1 小时前
OpenLayers 快速入门(九)Extent 介绍
前端·经验分享·笔记·vue·web
患得患失9491 小时前
【前端】【vueDevTools】使用 vueDevTools 插件并修改默认打开编辑器
前端·编辑器
ReturnTrue8681 小时前
Vue路由状态持久化方案,优雅实现记住表单历史搜索记录!
前端·vue.js
UncleKyrie1 小时前
一个浏览器插件帮你查看Figma设计稿代码图片和转码
前端
遂心_1 小时前
深入解析前后端分离中的 /api 设计:从路由到代理的完整指南
前端·javascript·api
你听得到111 小时前
Flutter - 手搓一个日历组件,集成单日选择、日期范围选择、国际化、农历和节气显示
前端·flutter·架构
风清云淡_A1 小时前
【REACT18.x】CRA+TS+ANTD5.X封装自定义的hooks复用业务功能
前端·react.js
@大迁世界1 小时前
第7章 React性能优化核心
前端·javascript·react.js·性能优化·前端框架