可视化图标库如何选择?

鉴于可视化技术已经相对成熟了,市面上的开源可视化图表库也是繁华缭乱的,那我们应该怎么选择呢?
下面是市面上常见的一些图表库:

1.ECharts.js

官网:https://echarts.apache.org/zh/index.html

Echarts.js文档界面相对友好,支持 SVG 、Canvas 双擎渲染,图表示例也比较全,而且文档是支持中英文的,使用的人也比较多,所以相关资料也很丰富。我们的项目中选择的是ECharts.js

2.Charts.js

英文原版:https://www.chartjs.org 中文版:https://chartjs.bootcss.com

文档整体结构和界面都是非常友好的,也拥有相当数量的基础内置图表,对于常规开发来说,这个库也是比较不错的选择,Api 学习难度低于 ECharts
但是如果有复杂图表比如仪表盘或者地图相关渲染的时候,这个库就不支持了,不过也不影响这个库的好用,大家可以根据自己的业务需求来选择,混合选择多款图表库也是可行的

3.Antv

官网:https://antv.vision/zh 国内镜像:https://antv.gitee.io/zh/

Antv的产品系列划分很多,根据不同的图表类型也分了很多不同的产品线

  • G2(可视化图形)、G2Plot(通用图表库)
  • S2(多维可视分析表格)
  • G6(关系数据图分析工具)
  • X6(图编辑引擎)
  • L7(地理空间数据可视化)
  • F2(专注移动端的可视化解决方案)

文档非常完善,但是服务器部署以后不太稳定,会发生一些未知的bug,导致图像无法渲然,所以不选择。

4.D3.js

官网:https://d3js.org/

纯英文文档,相信这一条或许会劝退很多人,GitHub 有国内开发者翻译的 Api 中文手册,没有内置图表
但是其定义的绘图开发框架可以让你用 Api 的方式来进行 SVG 绘图,这一点比使用原生 SVG 要好很多,如果大家有需求要进行自定义绘制的,可以考虑使用 D3.js。
一句话:很底层,但是足够灵活,可满足绝大部分图表内容的绘制。

总结

综合选择稳定性,上手难度,对各种图表支持的友好性,我们选择ECharts.js来开发。

组件如何设计?

为什么要用组件抽离

  • 如果我们直接上手画图的话,感觉上是非常的快速,每种图标的配置都来一份,每个框框标题都用 Div 一把梭,完全没有心智负担。但是来第二套、第三套、第四套图的时候,就显得非常的冗余。
  • 用过 ECharts 的小伙伴肯定知道,每一个图表都需要单独实例化一个对象用来作为图表管理和绘制,而大屏项目往往会有很多个这样的图表,所以用组件的方式来自动生成和管理这样的 echarts 对象,我们只需要通过传入 options 配置即可完成图表的绘制。
  • 其次,我们还可以通过组件来统一管理当屏幕变化时造成的图表 resize 的情况,封装了组件之后,都可以在组件内部进行统一的事件监听

基于以上三点分析,我们来规划一下我们的组件到底如何划分。

组件设计

下来看一下我们的大屏效果:
image.png
上面有大屏幕、大标题、每个图表的边框、每个图表的文字说明、绘图引擎、屏幕缩放的时候画面跟着等比缩放等等。于是我们对现有的可视化大屏项目做了这样几个拆分: :::tips

  • Screen - 大屏
  • Screen Title - 大屏标题
  • Card - 数据内容卡片
  • Card Title - 数据卡片标题
  • Swiper - 大屏内容滚动
  • Dance Number - 跳动的数字
  • ECharts - 图表 :::

通过这样拆解,将 UI 视觉稿的内容细分为这几大模块并分别管理和实现,虽然不多,但足以应对目前的需求内容了,灵活度也足够,可以随视觉稿调整随时替换。

上面是我们对几大模块的划分,那么ECharts图表组件我们如何设计呢?

ECharts图表组件设计

每一个图表都需要单独实例化一个对象用来作为图表管理和绘制,而大屏项目往往会有很多个这样的图表,所以用组件的方式来自动生成和管理这样的 echarts 对象,我们只需要通过传入options即可完成图表的绘制。由此我们确认,在Echarts组件中需要做到两点:

  • 实例化图表组件
  • 根据传入的options渲然不同的图表,其中options包含data跟opts,即渲染数据和配置

组件设计代码如下:

  1. <template>
  2. <div :id="uid" class="echart"></div>
  3. </template>
  4. <script setup lang="ts">
  5. import * as echarts from "echarts";
  6. import { randomString } from "../../utils/string.util";
  7. import { nextTick, onMounted, watch } from "vue";
  8. import { EventBusUtils } from "../../utils/eventbus.util";
  9. const uid = randomString();
  10. interface Props {
  11. opts: any;
  12. geoJson?: any;
  13. }
  14. const props = withDefaults(defineProps<Props>(), {
  15. opts: Object.create(null),
  16. geoJson: null,
  17. });
  18. let chartInstance: echarts.ECharts | null = null;
  19. onMounted(() => {
  20. // 初始化echarts实例
  21. const div = document.getElementById(uid)!;
  22. chartInstance = echarts.init(div, undefined, {
  23. renderer: "canvas",
  24. });
  25. EventBusUtils.addObserve("WindowResize", () => {
  26. chartInstance?.resize();
  27. });
  28. if (props.geoJson) {
  29. echarts.registerMap("ZheJiang", props.geoJson);
  30. }
  31. chartInstance.setOption(props.opts);
  32. nextTick(() => {
  33. chartInstance?.resize();
  34. });
  35. });
  36. const updateOptions = (opts: any) => {
  37. if (!chartInstance) {
  38. return;
  39. }
  40. chartInstance.setOption(opts);
  41. };
  42. watch(
  43. () => props.opts,
  44. () => {
  45. // 先 resize,再 setOptions 否则动画会丢失
  46. chartInstance?.resize();
  47. chartInstance?.setOption(props.opts);
  48. }
  49. );
  50. defineExpose({
  51. chartInstance,
  52. updateOptions,
  53. });
  54. </script>
  55. <style scoped>
  56. .echart {
  57. width: 100%;
  58. height: 100%;
  59. }
  60. </style>

我们接着来看如何对options进行抽象

抽象options

常见的图表有柱状图、折线图、面积图、饼图、环形图
我们要做的就是按图的类型、坐标轴、颜色这几个维度来进行考虑
我们依据设计稿的风格,对 xAxis、yAxis、color 这三个较为通用的配置进行了统一管理,设置一套默认的配置,用于支持常规的柱状图及折线图。
再根据 Options 的使用习惯,按照图表基本配置和数据源配置做工厂方法动态生成
比如Bar如下代码所示:

  1. export enum BarDirectionEnum {
  2. Horizontal = "Horizontal",
  3. Vertical = "Vertical",
  4. }
  5. /**
  6. * 快捷创建柱状图配置,需自行根据需求设置 category
  7. * @param colors 普通颜色或渐变色
  8. * @param categorys 坐标轴的类别数据源 可选
  9. * @param direction 垂直或水平 默认 垂直
  10. * @returns
  11. */
  12. export function createBarOpts(colors: BBColors[], categorys?: string[], direction = BarDirectionEnum.Vertical): any {
  13. return {
  14. grid: {
  15. top: 30,
  16. bottom: 30,
  17. left: 40
  18. },
  19. color: initColorOpts(colors, direction),
  20. xAxis: init_xAxis(direction, categorys),
  21. yAxis: init_yAxis(direction, categorys),
  22. };
  23. }

同样的,其他风格的我们也可以用统一的api风格扩展为createLineOpts、createLineSeriesItem、createPieOpts、createPieSeriesItem。
封装好之后的实际开发场景代码就会变成这样:

  1. const opts = computed(() => {
  2. const categorys: string[] = [];
  3. const values: string[] = [];
  4. props.shiftList?.forEach((item) => {
  5. categorys.push(DateUtils.string2string(item.dateFormat, "YYYY-MM-DD", "M.DD"));
  6. values.push(item.count);
  7. });
  8. const barOpts = createBarOpts([createGradientColors(["#04FEAC", "#1EE554"])], categorys);
  9. barOpts.series = [createBarSeriesItem(values)];
  10. return barOpts;
  11. });

传入echarts组件

  1. <zt-echart :opts="opts"></zt-echart>

这样一下子节省了好多代码,业务上逻辑就会清晰不少,当然,barOpts 实际上也是一个常规的对象,如果有定制的修改配置的情况,也是完全支持够用的。

基于以上的设计,我们就实现了图表的可配置化操作,我们要做的仅仅是知道某种类型的图表需要什么样的配置数据即可。

下面我们继续探讨大屏缩放问题。

大屏缩放问题解决?

大屏缩放问题,简单来说就收我们的屏幕内容要能随着浏览器缩放而自适应(按照比例)。
对于CSS 的值与单位问题不了解的,可以去看看MDN上解释的。我们这里采用的是vw(视窗宽度的 1%)来做自适应。

课外阅读:vh,vw参考

下面是适配方案
安装 postcss-px-to-viewport 插件

  1. pnpm add postcss-px-to-viewport -D

配置 postcss-px-to-viewport config

  1. import { defineConfig } from "vite";
  2. import pxtovw from "postcss-px-to-viewport";
  3. // postcss-px-to-viewport config
  4. const pxtovw_config = pxtovw({
  5. unitToConvert: "px", // 要转化的单位
  6. viewportWidth: 1920, // UI设计稿的宽度
  7. unitPrecision: 6, // 转换后的精度,即小数点位数
  8. propList: ["*"], // 指定转换的css属性的单位,*代表全部css属性的单位都进行转换
  9. viewportUnit: "vw", // 指定需要转换成的视窗单位,默认vw
  10. fontViewportUnit: "vw", // 指定字体需要转换成的视窗单位,默认vw
  11. selectorBlackList: ["ignore-"], // 指定不转换为视窗单位的类名,
  12. minPixelValue: 1, // 默认值1,小于或等于1px则不进行转换
  13. mediaQuery: false, // 是否在媒体查询的css代码中也进行转换,默认false
  14. replace: true, // 是否转换后直接更换属性值
  15. });
  16. // https://vitejs.dev/config/
  17. export default defineConfig({
  18. base: process.env.NODE_ENV === "production" ? "/vue-datav/" : "/",
  19. css: {
  20. postcss: {
  21. plugins: [pxtovw_config],
  22. }
  23. },
  24. });

上面的配置在直接写css的时候非常完美,但是在ts中使用代码动态添加css的时候就无法设置了,所以我们需要手动进行转化一下,代码如下:

  1. export function px2vw(px: number, root: number = 1920, fixed = 6) {
  2. const res = (px / root) * 100;
  3. return `${res.toFixed(fixed)}vw`;
  4. }

这样就可以把px转化成为vw,比如我们的css中可以这样写:

  1. <template>
  2. <div class="date_stamp">{{ dateStamp }}</div>
  3. </template>
  4. <style scoped lang="less">
  5. .date_stamp {
  6. height: 66px; // 这里的单位可以按照设计师给的UI搞写
  7. font-size: 22px;
  8. font-weight: normal;
  9. color: #11ebd7;
  10. line-height: 66px;
  11. position: absolute;
  12. top: 0;
  13. right: 0;
  14. }
  15. </style>

效果如下图所示:
image.png
全局监听 window.resize 事件
最后就是来解决图表自身的resize,我们建立一个消息中心,通过事件发布的方式通知每个图表进行自身的 resize,建议 window.resize 做 throttle 处理,提高性能。
eventbus添加

  1. export const EventBusUtils = {
  2. _map: new Map<string, Function[]>(),
  3. addObserve(key: string, action: Function) {
  4. let actions = this._map.get(key);
  5. if (actions) {
  6. actions.push(action);
  7. } else {
  8. actions = [action];
  9. }
  10. this._map.set(key, actions);
  11. },
  12. removeObserve(key: string) {
  13. if (this._map.has(key)) {
  14. this._map.delete(key);
  15. }
  16. },
  17. post(key: string, params?: any) {
  18. if (this._map.has(key)) {
  19. let actions = this._map.get(key);
  20. actions?.forEach((action) => {
  21. action(params);
  22. });
  23. }
  24. },
  25. };

在入口文件处添加resize事件

  1. let realtimeInterval: number | undefined = 0;
  2. onMounted(() => {
  3. realtimeInterval = window.setInterval(() => {
  4. accumulatedSaleTicket.value += Math.floor(Math.random() * 1000);
  5. }, 5 * 1000);
  6. });
  7. onUnmounted(() => {
  8. if (realtimeInterval) window.clearInterval(realtimeInterval);
  9. });
  10. const onResize = throttle(() => {
  11. EventBusUtils.post("WindowResize");
  12. }, 500);
  13. window.onresize = onResize;

在图表中,监听resize事件

  1. onMounted(() => {
  2. const div = document.getElementById(uid)!;
  3. chartInstance = echarts.init(div, undefined, {
  4. renderer: "canvas",
  5. });
  6. EventBusUtils.addObserve("WindowResize", () => {
  7. chartInstance?.resize();
  8. });
  9. nextTick(() => {
  10. chartInstance?.resize();
  11. });
  12. });
  13. watch(
  14. () => props.opts,
  15. () => {
  16. // 先 resize,再 setOptions 否则动画会丢失
  17. chartInstance?.resize();
  18. chartInstance?.setOption(props.opts);
  19. }
  20. );

使用技术栈

:::info