数据可视化大屏的一些思考

原创
2018/09/01 17:28
阅读数 3.4K

Vue前台

要求和规定

  1. 暂时只考虑支持Chrome等稍微高级的浏览器吧,而且编译之后的代码非常好做浏览器适配

  2. 整个大屏不应该有滚动条,滚动条可以出现在画板内,画布可以左右滚动

  3. 暂不考虑画布缩放问题,需要设置。

    overflow: auto
    

    画板和画布之间为了美观会有间距,所以画板的长和宽比画布要大40px。

    而画布的长和宽即是设置的最终可视化大屏大小

  4. 图表Item在画布内可以拖拽和缩放,但是不能超过画布大小

  5. 尽量减少请求后台的次数

  6. 查询后台数据的SQL应该尽量简单。原因是我们的可视化数据表不是原始表。是经过了抽取和转换之后的可视化图表,应该尽量符合可视化展示而不应该保留无关的业务结构和逻辑


思考

  1. 整个页面布局应该以absolute为基础。使其不会出现滚动条,并且自动适应宽度和高度

  2. 图表之间区分类别,使用类别做区分。类别同时会关联图表元素(HTML)、配置元素(HTML)、配置内容(JavaScript)

  3. 整个页面需要分层

    1. Layout层:负责管理和实现画布。内部包含图表元素。
    2. Item层:实现拖拽和缩放逻辑(注意是绝对定位)。
    3. Slice层:绘制图表,并且传递图标配置。
    4. ChartOption层:编辑图表配置。重点在于,此层和上一层的交互。在后面细说
  4. 点击图表(Slice层),执行选中当前图表对象Item事件。同时传递当前Item到父组件,由父组件分配至图表配置(ChartOption层),对图表Options进行编辑。

  5. 点击图标之外的其他地方只是取消图表的选中的样式,但是不可以取消当前选中的Item对象。

  6. 图表保存和更新时应该注意

    1. 保存和更新的区分在于有无可视化实例ID(instanceId)
    2. 保存和更新应该传递当前可视化大屏的所有状态。由后台区分那些图表元素应该新增,编辑或者删除
    3. 可以考虑添加定时保存功能。
  7. 保存数据库之前进行截屏操作,截屏组件使用html2canvas(从github下载源代码之后打包之后使用的)。

  8. 开发过程需要使用moke(mock这个组件之后可能会使用,现在用的还不熟练)

  9. 自定义图表实现思考

    1. 计数器,标题文本,进度条,图片。基本没什么需要思考的

    2. 轮播列表,使用HTML元素的

      <ul>
        <li>表头</li>
        <li>一行数据</li>
      </ul>
      

      去实现列表的轮播,使用内外两层div做滚动和隐藏。目的应该是列表中的数据来回滚动。

    3. 轮播列表的后台数据,如果每一次的SQL都是一样的,那么每一次查询的应该都是全量数据。直接替换轮播的List。如果每次的SQL都是动态的,那么应该是将查询回来的数据增量添加到轮播的List列表中(这个稍微有些难实现,不过效果应该会比较好)

    4. 词云需要引入ECharts的扩展组件

  10. 每一次添加图表。需要给图表一个ID值,用于做一些处理。这里设置图表的ID为字符串类型的“chart”+index。在新建的时候默认startIndex为0。在编辑时,需要由后台传递过来startIndex

  11. Copy一个图表作为模板,在此基础上创建新的图表。可以考虑增加一个字段作为判断。因为图表属于编辑还是新增主要在于是否传instanceId到后台。当判定为Copy时不传instanceId,只是普通编辑就传instanceId。用最少的代码实现一个功能才是厉害。


实践

  1. 第一步,我查找了github有关于data-view关键词的结果。找到了一个看起来非常可靠的数据可视化组件,使用下面的方式进行构建,并查看页面和代码。发现局限性在于无法适应画布和画板的设计。x-chart将宽度分为栅格而不是具体的数值。同时高度没有限制,可以无限扩展。除此之外。对于页面结构的设计和图表的封装都是可以参考的部分。

    # clone the project
    git clone https://github.com/yugasun/x-chart.git
    # install dependency
    npm install
    # develop
    npm run dev
    
  2. 第二步,参考x-chart开始实现自己的布局和布局内的元素。

    1. 首先精简依赖,只留下interactjs。功能上只留下拖拽和缩放事件的监听,并且剔除掉关于栅格的计算。
    2. 其次重新实现拖拽和缩放事件的功能,Item组件内分别监听了开始,过程,结束三个状态。在开始时设置下一个状态开始的标志,在过程阶段实时更新图表的style(其实就是x和y;width和height)。在结束阶段通知父组件更新Layout组件内中的关于元素的x和y的配置
    3. 边界设置,在超过边界是不改变图表状态。
  3. 第三步,首先实现的是在有假数据情况下创建一个图表。可以拖拽和缩放。

  4. 第四步,编写所有图表创建的时候所需要的配置。

  5. 第五步,添加配置组件。实现点击图表时根据不同的图表,显示对应图表的配置信息。这一步要特殊说一下

    绘制图表时使用ItemList循环创建,点击图表的时候传递当前子层的Item对象到父层,父层会将接收到的Item对象传递给配置组件子层(参考思考4)。然后不需要有任何操作,在配置组件子层的Item对象发生的修改会实时更新回到原来的ItemList中。剩去了非常多的无用操作。

    因为此现象存在,我们无需手动进行更新图表配置。而且当选中其他图表时只需要重新传递一次Item组件就OK了

  6. 第六步,图表开始加载真正的后台数据,并且顺便保存和更新和复制可视化大屏实例。

  7. 最后一步,需要完善所有图表绘制方法,和所有图表的配置的修改,和所有图表获取数据的方法,和所有图表的配置的初始化


关键代码

  1. 对于非常多类别的图表,每一次加载图表以及加载图表配置时都需要判断是哪一个图表。这里引入vue组件化的一个实现

    <component
               :loading="loading"
               :is="ChartComponentMap[item.chartType]"
               :api-data="chartData"
               :option="item.option"
               :i="item.i"
               @init="chartInit"
     />
    

    这里避免了if----else的判断操作,只需要将图表组件的名称构造在一个Map对象中就可以满足。如果新添加一个图表,只需要在下面的Map对象中添加一个键值就可以满足了

    const ChartComponentMap = {
      'plotBubble': 'PlotBubble',
      'plotMap': 'PlotMap'
      // 省略其他图表组件
    }
    
    export default ChartComponentMap
    
    

    同理,这种操作同样发生在其他需要和图表类型做关联的部分

  2. 每一次新建图表时,都需要按照图标类型,添加图表所需要的配置项。这里的配置项不可以是一个常量。这样相同类型的图表配置项会发生冲突。应该每一次新建给的都是一个新的配置项对象。可以参考以下代码进行实现。

    const CounterConfig = function() {
      this.config = {
        x: 0,
        y: 0,
        width: 350,
        height: 250,
        chartType: 'counter',
        choose: 'false',
        refresh: 'false',
        chartData: {},
        data: [],
        interval: 8000,
        option: {}
      }
    }
    const getCounterConfig = function() {
      return new CounterConfig().config
    }
    export { getCounterConfig }
    
    
  3. 刷新数据模块,需要对图表的配置进行监听。只有当数据发生变化并且数据的每一个字段都有值的时候,才会向后台请求数据。需要经过下面的两个函数的判断

    // 检查两次数据是否一致,如果一致就不去请求数据库
    checkData(lastObject, newObject) {
      var lastObjectKeys = Object.keys(lastObject)
      var newObjectKeys = Object.keys(newObject)
      if (lastObjectKeys.length === newObjectKeys.length) {
        for (var i = 0; i < lastObjectKeys.length; i++) {
          if (lastObject[lastObjectKeys[i]] !== newObject[lastObjectKeys[i]]) {
            return false
          }
        }
        return true
      } else {
        return false
      }
    }
    // 检查是否每个字段都填写了值,如有没有就不去请求数据库
    checkDataKey(object) {
      var objectKeys = Object.keys(object)
      for (var i = 0; i < objectKeys.length; i++) {
        if (object[objectKeys[i]] === undefined ||
            object[objectKeys[i]] === null ||
            object[objectKeys[i]] === '') {
          return false
        }
      }
      return true
    }
    
  4. 刷新页面,因为是单页应用。每一次重新加载浏览器会显得非常蠢(除非浏览器URL发生变化)。所以这里引入了一个reload组件

    <template>
      <div>
        <router-view v-if="isRouterAlive"></router-view>
      </div>
    </template>
    
    <script>
    export default {
      provide() {
        return {
          reload: this.reload
        }
      },
      data() {
        return {
          isRouterAlive: true
        }
      },
      methods: {
        reload() {
          this.isRouterAlive = false
          this.$nextTick(function() {
            this.isRouterAlive = true
          })
        }
      }
    }
    </script>
    

    在此组件路由下的页面中都可以按照下面的代码引用和调用

    // 引用
    inject: ['reload']
    // 调用
    this.reload()
    
  5. 当出现需要等待上一步操作或者上几步操作结束再执行任务时可以选择使用Promise来实现,避免出现JavaScript的回调地狱。参考如下的代码

    var _this = this
    const generateScreenCapture = new Promise(function(resolve, reject) {
      _this.$message({
        type: 'info',
        message: '正在生成缩略图!'
      })
      const params = {
        logging: false, // 日志开关,发布的时候记得改成false
        width: _this.panelConfig.panelWidth, // dom 原始宽度
        height: _this.panelConfig.panelHeight // dom 原始高度
      }
      window.html2canvas(document.getElementById('datav-container-layout'),
        params).then(function(canvas) {
        _this.panelConfig.instanceViewImg = canvas.toDataURL('image/png')
        resolve()
      })
    })
    generateScreenCapture.then(function() {
      // 当截图操作结束时,才执行保存数据库任务
    }.bind(this))
    
  6. 页面显示之前应该由正在加载的标识,尤其是当编辑可视化大屏的时候。加载所有的图表需要的时间非常长。需要有一个loading存在。当页面加载完毕将loading置为false。

    但是会有一个问题:当页面不是编辑的时候,watch函数就失去了作用。loading也不会被置为false。基于此问题,使用如下代码解决

    watch: {
      isFinish: {
        handler() {
          this.$nextTick(function() {
            this.loading = false
          }.bind(this))
        }
      }
    }
    
    isFinish() {
      // 当处于编辑状态时
      // 应该监听图表Item的列表
      // 没有处于编辑状态时
      // 应该监听其他会刷新页面的参数,例如数据源或者文件列表等等
      const instanceId = this.$route.params.instanceId
      if (instanceId) {
        return this.slices
      } else {
        return this.dataSourceList
      }
    }
    

Java后台

思考

  1. 后台代码没有特别复杂,需要将前台传递过来的数据源和SQL等信息发送给数据库进行查询。然后将返回值结果进行封装就OK了。需要注意的是,要经过严密的校验
  2. 存储大屏数据时。需要判断前台的图表列表中的数据,哪些需要保存,哪些需要更新,哪些需要删除。

代码

  1. 对于查询数据库的判断。需要每一个字段值都都不为空

    public static boolean IsNoneBlank(Object... params) {
    	for (Object param : params) {
    		if (null == param)
    			return false;
    		if (param instanceof Integer) {
    			if ((int) param < 0)
    				return false;
    		}
    		if (param instanceof String) {
    			if (StringUtils.isBlank(String.valueOf(param)))
    				return false;
    		}
    	}
    	return true;
    }
    // 调用
    CommonUtils.IsNoneBlank(database, sql, x, y, legend);
    
  2. 对于数据库返回值的判断。需要包含指定的字段以及要有数据

    public static boolean CheckData(List<LinkedHashMap<String, Object>> resultDataMapList, String... params) {
    	if (CollectionUtils.isEmpty(resultDataMapList))
    		return false;
    	LinkedHashMap<String, Object> resultDataMap = resultDataMapList.get(0);
    	Set<String> resultDataMapKeySet = new HashSet<String>(resultDataMap.keySet());
    	for (int i = 0; i < params.length; i++) {
    		if (resultDataMapKeySet.add(params[i]))
    			return false;
    	}
    	return true;
    }
    // 调用
    ChartDataUtils.CheckData(resultDataMapList, x, y);
    
  3. 将图表类型和获取数据的实现类做绑定,就不用if----else的判断了

    // 图表类型和实现类ID做对应
    public final static Map<String, String> CHART_TO_DATA_MAP = new HashMap<String, String>() {
    	private static final long serialVersionUID = 2143969699809629580L;
    	{
    		// 堆叠折线图==堆叠面积图
    		put("lineStacking", "LineStackingChartDataImpl");
    		// 堆叠面积图==堆叠折线图
    		put("lineStackingArea", "LineStackingChartDataImpl");
    		// 中国地图图表
    		put("mapChina", "MapChinaChartDataImpl");
    		// 普通饼图==环形饼图==2D饼图
    		put("pieNormal", "PieNormalChartDataImpl");
    		// 环形饼图==2D饼图==普通饼图
    		put("pieRing", "PieNormalChartDataImpl");
    		// 2D饼图==普通饼图==环形饼图
    		put("pie2D", "PieNormalChartDataImpl");
    		// 环形百分比图
    		put("piePercent", "PiePercentChartDataImpl");
    	}
    };
    // 获取实现类对象(这里使用接口做抽象)
    ChartDataService chartDataService = webApplicationContext
    					.getBean(ChartDataMap.CHART_TO_DATA_MAP.get(chartType), ChartDataService.class);
    // 然后直接调用方法就可以了
    
展开阅读全文
打赏
0
0 收藏
分享

作者的其它热门文章

加载中
更多评论
打赏
0 评论
0 收藏
0
分享
OSCHINA
登录后可查看更多优质内容
返回顶部
顶部