一个需求让我被迫解决 Echarts 多列折线图/柱状图标签遮挡问题 | 8月更文挑战

4,870 阅读7分钟

谈一谈Echarts

echarts是个开箱即食的图表库,以图表为出发点,通过配置图表内的组件来自定图表。内置了大多数常用图表。用户只需要进行一些配置,无需关注图形数据的映射渲染,就可以轻松得到你想要的图表。

也正是因为 Echarts 为你做了许多事情,在你想更细微的控制图表的时候,Echarts 就很难去实现。

例如,Echarts 只为我们提供了组件和系列映射图形(线、柱和其他数据映射图形)的关系和组件与坐标系的关系。组件与组件之间是相互无感知的,组件内更细微的单元之间也是无感知的。这也不能怪 Echarts , Echarts 当初就是这么设计的,目的就是为了帮助用户更快的创建一个图表。

label 标签的遮挡问题

随着需求不断的提升,ECharts 对细微单元的控制问题逐项显露出弊端。

需求

解决线图与柱状图 lable 标签遮挡问题。由于图表最后需要转为图片放入PDF内进行下载。所以数据上必须要通过 lable 标签清楚的展示出来。

思考

由于 label 之间无法感知,无法同时获得不同组数据中同项的 label 位置。所以如果要控制 label 之间的位置关系,最好的办法就是在配置图表之前,分析 data 数据列,将可能会导致 label 重叠的数据进行标识,在配置 series.data.label 的时候向某个方向位移一定距离。

方案是有了,但还有以下几个问题需要去解决。

  1. 如何形成同一列 label 的位置关系,如何标记他们。
  2. 如何判断 label 是否重合
  3. 判断重合的 label 向什么方向,位移多少距离
  4. 如果保证位移之后的 label 不会再发生重叠问题

解决问题

如何形成同一列 label 的位置关系,如何标记他们。

这里创建一个 ChartMap 类来保存当前数据与同列数据的关系,并记录坐标,位置,位移量。这里不做比较,只做记录,而且不仅可以记录 label 的位置关系,只要是和原数据相关联的都可以进行记录,看个人需求。

每一个图表对应一个 ChartMap 实例。ChartMap 实例内有一个 mapData 用来存关系数据。create 方法用来创建数据的映射对象,并存入 mapData 中

/** ChartMap 的Id */
let _id = 0;
/**
 * 用来记录 echarts 数据之间的关系
 * @class ChartMap
 * @constructor 初始化数据
 * @param {Array} data  储存原数据列副本
 * @param {Object} mapData  映射的数据
 * @param {String} name  名称
 * @param {Number} _id  Id
 * @method create 创建 mapData 对象
 */
class ChartMap {

  constructor(data, name = "Chart") {
    /** 储存原数据列副本 */
    this.data = JSON.parse(JSON.stringify(data));
    /** 映射的数据 */
    this.mapData = {};
    /** ChartMap 名称 */
    this.name = name == "Chart" ? name + _id : name;
    /** ChartMap Id */
    this._id = _id;
    _id += 1;
  }
  /**
   * 创建映射对象并存入 mapData 中
   * @param {Number|String} x 处于第几行(同组数据的那一项数据),x 点坐标位置
   * @param {Number|String} y 处于第几列(不容数据组中的哪一组),y 点坐标位置
   * @returns 
   */
  create(x, y) {
    if (this.mapData[`${x},${y}`]) return;
    this.mapData[`${x},${y}`] = {
      /** 原始数据列 */
      originList: this.data[x].data,
      /** 原始数据 */
      originData: Number(this.data[x].data[y]),
      /** 通过原始数据来标记标签位置,用于计算 offset */
      labelPosition: Number(this.data[x].data[y]),
      /** 需要与当前点进行位置计算的点集合 */
      compareMap: {},
      /** 距离当前点,绝对位置最近的点对象 */
      NearestPoint:null,
      /** 距离当前点,最近的绝对距离 */
      NearestDistance:0,
      /** 当前点 x 方向上需要位移的距离 */
      offSetx: 0,
      /** 当前点 y 方向上需要位移的距离 */
      offsetY: 0,
      /** 当前点在同组数据中的位置 */
      sort:0,
      /** 当前点的 x 坐标 */
      x,
      /** 当前点的 y 坐标 */
      y,
    };
  }
}

初始化 ChartMap 实例,构建需要进行比较的同列数据的关系(同y不同x,下文统称为同列数据)

/**
 * 序列化 echarts 数据列 ,修正 echarts label 遮挡问题
 * @param {String} echartType 图表种类:line 线 bar 柱状 (必填)
 * @param {Array} seriesArr echarts 数据列 (必填)
 * @param {Object} labelOption label额外配置项 (选填)
 * @param {Number} AxisMax  坐标系量程 (选填)
 * @param {Number} judgeCoefficient 判断系数,计算触发遮挡的条件 (选填)
 * @param {Number} fixCoefficient 修正系数,遮挡后修正距离的系数 (选填)
 */
function fixEchartsLabel(
  echartType,
  seriesArr,
  labelOption,
  AxisMax,
  judgeCoefficient = 2.5,
  fixCoefficient = 0.6
) {
  if (Object.prototype.toString.call(seriesArr) !== "[object Array]")
    return new Error("fixEchartsLabel 函数需要传入一个 echarts 数据列数据");
  if (seriesArr.length <= 1) return seriesArr;
 
  // 多少组数据 x
  const dataRowLength = seriesArr.length;
  // 每组数据有多少个(取最多的) y
  let lengerColLength = 0;
  for (let i = 0; i < seriesArr.length; i++) {
    if (Object.prototype.toString.call(seriesArr[i].data) !== "[object Array]")
      continue;
    if (seriesArr[i].data.length > lengerColLength) {
      lengerColLength = seriesArr[i].data.length;
    }
  }
  // 初始化坐标
  let x = 0;
  let y = 0;
  // 存映射点用于最后计算赋值
  const chartMap = new ChartMap(seriesArr);
  // 获取最大值
  let maxValue = 0;
  // 同列数据建立映射关系
  while (x <= dataRowLength && y < lengerColLength) {
    if (x + 1 > dataRowLength) {
      y += 1;
      x = 0;
      continue;
    }
    // 存当前点
    chartMap.create(x, y);
    const cur = chartMap.mapData[`${x},${y}`];
    maxValue = cur.originData > maxValue ? cur.originData : maxValue;
    // 存同组的零时数据,用于非 bar 图对相同值增加 sort
    const temp = [];
    // 最近距离
    let NearestDistance = Infinity;
    // 当前点建立与同列数据的关系
    for (let i = 0; i < dataRowLength; i++) {
      if (i == x) continue;
      // 存比较点,不用担心重复 create 做了重复的判断
      chartMap.create(i, y);
      const target = chartMap.mapData[`${i},${y}`];
      // 这里把同列的点都加入了比较列表中
      cur.compareMap[`${i},${y}`] = target;
      // 确定当前位置排序,非柱状图排除相同值的点
      if(cur.originData > target.originData &&  temp.indexOf(target.originData) == -1){
        cur.sort += 1;
        temp.push(target.originData)
      } else if (echartType == 'bar' && cur.originData == target.originData && target.x > cur.x ){
        target.sort += 1;
      }
      // 绑定最近距离的点
      const distance = Math.abs(target.originData - cur.originData);
      if( distance < NearestDistance ){
        cur.NearestPoint = target;
        cur.NearestDistance = distance;
        NearestDistance = distance;
      }
    }
    x += 1;
  }
  console.log(maxValue,chartMap)
  // AxisMax 用来计算偏移的基础位移
  AxisMax = AxisMax ? AxisMax : maxValue;
  // 返回计算后的数据列
  return calcLableOffset(
    chartMap.data,
    chartMap.mapData,
    labelOption,
    judgeCoefficient,
    fixCoefficient,
    AxisMax
  );
}

如何判断 label 是否重合

/**
 * 计算 echarts label 位移
 * @param {Array} seriesArr echarts 数据列
 * @param {Object} chartMap echarts数据map
 * @param {Object} labelOption label额外配置项
 * @param {Number} judgeCoefficient 判断系数,计算触发遮挡的条件 
 * @param {Number} fixCoefficient 修正系数,遮挡后修正距离的系数 
 * @return {Object} result 计算后的 echarts 数据列
 */
function calcLableOffset(
  seriesArr,
  chartMap,
  labelOption,
  judgeCoefficient,
  fixCoefficient,
  AxisMax
) {
  // 计算触发数值。
  const judgeDistance = +((+AxisMax * judgeCoefficient) / 100).toFixed(2);
  // 计算修正值。
  const fixDistance = +((+AxisMax * fixCoefficient) / 100).toFixed(2);
  // 循环数据 map 判断是否遮挡、是否位移以及位移距离
  for (let cPoint in chartMap) {
    const cur = chartMap[cPoint];
    // 位移的距离按照 基础距离 * 当前点再同列数据中的顺序
    // 这里将 5 倍临界值作为安全距离, 大于 5 倍临界值就不进行位移
    const offsetY = cur.NearestDistance > 5 * judgeDistance ? 0 : -fixDistance * cur.sort;
    cur.offsetY = offsetY;
    // 重新构建数据列
    seriesArr[cur.x].data[cur.y] = {
      value: cur.originData,
      label: {
        ...labelOption,
        position: 'top',
        offset: [0, offsetY],
        show: true,
      },
    };
  }
  return seriesArr;
}

判断重合的 label 向什么方向,位移多少距离

这里先给出我最后的方案

这里位移的方向统一是向上的,根据当前点所在位置的顺序计算位移距离。

位移距离 = 最近的比较点是否再安全距离外 ? 不进行位移 : ( 计算得到的基础位移记录 * 当前点再同列数据中的大小位置处于第几个 )

位移距离的控制是由3点要素来控制

  1. 当前点在同列数据中数据的大写处于第几个。
  2. 坐标系最大值和修正系数 fixCoefficient 来计算基础位移距离。
  3. 当前点与里它最近的同列点的距离是否再安全距离外。在安全距离外 label 就不用再发生位移。

这里其实没有进行遮挡的判断,用了另一种方法解决的遮挡及 label 位移后再遮挡的问题,具体的思考过程如下。

如果保证位移之后的 label 不会再发生重叠问题

这个问题困扰我很久,由于这里设计的位移方向是单向,再确定移动后的位置是否有遮挡情况下的判断会出现多次的递归比较,且最后的 label 位置经常远远大于源点。

所以在label位置的计算上用了一个取巧的方法,给同列数据按大小排序。移动距离按 基本距离 * 同列大小序位,这样一来的其实就是把 label 的位置在 y 方向上散列开。数据相较于同组越大,散开的距离也越大。

这么一来,嘿!满足需求了。不过这么一来依然有两个问题:

  1. 一些点非常明显的不需要进行比较,label 不用发生位移的也进行了位移
  2. 有时 label 会移动到图表外从而不可见。

我的解决方法;

  1. 每个数据点都记录一个最近的点的距离,并设个一个安全范围,大于安全范围的不用位移
  2. 给定一个坐标最大值。通过这个值来计算触发距离和位移基本距离。同时这个值也可以作为坐标系的最大值,在外部配置图表的时候设置上(需要手动分析一下数据动态设定最大值,这里不给这个参数默认取数据中的最大值)。

效果图如下

image.png

gitee地址

总结

这篇文章主要是记录了我的思考和解决问题的过程。希望通过这篇文章,能给大家日常的开发工作带来一些帮助。

由于我接手的项目是个2年前的项目,项目中用的是 Echarts ,只不过原来的图表展示的需求在日常的维护中越来越复杂,这里安利一下 antv-G2。

知道 G2 的小伙伴们应该会知道,这种问题在 G2 中只要配置下 label 的 layout 属性就可以使用官方给出的解决方案,非常好用。同时还可以自定 layout 方案非常灵活。

等我弄清楚 G2 ,有时间的话一定会给大家介绍分享一下。