findxc/blog

ECharts 双 y 轴时分隔线 splitLine 对齐问题

Opened this issue · 3 comments

直接看代码 —> echarts-double-yAxis-splitLine-alignment - CodeSandbox

问题

产品想实现 ECharts Mixed Line and Bar 这种两个 y 轴并且分隔线是对齐的效果。

image

如果不额外配置的话(注释掉这个例子中 yAxis 中 max 和 interval 的值),就是下面这种效果,两个 y 轴的分隔线是 ECharts 按各自的最优显示去计算的,所以不一定会刚好对齐。

image

如果我们希望分隔线能对齐的话,就需要手动设置 min, max 和 interval ,使得两个 y 轴的 (max - min) / interval 的值是一样的,也就是两个 y 轴的分割段数是一样的

一个简单的解决方案

我们先不考虑数据有负数的场景,把 y 轴的 min 设为 0 。

然后把 y 轴分割段数固定为 5 。

这样就只用考虑 interval 的取值了,max 就是 interval * 5 。

interval 最好取整数。我们先计算第一个 y 轴的 interval 。根据第一个 y 轴的真实数据计算得到最大值,比如说是 203.4 ,然后除以 5 ,是 40.68 ,然后向上取整,是 41 ,这就是第一个 y 轴的 interval 的值了。再用同样的方法计算得到第二个 y 轴的 interval 的值。

这样就 ok 了,通过设置 min, max 和 interval ,两个 y 轴都被分为了 5 份,分隔线是对齐的。

但是还存在一个问题,就是你的 interval 计算出来虽然是个整数,但是不够好,你去观察 ECharts 你会发现它的 interval 一般会是 10, 20, 30 等这种比较”整“的数,而不可能是 41, 42, 43 这种,你想想你小时候画坐标轴也不会用这种数字作为间隔。

如何得到一个比较”整“的 interval

你要先知道,什么是比较”整“的 interval 。

通过观察 ECharts 的折线图,我们可以发现,interval 的可能取值有 0.01, 0.02, 0.03, 0.050.1, 0.2, 0.3, 0.51, 2, 3, 510, 20, 30, 50 ,发现规律了吗?

interval 的取值是 1, 2, 3, 5 的 10 倍、 100 倍或者 0.1 倍、0.01 倍等等。

另外一点,如果说我们计算出来的 interval 是 1.2 ,那么我们实际就应该取 2 。如果是 3.6 ,那就应该取 5 。

如果是 12 呢,就是取 20 。如果是 120 呢,就是取 200 。

想想对于任意一个数,如果得到最合适的 interval 值?

如果能把所有可能的 interval 从小到大排列,那第一个大于等于这个数的,就是最合适的 interval 。

但是所有可能的 interval 是无穷的,也就是我们无法定义出这个排好序的数组。

但是我们可以先把数据缩放到 [1, 10) 的范围,然后找到 interval 后再对应缩放回去。

比如 120 是 1.2 * 100 ,对于 1.2 我们应该取 2 ,然后再用 2 * 100 得到 200 ,就是 120 对应的最合适 interval 了。

相应的代码如下:

// 把值拆解为一位数和10的多少次方的乘积
// 比如 120 = 1.2 * Math.pow(10, 2)
const parseDigitAndPow = (num) => {
  let pow = 0
  while (num < 1) {
    num = num * 10
    pow = pow - 1
  }
  while (num >= 10) {
    num = num / 10
    pow = pow + 1
  }
  return { digit: num, pow }
}

// 根据一位数拿到对应的 interval
// 第一个大于等于它的就是最合适的 interval
const parseInterval = (digit) => {
  const intervals = [1, 2, 3, 5, 10]
  for (let interval of intervals) {
    if (interval >= digit) {
      return interval
    }
  }
  throw Error(`digit: ${digit},没有找到合法 interval `)
}

// 把浮点数运算引起的不准确问题格式化一下(对于太小的数这个方法就不准确了)
const formatFloat = (num) => {
  return Number(num.toFixed(8))
}

// 找到大于等于 num 的最佳 interval
const findInterval = (num) => {
  if (num <= 0) {
    throw Error(`num: ${num},只能为大于 0 的值计算 interval`)
  }

  const { digit, pow } = parseDigitAndPow(num)
  const interval = parseInterval(digit)
  const realInterval = interval * Math.pow(10, pow)
  return formatFloat(realInterval)
}

到这里,我们的 interval 计算就 ok 了。但是还有一个小问题,就是我们都是按照 y 轴分为 5 份去计算的,但是其实可能某些场景下其实 y 轴分为 4 份或者 6 份时数据最大值更接近 y 轴最大值,也就是图形会把画布占得更满。

如何让图形尽量占满画布

想要解决这个问题的话,其实也简单,因为我们的期望是数据最大值占 y 轴 max 值的比例尽可能大,那我们就按照 y 轴分为 4、5、6 份来分别计算占 y 轴比例,然后哪个比例最大就用哪个就行了。

// 双 y 轴时为了分隔线对齐,计算 interval 和 max
// num1 和 num2 分别是两组数据的最大值
export default (num1, num2) => {
  // 都为 0 时 返回 echarts 本身的默认值
  if (!num1 && !num2) {
    return [
      { interval: 0.2, max: 1 },
      { interval: 0.2, max: 1 },
    ]
  }

  // 如果有一个为 0 ,那么就设为另外一个的值,也就是直接按有值的去计算
  if (!num1) {
    num1 = num2
  }
  if (!num2) {
    num2 = num1
  }

  // 从 份数为 4 5 6 中取最好的情况
  const splitNumbers = [4, 5, 6]

  // 根据切分的份数计算出对应的 interval
  const intervals = splitNumbers.map((n) => {
    const num1Avg = num1 / n
    const num2Avg = num2 / n
    const interval1 = findInterval(num1Avg)
    const interval2 = findInterval(num2Avg)
    const percent = (num1Avg / interval1 + num2Avg / interval2) / 2
    return { interval1, interval2, percent }
  })

  // 看哪种情况实际的平均值最接近 interval
  const percents = intervals.map((x) => x.percent)
  const maxPercent = Math.max(...percents)
  const index = percents.findIndex((x) => x === maxPercent)

  const bestSplitNumber = splitNumbers[index]
  const bestInterval1 = intervals[index].interval1
  const bestInterval2 = intervals[index].interval2

  return [
    {
      interval: bestInterval1,
      max: formatFloat(bestInterval1 * bestSplitNumber),
    },
    {
      interval: bestInterval2,
      max: formatFloat(bestInterval2 * bestSplitNumber),
    },
  ]
}

写在结尾

在做这个需求的时候,我一开始想的是如何直接实现最后一步的效果,然后发现理不清 ... 就是那种两只手怎么抛接三个球的感觉 ...

然后又梳理了下,把问题拆分,先做最基础功能,然后优化,然后再优化,这样目的更明确,在想解决方案时也可以更专注。

在思考如何计算 interval 的时候,自己是通过去各种测试 ECharts 的表现,找出它的规律,把规律再用代码实现。(当然你也可以直接去看 ECharts 源码?)

最后做完了自己还是挺满足的 hhh 。

F5F5 commented

没考虑到负数的情况,还可以再完善一下,做个标记🤓

F5F5 commented
// 把值拆解为一位数和10的多少次方的乘积
// 比如 120 = 1.2 * Math.pow(10, 2)
const parseDigitAndPow = (num) => {
  let pow = 0
  while (num < 1) {
    num = num * 10
    pow = pow - 1
  }
  while (num >= 10) {
    num = num / 10
    pow = pow + 1
  }
  return { digit: num, pow }
}

// 根据一位数拿到对应的 interval
// 第一个大于等于它的就是最合适的 interval
const parseInterval = (digit) => {
  const intervals = [1, 2, 3, 5, 10]
  for (const interval of intervals) {
    if (interval >= digit) {
      return interval
    }
  }
  throw Error(`digit: ${digit},没有找到合法 interval `)
}

// 把浮点数运算引起的不准确问题格式化一下(对于太小的数这个方法就不准确了)
const formatFloat = (num) => {
  return Number(num.toFixed(8))
}

// 找到大于等于 num 的最佳 interval
const findInterval = (num) => {
  if (num <= 0) {
    throw Error(`num: ${num},只能为大于 0 的值计算 interval`)
  }

  const { digit, pow } = parseDigitAndPow(num)
  const interval = parseInterval(digit)
  const realInterval = interval * Math.pow(10, pow)
  return formatFloat(realInterval)
}

function toInt (n) {
  if (n > 0) {
    return Math.ceil(n) + 1
  } else {
    return Math.floor(n) - 1
  }
}

// 双 y 轴时为了分隔线对齐,计算 interval 和 max
// num1 和 num2 分别是两组数据的最大值
function calculateYMaxAndIntervals (arr) {
  let [{ max: max1, min: min1 }, { max: max2, min: min2 }] = arr
  max1 = toInt(max1)
  min1 = toInt(min1)
  max2 = toInt(max2)
  min2 = toInt(min2)
  let num1 = max1 - min1
  let num2 = max2 - min2
  // 都为 0 时 返回 echarts 本身的默认值
  if (!num1 && !num2) {
    return [
      { interval: 0.2, max: 1 },
      { interval: 0.2, max: 1 }
    ]
  }

  // 如果有一个为 0 ,那么就设为另外一个的值,也就是直接按有值的去计算
  if (!num1) {
    num1 = num2
  }
  if (!num2) {
    num2 = num1
  }

  // 从 份数为 4 5 6 中取最好的情况
  const splitNumbers = [4, 5, 6]

  // 根据切分的份数计算出对应的 interval
  const intervals = splitNumbers.map((n) => {
    const num1Avg = num1 / n
    const num2Avg = num2 / n
    const interval1 = findInterval(num1Avg)
    const interval2 = findInterval(num2Avg)
    const percent = (num1Avg / interval1 + num2Avg / interval2) / 2
    return { interval1, interval2, percent }
  })

  // 看哪种情况实际的平均值最接近 interval
  const percents = intervals.map((x) => x.percent)
  const maxPercent = Math.max(...percents)
  const index = percents.findIndex((x) => x === maxPercent)

  const bestSplitNumber = splitNumbers[index]
  const bestInterval1 = intervals[index].interval1
  const bestInterval2 = intervals[index].interval2

  return [
    {
      interval: bestInterval1,
      max: max1,
      min: max1 - formatFloat(bestInterval1 * bestSplitNumber)
    },
    {
      interval: bestInterval2,
      max: max2,
      min: max2 - formatFloat(bestInterval2 * bestSplitNumber)
    }
  ]
}

console.log(calculateYMaxAndIntervals([{
  max: 20.3,
  min: -2
}, {
  max: 135.6,
  min: 2
}]))

小改了一版,记录一下

在两位大佬的基础上小改了一版,记录一下:

// 把值拆解为一位数和10的多少次方的乘积
// 比如 120 = 1.2 * Math.pow(10, 2)
const parseDigitAndPow = (num) => {
  let pow = 0
  while (num < 1) {
    num = num * 10
    pow = pow - 1
  }
  while (num >= 10) {
    num = num / 10
    pow = pow + 1
  }
  return { digit: num, pow }
}

// 根据一位数拿到对应的 interval
// 第一个大于等于它的就是最合适的 interval
const parseInterval = (digit) => {
  const intervals = [1, 2, 3, 5, 10]
  for (let interval of intervals) {
    if (interval >= digit) {
      return interval
    }
  }
  throw Error(`digit: ${digit},没有找到合法 interval `)
}

// 把浮点数运算引起的不准确问题格式化一下(对于太小的数这个方法就不准确了)
const formatFloat = (num) => {
  return Number(num.toFixed(8))
}

// 找到大于等于 num 的最佳 interval
const findInterval = (num) => {
  if (num <= 0) {
    throw Error(`num: ${num},只能为大于 0 的值计算 interval`)
  }

  const { digit, pow } = parseDigitAndPow(num)
  const interval = parseInterval(digit)
  const realInterval = interval * Math.pow(10, pow)
  return formatFloat(realInterval)
}

function toInt (n) {
  if (n > 0) {
    return Math.ceil(n) + 1
  } else {
    return Math.floor(n) - 1
  }
}

// 双 y 轴时为了分隔线对齐,计算 interval 和 max
// num1 和 num2 分别是两组数据的最大值
function calculateYMaxAndIntervals(max1 = 1, max2 = 1, rmin1 = 0, rmin2 = 0) {
  let min1 = rmin1
  let min2 = rmin2
  // 用toInt凑整
  max1 = toInt(max1)
  max2 = toInt(max2)
  min1 = toInt(min1)
  min2 = toInt(min2)
  let num1 = max1 - min1
  let num2 = max2 - min2
  // 都为 0 时 返回 echarts 本身的默认值
  if (!num1 && !num2) {
    return [
      { interval: 0.2, max: 1 },
      { interval: 0.2, max: 1 },
    ]
  }

  // 如果有一个为 0 ,那么就设为另外一个的值,也就是直接按有值的去计算
  if (!num1) {
    num1 = num2
  }
  if (!num2) {
    num2 = num1
  }

  // 从 份数为 4 5 6 中取最好的情况
  const splitNumbers = [4, 5, 6, 7,8,9]

  // 根据切分的份数计算出对应的 interval
  const intervals = splitNumbers.map((n) => {
    const num1Avg = num1 / n
    const num2Avg = num2 / n
    const interval1 = findInterval(num1Avg)
    const interval2 = findInterval(num2Avg)
    const percent = (num1Avg / interval1 + num2Avg / interval2) / 2
    return { interval1, interval2, percent }
  })

  // 看哪种情况实际的平均值最接近 interval
  const percents = intervals.map((x) => x.percent)
  const maxPercent = Math.max(...percents)
  const index = percents.findIndex((x) => x === maxPercent)

  const bestSplitNumber = splitNumbers[index]
  const bestInterval1 = intervals[index].interval1
  const bestInterval2 = intervals[index].interval2

  // 若两组数据的范围都>=0,则两个坐标系的最小值都取0
  if (rmin1 >= 0 && rmin2 >= 0) {
    return [
      {
        interval: bestInterval1,
        max: formatFloat(bestInterval1 * bestSplitNumber),
        min: 0,
      },
      {
        interval: bestInterval2,
        max: formatFloat(bestInterval2 * bestSplitNumber),
        min: 0,
      },
    ]
  }

  // 以下是对于两组数据中至少有一组的最小值<0时的处理,处理目标:
  /**
   * 1. 两组数据所分的段数相同
   * 2. 两组数据的最大值距离0刻度的段数跨度相同
   * 3. 两组数据的最小值距离0刻度的段数跨度相同
   * 
   * 上面3点就可以保证两个y轴的刻度splitline能对齐,且0坐标刻度也能对齐
   * 以下的计算以上面得出的bestInterval1和bestInterveral2为基准
   */

  /**
   * 因为上面的max1是向上凑整得到的,不一定是bestInterval1的整数倍,
   * i. 因此当max1>0时,我们以bestInterval1的整数倍去递增finalMax1,第一个>=max1的值,
   * 就是我们要的finalMax1,finalMax1表示最终确定下来的max1
   * ii. 当max1==0时,finalMax1就是0,不用处理
   * iii. 当max1<0时,我们以bestInterval1的整数倍去递减finalMax1,最接近 max1 且大于等于max1
   * 的值,就是我们要的finalMax1
   */
  let finalMax1 = 0
  if (max1 > 0) {
    while (finalMax1 < max1) {
      finalMax1 += bestInterval1
    }
  } else if (max1 < 0) {
    while(finalMax1 > max1) {
      finalMax1 -= bestInterval1
    }
    if (finalMax1 < max1) {finalMax1 += bestInterval1}
  }
  /**
   * min1也是向下凑整得到的,不一定是bestInterval1的整数倍,
   * i. 当min1>=0时,finalMin1就是0,不用处理
   * ii. 当min1<0时,我们以bestInterval1的整数倍去递减finalMin1,第一个<min1的值,
   * 就是我们要的finalMin1
   */
  let finalMin1 = 0
  if (min1 < 0) {
    while(finalMin1 > min1) {
      finalMin1 -= bestInterval1
    }
  }
  
  /**
   * 同finalMax1一样处理finalMax2
   */
  let finalMax2 = 0
  if (max2 > 0) {
    while (finalMax2 < max2) {
      finalMax2 += bestInterval2
    }
  } else if (max2 < 0) {
    while(finalMax2 > max2) {
      finalMax2 -= bestInterval2
    }
    if (finalMax2 < max2) {finalMax2 += bestInterval2}
  }
  /**
   * 同finalMin1一样处理finalMin2
   */
  let finalMin2 = 0
  if (min2 < 0) {
    while(finalMin2 > min2) {
      finalMin2 -= bestInterval2
    }
  }
  // 开始校准
  /**
   * 经过上面的处理我们得到的finalMax1,finalMin1,finalMax2,finalMin2都分别是自己的bestInterval
   * 的整数倍,但是并不能保证finalMin1~finalMax1之间的段数,就和finalMin2~finalMax2之间的段数一样,如果
   * 段数不一样,则两组数据的刻度线就不会对齐。
   * 而且当finalMin1~finalMax1或者finalMin2~finalMax2或者二者同时出现跨越0刻度线时(也就是数据有正有负),
   * 也不能保证finalMin1和finalMin2距离各自0刻度的段数一样,finalMax1和finalMax2距离各自0刻度的段数也不一定一样,这样
   * 就不能保证两组数据的0刻度线对齐。
   * 因此我们要继续处理finalMin1和finalMin2, finalMax1和finalMax2,目标是满足这两个要求
   */
  /**
   * i. 若finalMin1和finalMin2都小于0,则比如finalMin1距离0刻度的段数更多,就把finalMin2以bestInterval2的倍数往下递减,
   * 直到两者距离0刻度的段数一样
   * ii. 若finalMin1或者finalMin2小于0,而另一个finalMin等于0(finalMin最大为0),则把这个finalMin以其bestInterval的倍数往下递减,
   * 直到两者距离0刻度的段数一样
   */
  if (finalMin1 < 0 && finalMin2 < 0) {
    let seg1 = Math.ceil(-finalMin1 / bestInterval1)
    let seg2 = Math.ceil(-finalMin2 / bestInterval2)
    if (seg1 < seg2) {
      const diff = seg2 - seg1
      finalMin1 -= bestInterval1 * diff
    }
    if (seg2 < seg1) {
      const diff = seg1 - seg2
      finalMin2 -= bestInterval2 * diff
    }
  } else if (finalMin1 < 0) {
    let diff = Math.ceil(-finalMin1 / bestInterval1)
    finalMin2 -= diff * bestInterval2
  } else if (finalMin2 < 0) {
    let diff = Math.ceil(-finalMin2 / bestInterval2)
    finalMin1 -= diff * bestInterval1
  }

  /**
   * i. 若finalMax1和finalMax2都>=0,则比如finalMax1距离0刻度的段数更多,就把finalMax2以bestInterval2的倍数往上递增,
   * 直到两者距离0刻度的段数一样
   * ii. 若其中某个finalMax < 0,则把该finalMax设成其bestInterval的倍数,以使得两者距离0刻度的段数一样
   * iii. 若两个finalMax都 < 0,则说明两组数据的范围都在0刻度以下,则不牵扯0刻度线对齐的问题,只要两者划分的段数相同即可,
   * 因此比较两者的段数seg1和seg2,假如seg1>seg2,则把finalMin2以bestInterval2的倍数往下递减,以使得两者的段数相等。这里也可以
   * 把finalMax2以bestInterval2的倍数往上递增,但是这样有可能触碰到0刻度线,一旦碰到0或者跨越0,就得把finalMax1也往上递增,太麻烦。
   * seg1<seg2 也是同理。
   */
  if (finalMax1 >= 0 && finalMax2 >= 0) {
    let seg1 = Math.ceil(finalMax1 / bestInterval1)
    let seg2 = Math.ceil(finalMax2 / bestInterval2)
    if (seg1 < seg2) {
      const diff = seg2 - seg1
      finalMax1 += diff * bestInterval1
    }
    if (seg2 < seg1) {
      const diff = seg1 - seg2
      finalMax2 += diff * bestInterval2
    }
  } else if (finalMax1 >= 0) {
    let diff = Math.ceil(finalMax1 / bestInterval1)
    finalMax2 = diff * bestInterval2 
  } else if (finalMax2 >= 0) {
    let diff = Math.ceil(finalMax2 / bestInterval2)
    finalMax1 = diff * bestInterval1
  } else  {
    // finalMax1 < 0 && finalMax2 < 0
    let seg1 = (finalMax1 - finalMin1) / bestInterval1
    let seg2 = (finalMax2 - finalMin2) / bestInterval2
    if (seg1 > seg2) {
      const diff = seg1 - seg2
      finalMin2 -= diff * bestInterval2
    } else if (seg1 < seg2) {
      const diff = seg2 - seg1
      finalMin1 -= diff * bestInterval1
    }
  }

  return [
    {
      interval: bestInterval1,
      max: finalMax1,
      min: finalMin1
    },
    {
      interval: bestInterval2,
      max: finalMax2,
      min: finalMin2
    },
  ]
}

export default function calMaxAndMin(data1, data2) {
  //分别找出双y轴的最大最小值
  // let max1 = Math.max(1, ...data1) || 1;
  let max1 = data1.length > 0 ? Math.max(...data1, 1) : 1;
  let min1 = data1.length > 0 ? Math.min(...data1, 0) : 0;
  // let max2 = Math.max(1, ...data2) || 1;
  let max2 = data2.length > 0 ? Math.max(...data2, 1) : 1;
  let min2 = data2.length > 0 ? Math.min(...data2, 0) : 0;
  const yMaxAndIntervals = calculateYMaxAndIntervals(max1, max2, min1, min2) 
  return {
    y1Max: yMaxAndIntervals[0].max,
    y2Max: yMaxAndIntervals[1].max,
    y1Min: yMaxAndIntervals[0].min,
    y2Min: yMaxAndIntervals[1].min,
    y1Interval: yMaxAndIntervals[0].interval, 
    y2Interval: yMaxAndIntervals[1].interval, 
  }
}