可视化图标库如何选择?
鉴于可视化技术已经相对成熟了,市面上的开源可视化图表库也是繁华缭乱的,那我们应该怎么选择呢?
下面是市面上常见的一些图表库:
1.ECharts.js
Echarts.js文档界面相对友好,支持 SVG 、Canvas 双擎渲染,图表示例也比较全,而且文档是支持中英文的,使用的人也比较多,所以相关资料也很丰富。我们的项目中选择的是ECharts.js
2.Charts.js
英文原版:https://www.chartjs.org 中文版:https://chartjs.bootcss.com
文档整体结构和界面都是非常友好的,也拥有相当数量的基础内置图表,对于常规开发来说,这个库也是比较不错的选择,Api 学习难度低于 ECharts
但是如果有复杂图表比如仪表盘或者地图相关渲染的时候,这个库就不支持了,不过也不影响这个库的好用,大家可以根据自己的业务需求来选择,混合选择多款图表库也是可行的
3.Antv
Antv的产品系列划分很多,根据不同的图表类型也分了很多不同的产品线
- G2(可视化图形)、G2Plot(通用图表库)
- S2(多维可视分析表格)
- G6(关系数据图分析工具)
- X6(图编辑引擎)
- L7(地理空间数据可视化)
- F2(专注移动端的可视化解决方案)
文档非常完善,但是服务器部署以后不太稳定,会发生一些未知的bug,导致图像无法渲然,所以不选择。
4.D3.js
纯英文文档,相信这一条或许会劝退很多人,GitHub 有国内开发者翻译的 Api 中文手册,没有内置图表
但是其定义的绘图开发框架可以让你用 Api 的方式来进行 SVG 绘图,这一点比使用原生 SVG 要好很多,如果大家有需求要进行自定义绘制的,可以考虑使用 D3.js。
一句话:很底层,但是足够灵活,可满足绝大部分图表内容的绘制。
总结
综合选择稳定性,上手难度,对各种图表支持的友好性,我们选择ECharts.js来开发。
组件如何设计?
为什么要用组件抽离
- 如果我们直接上手画图的话,感觉上是非常的快速,每种图标的配置都来一份,每个框框标题都用 Div 一把梭,完全没有心智负担。但是来第二套、第三套、第四套图的时候,就显得非常的冗余。
- 用过 ECharts 的小伙伴肯定知道,每一个图表都需要单独实例化一个对象用来作为图表管理和绘制,而大屏项目往往会有很多个这样的图表,所以用组件的方式来自动生成和管理这样的 echarts 对象,我们只需要通过传入 options 配置即可完成图表的绘制。
- 其次,我们还可以通过组件来统一管理当屏幕变化时造成的图表 resize 的情况,封装了组件之后,都可以在组件内部进行统一的事件监听
组件设计
下来看一下我们的大屏效果:
上面有大屏幕、大标题、每个图表的边框、每个图表的文字说明、绘图引擎、屏幕缩放的时候画面跟着等比缩放等等。于是我们对现有的可视化大屏项目做了这样几个拆分:
:::tips
- Screen - 大屏
- Screen Title - 大屏标题
- Card - 数据内容卡片
- Card Title - 数据卡片标题
- Swiper - 大屏内容滚动
- Dance Number - 跳动的数字
- ECharts - 图表 :::
通过这样拆解,将 UI 视觉稿的内容细分为这几大模块并分别管理和实现,虽然不多,但足以应对目前的需求内容了,灵活度也足够,可以随视觉稿调整随时替换。
上面是我们对几大模块的划分,那么ECharts图表组件我们如何设计呢?
ECharts图表组件设计
每一个图表都需要单独实例化
一个对象用来作为图表管理和绘制,而大屏项目往往会有很多个这样的图表,所以用组件的方式来自动生成和管理这样的 echarts 对象,我们只需要通过传入options
即可完成图表的绘制。由此我们确认,在Echarts组件中需要做到两点:
- 实例化图表组件
- 根据传入的options渲然不同的图表,其中options包含data跟opts,即渲染数据和配置
组件设计代码如下:
<template>
<div :id="uid" class="echart"></div>
</template>
<script setup lang="ts">
import * as echarts from "echarts";
import { randomString } from "../../utils/string.util";
import { nextTick, onMounted, watch } from "vue";
import { EventBusUtils } from "../../utils/eventbus.util";
const uid = randomString();
interface Props {
opts: any;
geoJson?: any;
}
const props = withDefaults(defineProps<Props>(), {
opts: Object.create(null),
geoJson: null,
});
let chartInstance: echarts.ECharts | null = null;
onMounted(() => {
// 初始化echarts实例
const div = document.getElementById(uid)!;
chartInstance = echarts.init(div, undefined, {
renderer: "canvas",
});
EventBusUtils.addObserve("WindowResize", () => {
chartInstance?.resize();
});
if (props.geoJson) {
echarts.registerMap("ZheJiang", props.geoJson);
}
chartInstance.setOption(props.opts);
nextTick(() => {
chartInstance?.resize();
});
});
const updateOptions = (opts: any) => {
if (!chartInstance) {
return;
}
chartInstance.setOption(opts);
};
watch(
() => props.opts,
() => {
// 先 resize,再 setOptions 否则动画会丢失
chartInstance?.resize();
chartInstance?.setOption(props.opts);
}
);
defineExpose({
chartInstance,
updateOptions,
});
</script>
<style scoped>
.echart {
width: 100%;
height: 100%;
}
</style>
抽象options
常见的图表有柱状图、折线图、面积图、饼图、环形图
我们要做的就是按图的类型、坐标轴、颜色这几个维度来进行考虑
我们依据设计稿的风格,对 xAxis、yAxis、color 这三个较为通用的配置进行了统一管理,设置一套默认的配置,用于支持常规的柱状图及折线图。
再根据 Options 的使用习惯,按照图表基本配置和数据源配置做工厂方法动态生成
比如Bar如下代码所示:
export enum BarDirectionEnum {
Horizontal = "Horizontal",
Vertical = "Vertical",
}
/**
* 快捷创建柱状图配置,需自行根据需求设置 category
* @param colors 普通颜色或渐变色
* @param categorys 坐标轴的类别数据源 可选
* @param direction 垂直或水平 默认 垂直
* @returns
*/
export function createBarOpts(colors: BBColors[], categorys?: string[], direction = BarDirectionEnum.Vertical): any {
return {
grid: {
top: 30,
bottom: 30,
left: 40
},
color: initColorOpts(colors, direction),
xAxis: init_xAxis(direction, categorys),
yAxis: init_yAxis(direction, categorys),
};
}
同样的,其他风格的我们也可以用统一的api风格扩展为createLineOpts、createLineSeriesItem、createPieOpts、createPieSeriesItem。
封装好之后的实际开发场景代码就会变成这样:
const opts = computed(() => {
const categorys: string[] = [];
const values: string[] = [];
props.shiftList?.forEach((item) => {
categorys.push(DateUtils.string2string(item.dateFormat, "YYYY-MM-DD", "M.DD"));
values.push(item.count);
});
const barOpts = createBarOpts([createGradientColors(["#04FEAC", "#1EE554"])], categorys);
barOpts.series = [createBarSeriesItem(values)];
return barOpts;
});
传入echarts组件
<zt-echart :opts="opts"></zt-echart>
这样一下子节省了好多代码,业务上逻辑就会清晰不少,当然,barOpts 实际上也是一个常规的对象,如果有定制的修改配置的情况,也是完全支持够用的。
基于以上的设计,我们就实现了图表的可配置化操作,我们要做的仅仅是知道某种类型的图表需要什么样的配置数据即可。
大屏缩放问题解决?
大屏缩放问题,简单来说就收我们的屏幕内容要能随着浏览器缩放而自适应(按照比例)。
对于CSS 的值与单位问题不了解的,可以去看看MDN上解释的。我们这里采用的是vw
(视窗宽度的 1%)来做自适应。
课外阅读:vh,vw参考
下面是适配方案
安装 postcss-px-to-viewport 插件
pnpm add postcss-px-to-viewport -D
配置 postcss-px-to-viewport config
import { defineConfig } from "vite";
import pxtovw from "postcss-px-to-viewport";
// postcss-px-to-viewport config
const pxtovw_config = pxtovw({
unitToConvert: "px", // 要转化的单位
viewportWidth: 1920, // UI设计稿的宽度
unitPrecision: 6, // 转换后的精度,即小数点位数
propList: ["*"], // 指定转换的css属性的单位,*代表全部css属性的单位都进行转换
viewportUnit: "vw", // 指定需要转换成的视窗单位,默认vw
fontViewportUnit: "vw", // 指定字体需要转换成的视窗单位,默认vw
selectorBlackList: ["ignore-"], // 指定不转换为视窗单位的类名,
minPixelValue: 1, // 默认值1,小于或等于1px则不进行转换
mediaQuery: false, // 是否在媒体查询的css代码中也进行转换,默认false
replace: true, // 是否转换后直接更换属性值
});
// https://vitejs.dev/config/
export default defineConfig({
base: process.env.NODE_ENV === "production" ? "/vue-datav/" : "/",
css: {
postcss: {
plugins: [pxtovw_config],
}
},
});
上面的配置在直接写css的时候非常完美,但是在ts中使用代码动态添加css的时候就无法设置了,所以我们需要手动进行转化一下,代码如下:
export function px2vw(px: number, root: number = 1920, fixed = 6) {
const res = (px / root) * 100;
return `${res.toFixed(fixed)}vw`;
}
这样就可以把px
转化成为vw,比如我们的css中可以这样写:
<template>
<div class="date_stamp">{{ dateStamp }}</div>
</template>
<style scoped lang="less">
.date_stamp {
height: 66px; // 这里的单位可以按照设计师给的UI搞写
font-size: 22px;
font-weight: normal;
color: #11ebd7;
line-height: 66px;
position: absolute;
top: 0;
right: 0;
}
</style>
效果如下图所示:
全局监听 window.resize 事件
最后就是来解决图表自身的resize,我们建立一个消息中心,通过事件发布的方式通知每个图表进行自身的 resize,建议 window.resize 做 throttle 处理,提高性能。
eventbus添加
export const EventBusUtils = {
_map: new Map<string, Function[]>(),
addObserve(key: string, action: Function) {
let actions = this._map.get(key);
if (actions) {
actions.push(action);
} else {
actions = [action];
}
this._map.set(key, actions);
},
removeObserve(key: string) {
if (this._map.has(key)) {
this._map.delete(key);
}
},
post(key: string, params?: any) {
if (this._map.has(key)) {
let actions = this._map.get(key);
actions?.forEach((action) => {
action(params);
});
}
},
};
在入口文件处添加resize事件
let realtimeInterval: number | undefined = 0;
onMounted(() => {
realtimeInterval = window.setInterval(() => {
accumulatedSaleTicket.value += Math.floor(Math.random() * 1000);
}, 5 * 1000);
});
onUnmounted(() => {
if (realtimeInterval) window.clearInterval(realtimeInterval);
});
const onResize = throttle(() => {
EventBusUtils.post("WindowResize");
}, 500);
window.onresize = onResize;
在图表中,监听resize事件
onMounted(() => {
const div = document.getElementById(uid)!;
chartInstance = echarts.init(div, undefined, {
renderer: "canvas",
});
EventBusUtils.addObserve("WindowResize", () => {
chartInstance?.resize();
});
nextTick(() => {
chartInstance?.resize();
});
});
watch(
() => props.opts,
() => {
// 先 resize,再 setOptions 否则动画会丢失
chartInstance?.resize();
chartInstance?.setOption(props.opts);
}
);
使用技术栈
:::info
- vue3
- vite
- typescript
- pnpm+workspace
- echarts
- postcss-px-to-viewport
:::
Demo
预览地址:https://ztstory.github.io/vue-datav/#/
源码地址:https://github.com/xiumubai/datav-vue
视频地址:https://www.bilibili.com/video/BV11v4y1U7cc/