动画在前端产品中是很重要的,它能增强用户体验,提升信息传达效果,还能塑造品牌形象。在数据产品中,精心设计的动画,可以直观地呈现复杂数据和信息,突出关键数据变化趋势,给用户深刻印象,提升品牌辨识度和美誉度。VChart 不仅具备基本的图表绘制能力,还提供了灵活丰富的动画能力。
本次分享将介绍可视化动画如何在从场景中沉淀动画设计方案,并通过 VChart 及底层自研可视化体系进一步实现。
VisActor: https://visactor.io
可视化动画是一种通过动态的图形、图像和视频来呈现数据、信息或概念的表现形式。
它具有以下几个重要特点和作用:
- 直观呈现:将复杂的数据或抽象的概念转化为易于理解的视觉元素,让观众能够快速获取关键信息。
- 增强吸引力:相比静态的图表或文字,动画能够吸引观众的注意力,提高信息传递的效果。
- 故事讲述:可以按照一定的逻辑和情节,讲述一个信息传递的过程。
定义
数据大屏是一种将大量数据 通过可视化的方式集中展现 在一个大型屏幕上的信息展示系统。
它具有以下特点和作用:
- 可视化展示 :利用图形、图表、地图 等直观的可视化元素呈现数据,便于快速理解和解读。
- 大规模数据呈现 :能够处理和展示海量复杂 的数据。
- 实时性 :可以实时更新数据 ,反映最新的信息状态。
使用场景
智慧政务
新能源
智慧文旅
金融业务
动画对大屏的意义
“更好看,更抓眼球”: 不可否认的是,动态效果比静态效果更能吸引眼球。
运营业务数据大屏: 开启动画
运营业务数据大屏: 关闭动画
“更好的叙事方式”:
动态: 按照年份动态绘制
静态: 按照年份拆分柱子
动画作用域
图元级别: 图元本身的动画
图表级别:图表间的切换动画
页面级别: 不同页面间的切换动画
动画作用域之间的关系
- 图元动画发生在图元的视觉属性发生变化开始到变化结束的时间段内,由于大屏具有实时监测、交互筛选数据等功能,所以图元的属性会随时发生变化,图元动画会高频执行。
- 图表切换动画依赖图表库形变(morphing)动画能力,但由于产品功能侧未开放切换动画类型的功能,所以也不存在图表切换动画的表现场景。
- 页面切换动画依赖大屏业务逻辑,由大屏页面容器间的切换逻辑实现,不在VChart能力覆盖范围内。
动画配置
我们对不同图表进行面向用户的动画配置抽象 。
一级结构:入场、常态、更新、退场
首先,按照时间线的演进,图元的状态可以按序分为三种:
- 入场:图表在初次绘制时,图元由无到有的状态。
- 常态: 图元绘制出来后,图表的静置状态。
- 退场:图表被销毁时, 图元从有到无的状态。
其次,更新状态也是图元的高频状态。
- 更新:大屏的定时更新数据及交互改变属性时, 会引发图表更新并重绘。
三种引发更新状态的大屏配置:
数据定时更新
交互筛选数据
交互改变样式
上述分析表明,常态状态和更新状态为图元的高频状态 。
二级结构:常态 = 轮播 + 氛围, 更新 = 时序更新 + 一般更新
常态动画的主要思路有2种:循环执行某种动画、附加额外图元以增强图表图元,我们将前者定义为“轮播”,后者定义为“氛围”。
轮播: 循环高亮图表图元
氛围: 附加额外图元
更新动画的主要思路有2种:按时间顺序动态播放、更新数据或某个配置,我们将前者定义为“时序更新”,后者定义为“一般更新”。
时序更新:
一般更新:任何配置或数据变化引发的更新
三级结构:对应不同图表的具体动画配置
图表组件 | 入场 | 更新 | 常态 | |||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
时序更新 | 一般更新 | 轮播 | 氛围 | |||||||||||||||
柱状图并列柱状图堆叠柱状图百分比柱状图 | 生长 |
| 生长
移入
| 生长
移入
| 生长
移入
分组高亮
| 流光
| | 生长 | | 移入 | | 生长 | | 移入 | | 分组高亮 | | 流光 | | | 条形图并列条形图堆叠条形图百分比条形图 | 生长
| 生长
位移
| 生长
位移
| 生长
移入
分组高亮
|
| | 生长 | | 位移 | | 生长 | | 移入 | | 分组高亮 | | 流光 | | | 面积图百分比面积图折线图 | 生长 加载 | | 生长 | 生长 加载 | 流光 涟漪 | 生长 | | 生长 | | 加载 | | 流光 | | 涟漪 | | | | | | 饼图玫瑰图环形图 | 径向 缩放 | | 径向 | 改变尺寸 改变中心 | 无 | 径向 | | 改变尺寸 | | 改变中心 | | 无 | | | | | | | | 散点图圆视图 | 长 缩放 | | 生长 | 生长 缩放 | 涟漪 | 生长 | | 生长 | | 缩放 | | 涟漪 | | | | | | | | 雷达图 | 径向 缩放 | | 生长 | | 涟漪 | 生长 | | 无 | 涟漪 | | | | | | | | | |
底层原理
VRender 渲染层——计时和动画原子
VRender层主要由Animate动画类和Tick计时器类构成
Animate类
- 挂载在图元节点上,定义动画如何执行
- 最主要的成员是onUpdate,它接收当前的动画执行比率,并根据比率更新图元属性
<!---->
/**
** 动画开始: 一些前置准备工作
*/
onStart() => void
/**
* 更新图元状态
* @param end 动画是否结束
* @param ratio 动画执行的比率 [0, 1]
* @param out 图元属性
*/
onUpdate(end: boolean, ratio: number, out: any) => void
/**
* 动画结束
*/
onEnd()
/**
** 插值工具函数
* @param key
* @param ratio 动画执行的比率 [0, 1]
* @param from 起始属性
* @param to 终止属性
*/
interpolateFunc(key: string, ratio: number, from: any, to: any, nextAttributes: any) => void
Tick类
- 用于控制动画开始、暂停等执行逻辑
// 设置每秒帧数
setFPS: (fps: number) => void;
// 设置重复执行的时间间隔
setInterval: (interval: number) => void;
// 开始
start: (force?: boolean) => boolean;
// 暂停
stop: () => boolean;
// 恢复
resume: () => boolean;
案例:流光动画
柱状图轮播流光动画
折线图轮播流光动画
柱状图流光动画:
本质是在柱图元上追加一个矩形节点,通过调整矩形节点的位置 达到流光的效果。
onStart(): void {
// 拿到父节点(动画需要载的图元)
const root = this.target.attachShadow();
// 拿到一些属性
const isHorizontal = this.params?.isHorizontal ?? true;
const sizeAttr = isHorizontal ? 'height' : 'width';
const otherSizeAttr = isHorizontal ? 'width' : 'height';
const size = this.target.AABBBounds[sizeAttr]();
const y = isHorizontal ? 0 : this.target.AABBBounds.y1;
// 初始化流光矩形
const rect = application.graphicService.creator.rect({...some_attr})
}
protected onUpdate(end: boolean, ratio: number, out: Record<string, any>): void {
const isHorizontal = this.params?.isHorizontal ?? true;
const parentAttr = (this.target as any).attribute; // 拿到父节点
if (isHorizontal) {
const parentWidth = parentAttr.width ?? Math.abs(parentAttr.x1 - parentAttr.x) ?? 250;
const streamLength = this.params?.streamLength ?? parentWidth;
const maxLength = this.params?.attribute?.width ?? 60;
// 起点,rect x右端点 对齐 parent左端点
// 如果parent.x1 < parent.x, 需要把rect属性移到parent x1的位置上, 因为初始 rect.x = parent.x
const startX = -maxLength;
// 插值出位置
const currentX = startX + (streamLength - startX) * ratio;
// 位置限定 > 0
const x = Math.max(currentX, 0);
// 宽度计算
const w = Math.min(Math.min(currentX + maxLength, maxLength), streamLength - currentX);
// 如果 rect右端点 超出 parent右端点, 宽度动态调整
const width = w + x > parentWidth ? Math.max(parentWidth - x, 0) : w;
this.rect.setAttributes(...some_attr);
} else {
// ... 纵向移动
}
}
折线图流光动画:
本质是在线图元上追加一个线节点,通过调整线节点的位置达到流光的效果。
为了让追加的线节点紧密贴合在父节点上,这里需要做的是分割或者说复刻线的一个选段。
// ....前置初始化折线图元
protected onUpdate(end: boolean, ratio: number, out: Record<string, any>): void {
// 对于折线而言,只需要拿到path的points即可复刻
// 1. 遍历每个分段, 计算折线每个分段的长度
for (let i = 1; i < points.length; i++) {
totalLen += PointService.distancePP(points[i], points[i - 1]);
}
// 2. 根据ratio,计算节点起点所在的位置和终点所在的位置
const startLen = totalLen * ratio;
const endLen = Math.min(startLen + this.params?.streamLength ?? 10, totalLen);
// 3. 遍历每个分段, 找到节点
const len = PointService.distancePP(points[i], points[i - 1]);
const nextPoints.push(PointService.pointAtPP(points[i - 1], points[i], 1 - (lastLen + len - startLen) / len));
// 对于曲线而言,根据百分比分割贝塞尔曲线
const startPercent = 1 - (lastLen + len - startLen) / len;
let endPercent = 1 - (lastLen + len - endLen) / len;
const [_, curve2] = divideCubic(curveItem as ICubicBezierCurve, startPercent);
customPath.moveTo(curve2.p0.x, curve2.p0.y);
}
VGrammar 语法层——动画触发和编排
触发时机
VGrammar将动画的触发时机分为2种:被动触发和主动触发
被动触发 通常发生在图元状态发生变化时:
- enter:新增图形元素时的动画触发;
- exit:移除图形元素的动画触发;
- update:图形元素视觉通道更新的动画触发;
- state:图形元素交互状态变更的动画触发,在最为常见的交互&动画配合的场景中,动画表现为伴随着交互状态变更的插值,例如 hover 动画。针对这一动画状态,我们单独在底层渲染库做额外处理,避免昂贵的数据流计算以提升性能;
enter状态
update状态
主动触发 发生在任意时刻,在大屏的场景应用中体现在轮播动画中,因为轮播动画发生前,图元没有任何状态的改变
饼图轮播动画:按照某种时间间隔一直循环下去
Effect封装
Effect可以理解为动画的最小可执行单元。
Effect 可以是封装好的特定动画效果:它基于dataflow执行之后得到的图元映射后正确属性和上下文,封装不同的动画类型。
比如柱图的生长动画,开发者指定type: 'growHeightIn'
即可让同一个维度上的柱图元,从同一个起点向上生长。
animationAppear: { // 触发时机
bar: { // 图元类型
type: 'growHeightIn', // 动画类型
duration: 1000, // 持续时间
easing: 'linear' // 缓动效果
}
}
经过VChart层转换后的最终配置
在VGrammar层实现时,需要拿到同一维度上的柱子起点,并传给VRender的Animate类。
function growHeightInOverall(
element: IElement,
options: IGrowCartesianAnimationOptions,
animationParameters: IAnimationParameters
) {
const y = element.getFinalAnimationAttribute('y');
const y1 = element.getFinalAnimationAttribute('y1');
const height = element.getFinalAnimationAttribute('height');
// ....
// 获取同一维度柱子起点
const overallValue = (animationParameters as any).groupHeight ?? animationParameters.group.getBounds().height();
return {
from: { y: overallValue, y1: isNil(y1) ? undefined : overallValue, height: isNil(height) ? undefined : 0 },
to: { y: y, y1: y1, height: height }
};
}
- Effect 也可以由开发者自行配置起始状态以及结尾状态的动画配置:它描述了动画的属性插值的计算逻辑。
比如散点的涟漪动画,开发者指定channel: { [attr]: from: start_state, to: end_state}
即可拿到上下文后自行计算起始状态。
animationNormal: { // 动画触发时机
channel: { // 自定义视觉通道
outerBorder: { // 外边框属性
from: { distance: 0, strokeOpacity: 1 }, // 开始时的属性
to: (...args) => { // 结束时的属性
return {
distance: 16,
strokeOpacity: 0,
stroke: args[1].graphicItem.attribute.fill
}
}
}
},
}
```
Timeline编排
有了动画的基本单元,接下来便是对不同动画执行时机的编排。
VGrammar 通过动画配置描述了对应的图元动画执行逻辑,动画配置构成的示意图为:
动画时间线 Timeline 的定义为:
Timline 中的构成元素包含:
- timeslice:动画的分片,描述了具体的某一段插值动画配置,包含相关的动画效果、动画执行时间等具体动画配置。在一个 timeline 上所有的 timeslice 头衔尾的串联在一起;
- startTime:动画的开始执行时间,描述了当前 timeline 触发执行之后开始执行动画的时间;
- duration:动画时间线的执行时长,描述了当前 timeline 的动画时长;
- loop:一个 timeline 可以被设置为循环,其中包含的所有 timeslice 所描述的动画过程将会被重复的执行。
动画分片 Timeslice 的定义为:
image.pngTimeslice 中的构成元素包含:
- effect:动画的具体执行效果,描述了具体的图元视觉通道属性插值计算逻辑。effect 可以是封装好的特定动画效果,或者由开发者配置起始状态以及结尾状态的动画配置,描述了动画的属性插值的计算逻辑;
- duration:动画分片的执行时长;
- delay:动画分片的执行前的等待时间;
- OneByOne:描述了相应图元内具体图形元素依次执行的逻辑;
VChart 图表层——状态和配置封装
由于VGrammar层已经完成大部分动画效果的封装和编排,所以VChart层只需要在图表库通用逻辑的基础上进行进一步封装和属性透传。
触发时机的补充
VChart在之前的状态(enter、update、exit、state)基础上新增了3种状态——appear、disappear和normal。因为在业务侧,人们除了图元状态发生变化时,显然也希望在常态时机有不间断的动画,正如前面我们对大屏动画的梳理中提到的一样。
Effect进一步封装
对于一些特殊的动画,VChart需要做额外处理。
比如下文的雷达图动画,表现为按照角度展开。但由于雷达图图元由散点和环形面积图元组成,要想控制他们同时展开,光靠插值计算无法达到同步,所以VChart采取的方式是: 控制上层图元的按照角度展开,就可以做到其子图元一体化展开。
红色部分为rootMark, 构成雷达图的点图元和面积图元皆为其子图元
// 在rootMark上配置type: grow
// VGrammar层处理方式
{
from: startAngle,
to: endAngle
}
直接设置起始角度和终止角度,走VRender底层的插值更新即可
动画配置的封装与透传
最终VChart透传出去的配置如上图所示:
一级结构为触发状态。
二级结构为图元类型:不同图表的图元类型不同。
三级结构为动画效果的具体配置:
- type表示封装好的动画类型
- channel表示开放给用户自定义的动画类型,用于自定义插值。
- timeSlices表示开放给用户定义的动画分片,用于自定义编排。
更多VChart动画配置教程: https://www.visactor.io/vchart/guide/tutorial\_docs/Animation/Animation\_Types
大屏动画实现
动画原子的配置与组合
动画原子,即图元动画效果的最小化配置。
不同的图表配置方式不同,根据效果选择type、channel和timeSlices三种配置方式中的哪一种。
下面是不同动画的配置案例:
低阶:type配置
所有属性计算都内置好,只需要指定type
折线图入场: 加载动画
animationAppear: { // 触发时机
bar: { // 图元类型
type: 'clipIn', // 动画类型
duration: 1000, // 持续时间
easing: 'linear' // 缓动效果: 匀速
}
}
VChart配置
饼图入场: 径向动画
animationAppear: { // 触发时机
pie: { // 图元类型
type: 'growAngleIn', // 动画类型
duration: 1000, // 持续时间
easing: 'circInOut' // 缓动效果: 缓入缓出
}
}
VChart配置
雷达图入场: 缩放动画
animationAppear: { // 触发时机
radar: { // 图元类型
type: 'grow', // 动画类型
duration: 1000, // 持续时间
easing: 'quintIn' // 缓动效果: 缓入
}
}
VChart配置
中阶:channel配置
需要计算图元动画的先后时间,控制轮播顺序
饼图:轮播-改变位置
饼图:轮播-改变尺寸
{
channel: {
x: {
from: (...p) => p[1].graphicItem.attribute.x,
to: (...p) => {
const angle = (p[1].graphicItem.attribute.startAngle + p[1].graphicItem.attribute.endAngle) / 2
return p[1].graphicItem.attribute.x + offset * Math.cos(angle)
}
},
y: { .... },
outRadius: { .... }
},
oneByOne: true,
duration: loopDuration,
delayAfter: loopDuration + interval, // 前一个动画线与后一个动画线对齐
}, {
channel: {
x: {
from: (...p) => {
const angle = (p[1].graphicItem.attribute.startAngle + p[1].graphicItem.attribute.endAngle) / 2
return p[1].graphicItem.attribute.x + offset * Math.cos(angle)
},
to: (...p) => p[1].graphicItem.attribute.x,
},
y: { .... },
outRadius: { .... }
oneByOne: true,
duration: loopDuration,
delayAfter: interval,
}
高阶:timeSlices配置
需要进一步计算不同组的动画先后时间
柱图:轮播-分组高亮
channel_1:
$$delay = duration * index $$
$$delayAfter = duration * (totalCount - index)$$
chennel_2:
$$delay = duration * (index + 1)$$
$$delayAfter = duration * (totalCount - index - 1)$$
timeSlices: [
{
effects: {
channel: {
fill: {
to: fillColor
},
stroke: {
to: strokeColor
}
},
easing: ease
},
delay: (datum, element, context, global) => {
const { count, index } = getGroupInfo(chartInstance, datum)
if(count === 0) {
return 0
}
return index * totalDuration / count
},
duration: (datum, element, context, global) => {
const { count, index } = getGroupInfo(chartInstance, datum)
if(count === 0) {
return 1000
}
return totalDuration / count / 2
}
},
{
effects: {
channel: {
fill: {
from: fillColor,
to: (...p) => {
return p[1].graphicItem.attribute.fill
}
},
stroke: {
from: strokeColor,
to: (...p) => {
return p[1].graphicItem.attribute.fill;
}
}
},
easing: ease
},
delayAfter: (datum, element, context, global) => {
const { count, index } = getGroupInfo(chartInstance, datum)
if(count === 0) {
return 0
}
return (interval + atmoDuration) * 1000 + (count - index - 1) * totalDuration / count;
},
duration: (datum, element, context, global) => {
const { count, index } = getGroupInfo(chartInstance, datum)
if(count === 0) {
return 1000
}
return totalDuration / count / 2
}
}
]
动画原子的组合
image.png
面积图循环动画
轮播: 加载 + 氛围: 流光 & 涟漪
animationNormal: {
"area": [{ // 面积图加载动画
"type": "clipIn", "oneByOne": false, "startTime": 5000, "easing": "circInOut", "loop": true, "duration": 1000, // 动画相关配置
"delayAfter": 6000, // 编排相关配置
"controlOptions": { "immediatelyApply": false }
},
{ // 面积图氛围动画
"loop": true, "startTime": 5000, "duration": 1000, "easing": "circInOut"
custom: StreamLight, // 需要自行引入
"delay": 1000, // 编排相关配置
"delayAfter": 5000, // 编排相关配置
}],
"point": [{
"loop": true, "startTime": 5000, "easing": "circInOut",
"delayAfter": 6000, // 编排相关配置
"duration": 1000, // 编排相关配置
"channel": { "outerBorder": { "from": { "distance": 0, "strokeOpacity": 1 } }
}
}
]
}
}
时序动画
时序控件:交互式控制数据更新
时序动画通常伴随着时间数据的递增或递减顺次进行,VChart提供的Player组件可以对这种场景进行动画的自动、手动播放/切换的控制。
player组件支持前进、后退、播放等功能,供用户交互式切换时序数据
{
dataSpecs: dataset[] // 每一“帧”的数据构成数组,player自动识别并调用
}
时序控件Player实现的本质是,根据时间字段对数据进行分片,切换不同分片的数据,即调用updateData接口,实现图表的重绘。
时序标记:显示当前时间序列
为了让观众知道目前所处的时间序列,通常需要增加文字标记。
利用VChart提供的customMark,可以绑定dataId,则可以随着数据的变化而变化。
右下角的文字标记可以展示当前所处年份
customMark: [{
type: 'text',
dataId: 'year',
style: {
text: datum => datum.year, // 根据数据的变化而变化
x: (datum, ctx) => {
return ctx.vchart.getChart().getCanvasRect()?.width - 50
},
y: (datum, ctx) => {
return ctx.vchart.getChart().getCanvasRect()?.height - 50
}
}}
],
动画的冲突
冲突的原因
大屏渲染图表的基本流程是: 先初始化一个空的图表,而后通过updateSpec的方式更新图表。
更新updateSpec的方式有3种:
- 字体加载完成: 发生在初始化后的很短时间间隔后。
- 自动获取数据: 按照用户的配置每隔一段时间获取最新数据。
- 配置更新: 用户可以在图表渲染后切换图表配置,该过程发生在任意时刻。
冲突的可能情况:
- 出场与循环动画的冲突
- 出场与更新动画的冲突
- 更新与循环动画的冲突
出场动画与循环动画的冲突
在图表库的处理中,如果出场动画和循环动画同时被触发,那么这两种动画会被先后执行。
出场和循环同时被执行,仿佛出场执行了2次,造成误解
解决的方式:
在动画上配置startTime可以延迟它的进场时间,由此规避第一次循环动画,使其不与appear动画产生冲突。
animationNormal: {
bar: {
startTime: appear_duration,
type: 'growHeightIn',
duration: ....
// ....
}
}
出场动画与更新动画的冲突
出场与更新动画的冲突发生在,第一次渲染后很短的时间内字体加载完成重新渲染图表,所以会造成入场动画立刻被阻断。
出场被更新打断,仿佛视图卡顿,影响观感
解决的方式:使用setTimeout推迟update动画的时间。
const delayTime =
this.getAppearDuration(preAnimationSpec) * 1000 - // 入场动画的持续时间
(Date.now() - this.chartAppearTimeStamp) // 当前时间与入场动画开始时间的间隔
setTimeout(() => { this.updateChartSpec(newSpec)}, delayTime)
更新动画与循环动画的冲突
图表库在执行updateSpec时,会判断需要 更新图元(reMake: false
) 还是 重绘图元(reMake: true
)。
如果是重绘图元,则之前挂载的动画会被阻断,这意味着循环动画将不再执行。
为了避免循环动画被阻断的问题,对图表更新流程进行改造:updateSpec -> specWithoutDataChange + updateData
const specWithoutDataChange = {
...curSpec,
data: preData
}
// ....
const curData = curSpec.data
// step1: 更新除数据以外的spec,保证其他属性被正确挂载,此时动画被阻断
this.chartInstance?.updateSpec(specWithoutDataChange, false, undefined, { reAnimate }) // 注意, 这里要加?保护, 有可能appear动画还没有执行完, 就要切换页面到下一个次更新了, 那么setTimeout执行时, chartInstance可能被release掉了
// step2: 通过updateData api单独更新数据, 此时动画被重启
this.chartInstance?.updateFullDataSync(curData, true, { reAnimate })
质量保证
大屏侧: 模块化手动测试 + 场景化自动测试,保障应用层渲染质量
image.png对于静态产物,完善自动化测试case。
自动化测试可以分为两块:
-
模块化测试:分别建立颜色主题、图元、标签、坐标轴、图例.....对应的屏。
实际操作时:
- 每个屏应该包含对应模块的所有功能case
- 根据oncall状态更新case
- 小的改动:提交代码前跑通对应模块的测试屏
-
场景化测试: 域内重点大屏case录入(进行多轮域内重点大屏自动化测试,效果很好,能够发现很多细节问题)
实际操作时:
- 同样根据oncall状态更新case
- 大的改动:提交代码前全量跑通域内重点大屏
对于动态/交互产物,加强自测。
- 动态产物:每种图表类型的每个动画效果都建立case。改动后需要及时和设计师、产品确认效果
- 交互产物:
交互分为:图表本身交互、大屏通用交互
图表本身交互需要注意tooltip效果是否正确
大屏通用交互需要注意图表参数的透出是否正确, 比如下面这种场景就依赖图表参数:
这类case之前不太了解,后续增加这些边界case,保证自测时就应该测到
图表库侧: 图表截图对比 + 内存测试,保障单图表渲染质量
由VChart底层引起的问题,可以通过在图表库的质量保证平台BugServer添加case,如果有问题,在图表库发版前能被及时发现
具体实施:
- 在bugserver中录入大屏高频case,并标注测试内容和关联issue,每次合入代码/发版前都进行截图对比
-
内存测试
最后,我们诚挚的欢迎所有对数据可视化感兴趣的朋友参与进来,参与 VisActor 的开源建设:
最后,我们诚挚的欢迎所有对数据可视化感兴趣的朋友参与进来,参与 VisActor 的开源建设:
官方网站:www.visactor.io/
Discord:discord.gg/3wPyxVyH6m
Twiter:twitter.com/xuanhun1
github:github.com/VisActor
飞书群:
微信公众号: