Bootstrap

Echarts 柱状图显示在x刻度线右侧方案记录

由于 通过计算位移x轴柱图,因此x轴需要value轴。category类目轴不讨论。

问题

产品希望柱状图显示在坐标轴刻度的右侧,这样就不会在特殊情况下遮挡纵坐标。

横坐标轴为value轴,这样才能更好实现非等间距坐标效果。

非等间距坐标实现,参考Echarts 配置不等间距坐标轴刻度方案记录

echarts官方并未提供柱状图的偏移配置,series.boundaryGap、series.barGap、series.barCategoryGap均未想到有很好的实现。

方案制定

1.移动横坐标区间

使整体柱状图不压住y轴,首先想到的是使整个x轴的x值减少半个柱状图宽度的值。

就如同中学代数,

设 a = 半个柱状图宽度的值 

使 x = x - a 带入原式

但仅使x - a会显示不了最后一个柱状图。原本[0, 50],现在比如[-0.5, 49.5]肯定不行啊,所以在x - a前,xMax要 + 2a(比如[-0.5, 50.5]), 这样就能保证最后一个柱状图显示了。

因此,就需要手动计算并配置xAxis 的max 和 min。这个通过遍历数据源,很快就能得到。

2.x轴上每个值加上a/2

也就是比如原来x= 0 的坐标,现在更换为 x + a

比较

目测是第二种方案更好理解一点。

现在的问题是如何得到a 的值呢。

获取柱状图宽度对应的值

为了便于计算,配置echart barCategoryGap = 0

这样就可以使柱条与柱条间的值,恰好为一个柱状图的宽度

根据现象推测echarts的算法为,柱条的宽度 = 最靠近(差最小)的两个柱条间的距离

那么代码

/* 最小间距*/
let minGap = Infinity;
for (let i = 0; i < xAxisData.length - 1; i++) {
  minGap = Math.min(minGap, xAxisData[i + 1] - xAxisData[i]);
}

那么此时得到minGap 为柱条宽度对应的值。

位移

方案1,移动横坐标区间

接着配置
xAxis.min = xMin - minGap / 2;
xAxis.max = xMax + minGap / 2;

得到

此时,y轴,仍从0开始,需要放在最左侧,可以配置yAxis.axisLine.onZero = false,这样就将整体柱条向右移动了半个柱条距离了。

这种方案的问题是,x坐标的指示不应该在柱条中间了,而是在柱条右侧。

解决的话要么更改markLine 的值减去a,或者重新画个x坐标轴

方案2,每个横坐标值 + a

series.data = dataSource.map(it => [ it[0] + minGap / 2, it[1] ])

该方案鼠标指示器hover上去的时候,显示的值不对,需要减回去。

存在问题

方案1,方案2都存在的问题是,指示器的线在柱图中央。

以下讨论方案1

截至目前,存在柱图过少时,柱条宽度会很宽的问题,如图

因此需要配置柱条的最大宽度。

直接配置series.barMaxWidth 会出现如下情况

此时需要重新计算柱图最大宽度对应的坐标值了

下面由于时间原因,均用方案一作为例子

适配柱条最大宽度

计算x轴长度px

方便计算,事先配置了echarts的grid属性。

const grid = { left: 50, right: 50 };
const echartsEl = document.getElementById('echarts');
const xAxisWidth = echartsEl.offsetWidth - grid.left - grid.right;

计算x轴单位宽度 

单位宽度xUnitValue为

 xUnitValue=\frac{xAxisWidth}{(xMax-xMin)}

到这里还未完,因为实际上我们是要将横坐标xMax = xMax + 2a,  x = x - a 的

所以单位宽度的式子应该为

xUnitValue=\frac{xAxisWidth}{(xMax-xMin+barMaxWidthValue)}

得到x轴单位1对应的px

根据单个柱条的信息,也可单位宽度xUnitValue

有如下式子

xUnitValue=\frac{barMaxWidth}{barWidthValue}

那么可建立等式

\frac{xAxisWidth}{(xMax-xMin+barWidthValue)}=\frac{barMaxWidth}{barMaxWidthValue}

截至目前已知

  • 横坐标长度  xAxisWidth
  • 最大最小值 xMax ,xMin
  • 最大柱条宽度 barMaxWidth

化简等式可得

barMaxWidthValue=\frac{barMaxWidth*(xMax-xMin)}{xAxisWidth-barMaxWidth}

这样可求得最大柱条宽度对应的坐标值

之后与上文提到的最小坐标间距minGap做比较即可。

代码

const barMaxWidthValue = (barMaxWidth * (xMax - xMin)) / (xAxisWidth - barMaxWidth);
let barWidthValue = minGap;
if (minGap > barMaxWidthValue) {
  barWidthValue = barMaxWidthValue;
}

效果

适配只有一个点的情况

当前算法,若只有一个点,则xMax 与xMin相等,而上面分式可看的有的xMax - xMin作为分母,导致计算异常。

而由图看出,echarts在处理xMin = xMax时,柱图会放在最右侧。

我们希望放在最左侧。

这种情况处理就需要额外判断,如果xMax 与xMin相等,则xMax = 2*xMin

这样便可达成效果:

适配多个系列的情况

当有多个系列时,又出了问题。

推测是echarts在计算横坐标间有剩余空间时,柱条宽度不会压缩。

就比如上图0-2之间有许多空白,那么此时占用的宽度为 2*barWidth + barGap

系列数 * barWidth 的最大值为 minGap

为了便于计算,我把barGap设为0,也就是取消系列间的空白

伪代码

let barMaxWidthValue = (barMaxWidth * (xMax - xMin)) / (xAxisWidth - barMaxWidth * 系列数);
if (xAxisWidth > barMaxWidth * 系列数) {
  // 处理系列宽度比x轴还宽的情况
  barMaxWidthValue = (barMaxWidth * (xMax - xMin)) / (xAxisWidth - barMaxWidth * 系列数);
}
let barWidthValue = minGap;
if (minGap > barMaxWidthValue * 系列数) {
  barWidthValue = barMaxWidthValue * 系列数;
}

 效果

关于markLine位置问题

我的想法是再加一个正常的grid坐标轴,覆盖在原坐标轴上方。markLine也从加在这个正常坐标轴中。

便于理解,我拆开给大家看

之后调整下面的空坐标轴位置,与上面坐标重叠就可以了

适配Tooltip指示器

由于覆盖了一个grid在原图上,因此会同时出现两个坐标指示器,和多余的tooltip项。

需要配置原图的xAxis.axisPointer.type = 'none' 

tooltip中的内容,考虑配置 tooltip.formatter 隐藏覆盖坐标轴的信息。

全部代码

仅方案1的

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>柱状图显示在坐标轴刻度右侧</title>
</head>
<script src="https://cdn.bootcdn.net/ajax/libs/echarts/5.1.2/echarts.common.js"></script>
<!-- <script src="https://cdn.bootcdn.net/ajax/libs/echarts/4.9.0-rc.1/echarts-en.common.min.js"></script> -->

<body>
  <div id="echarts" style="height: 300px;width:100%"></div>
</body>
<script>
  let myChart = window.echarts.init(document.getElementById('echarts'));
  const barMaxWidth = 20;
  let dataSource = [
    [0, 10],
    [2, 20],
    [5, 30],
    [10, 40],
    [14, 45],
    [20, 50],
    [30, 57],
    [50, 60],
    [60, 65],
    [90, 70],
    [100, 75],
  ];
  const series = new Array(3).fill(null).map((item, i) => {
    return {
      xAxisIndex: 0,
      yAxisIndex: 0,
      name: 'Fake Data' + i,
      type: 'bar',
      barCategoryGap: 0,
      barMaxWidth,
      barGap: 0,
      data: dataSource,
    };
  });
  /* 横坐标*/
  const xAxisData = dataSource.map(it => it[0]);
  const xMin = Math.min(...xAxisData);
  let xMax = Math.max(...xAxisData);
  if (xMax === xMin) xMax = 2 * xMin;
  const grid = [
    { left: 50, right: 50, top: 20, bottom: 30 },
    { left: 50, right: 50, top: 20, bottom: 30 },
  ];
  const echartsEl = document.getElementById('echarts');
  const xAxisWidth = echartsEl.offsetWidth - grid[0].left - grid[0].right;
  // const xUnitValue = xAxisWidth / (xMax - xMin);

  /* 最小间距*/
  let minGap = Infinity;
  for (let i = 0; i < xAxisData.length - 1; i++) {
    minGap = Math.min(minGap, xAxisData[i + 1] - xAxisData[i]);
  }

  let barMaxWidthValue = (barMaxWidth * (xMax - xMin)) / (xAxisWidth - barMaxWidth);
  if (xAxisWidth > barMaxWidth * series.length) {
    // 处理系列宽度比x轴还宽的情况
    barMaxWidthValue = (barMaxWidth * (xMax - xMin)) / (xAxisWidth - barMaxWidth * series.length);
  }
  let barWidthValue = minGap;
  if (minGap > barMaxWidthValue * series.length) {
    barWidthValue = barMaxWidthValue * series.length;
  }
  
  const option = {
    tooltip: [{ trigger: 'axis' }],
    grid,
    xAxis: [
      {
        gridIndex: 0,
        type: 'value',
        min: xMin - barWidthValue / 2,
        max: xMax + barWidthValue / 2,
        axisTick: { show: false }, // 隐藏刻度线
        splitLine: { show: false }, // 隐藏分割线
        axisLabel: { show: false }, // 隐藏刻度值
        axisPointer: {
          type: 'none',
          // show: false // tooltip会不显示
        },
      },
      {
        gridIndex: 1,
        type: 'value',
        min: xMin,
        max: xMax + barWidthValue,
        axisTick: { show: false }, // 隐藏刻度线
        splitLine: { show: false }, // 隐藏分割线
        axisLabel: { show: false }, // 隐藏刻度值
        axisPointer: {
          triggerTooltip: false,
        },
      },
    ],
    yAxis: [
      {
        gridIndex: 0,
        type: 'value',
        axisLine: {
          onZero: false,
        },
      },
      {
        gridIndex: 1,
        type: 'value',
        axisLine: {
          onZero: false,
        },
        axisTick: { show: false }, // 隐藏刻度线
        splitLine: { show: false }, // 隐藏分割线
        axisLabel: { show: false }, // 隐藏刻度值
      },
    ],
    series: [
      ...series,
      {
        xAxisIndex: 1,
        yAxisIndex: 1,
        type: 'bar',
        data: dataSource.map(it => [it[0], 0]),
        markLine: {
          symbol: false, // 取消箭头
          silent: true, // 取消鼠标hover事件
          label: { position: 'start' }, // 改变label位置
          lineStyle: { color: '#ddd', type: 'solid' },
          data: dataSource.map(val => {
            return {
              xAxis: val[0],
            };
          }),
        },
        z: 10,
      },
    ],
  };
  myChart.setOption(option);
</script>

</html>

最终效果

;