直角坐标系图表-坐标轴刻度取值问题

1,210 阅读5分钟

直角坐标系下坐标系数据分布区域优化

​ 坐标轴是直角坐标系类型图表十分重要的组成部分。坐标系中横纵坐标轴以及刻度的作用下会产生一个数据空间。一般来说数据空间会根据数据的分布确定,数据应当合理地分布在数据空间中,不至于使数据数据空间有过多的空白。好的数据空间应当至少满足以下两个条件:

  • 尽可能精确地拟合数据的分布,不至于浪费显示面积,也不应该使数据超出坐标轴显示。
  • 数据空间应当根据数据或者图形本身的特点,体现数据的信息。

在这两个基本原则的基础上,可以衍生许多不同的价值取向。例如柱状图,矩形的高度代表数值的大小。但不同矩形之间高度比例,体现了不同组数据之间的差值,也是数据中一个很重要的信息。

例如下面三张年份-销量图,图一直观一看不同数据之间的差距很大。 如果1963的新增数据在160以上,在直觉上给人感觉:1959至1962年市场低迷,1962年销量暴涨,同比前一年翻番的感觉,而这个信息其实是不准确的。

所以,如果数据全部分布在x轴同一侧时,y轴绘制的的起点一般都是0(如果比较的基数不是0的情况下另当别论)。如果数据分布在x轴两侧,矩形的绘制起点也会是0。

而折线图,在基础的数值之外,关注的是数据变化的趋势,这同样是数据中蕴含的信息。不恰当的y轴,有可能会使这种信息表达得特别不明显,甚至破坏这种信息,误导看图者。

例如上图,数据分布的范围比较小,直觉上销量数据在170左右波动,没有明显的趋势变化。但是如果经过处理后,将波动的特征放大,会发现数据在整体上,有先上升,后下降的趋势。

所以,在数据原始的分布区间离0特别远的时候,把0作为y轴的起点是不合适的。但是这种情况也并不绝对。数据本身的含义,有可能希望y轴就是从0开始。

坐标轴三要素

确定坐标轴有三个要素:

  • 起点和终点(最大值和最小值)
  • 刻度数
  • 步长(刻度数之间的间距)

在知道最大值和最小值的情况下,刻度数和步长是可以彼此推算出来的。

Step =( max - min ) ÷ TickCount

一般来说,期望的刻度数是容易得出的。太密的刻度其实会让坐标轴显得很拥挤。AntV-G2默认的数量是5,我们就暂时取刻度数TickCount为5。而数据的最大最小值,是可以轻易地通过遍历数据来获得。如果仅仅到这里为止,那么会发现我们已经可以根据每个数据的特点动态确定坐标轴了。但是结果并没有我们想的那么乐观。

例如折线图的一组数据,最大最小值分别为:24,102。那么根据上面的公示算出来的步长step = 15.6,刻度数分别是:

24,39.6,55.2,70.8,86.4,102

很明显,这样直接计算得出来的刻度,可读性很差。所以在这一环节,步长的规范化是关键。

步长规范化

在计算出原始的步长后,我们希望能得出一个近似的,但是规范化的步长,例如10 ,20, 25 这样的整数。可以通过下面的方法进行计算。

const getStandarInterval = (t: number) => {
  if (t <= 0.1) {
    t = 0.1;
  } else if (t <= 0.2) {
    t = 0.2;
  } else if (t <= 0.25) {
    t = 0.25;
  } else if (t <= 0.5) {
    t = 0.5;
  } else if (t < 1) {
    t = 1;
  } else {
    t = getStandarInterval(t / 10) * 10;
  }
  return t;
};
const rawTickInterval: number = (max - min) / tickCount;
// 计算数量级
mag = Math.pow(10, Math.floor(Math.log10(rawTickInterval)));
if (mag == rawTickInterval) {
  mag = rawTickInterval;
} else {
  mag = mag * 10;
}
tickInterval = Number((rawTickInterval / mag).toFixed(6));
//选取规范步长
const stepLen = getStandarInterval(tickInterval);
tickInterval = stepLen * mag;

首先归一化,我们给出一个标准步长的数组[0,1, 0.2, 0.25, 0.5, 1]

然后把计算原始步长的数量级,并且把原始步长归一化。例如15.6 => 0.156,数量级为100

接着把归一化后的步长标准化,0.156 => 0.2。

再将标准化后的步长,恢复到原来的数量级,标准步长stepLen = 0.2*100 = 20

在步长标准化时,逼近的规则不是近似,而是向上取整。因为缩小步长可能会导致在给定的刻度数内无法完整展示数据。

获取标准化的步长后,仅仅确定了三要素中的一个。

反推最大值、最小值和刻度数

获取了标准化步长后,并非万事大吉。如果我们直接使用原始数据的最大最小值进行坐标轴绘制,依然存在一定问题。例如原始数据最小值为3.123,所有的刻度都会带着这样的小数。

一般来说,我们总是0这个点是在刻度中。即最大值最小值应该是步长的整数倍!

在确定坐标轴最大最小值时,我们就有了许多要考虑的要素:

  1. 数据是否全部分布在x轴的同一侧,即是否全部大于0或者小于0。
  2. 如果数据全部在同一侧,x轴是否需要从0开始绘制(最小值/最小值是否强制设为0),柱状图一般需要从0开始。

无论如何,我们可以先确定最小值,最小值应该是一个刚好小于原始数据最小值(或者0),且是步长的整数倍。确定最小值后,从最小值向上累加步长,直到刚好大于原始数据最大值。最大值也会是步长的整数倍。

let tempmin = 0;
if (min < 0) {
  while (tempmin > min) {
    tempmin -= tickInterval;
  }
} else {
  while (tempmin + tickInterval < min) {
    tempmin += tickInterval;
  }
}
min = tempmin;
let tickCount = 1;
while (tickCount * tickInterval + min < max) {
  tickCount++;
}
max = tickCount * tickInterval + min;

这个过程中,顺便确定了真实的tickCount,因为标准化步长时,对步长进行了适当放大,最后刻度数可能会小于给定的刻度数,为了避免图表上面空白过大,会按实际数据占位来确定最后的刻度数。这样就完成了一个简单的根据数据计算标准化刻度的过程。

在这个计算的过程中有很多地方可以需求进行调整,例如如果不介意最后计算得出的刻度数tickCount 大于输入的tickCount,更倾向于稍密集的刻度数,可以在获取标准化步长的过程中将向上取整改为取最近似的标准步长。还可以把标准化步长数组调整得更密集,能更好地逼近原始步长。还有是否从0开始计算等等。

不同分组数据分布差异大

根据上面的方法可以优化一组数据的y轴显示,但是如果图表显示两组数据,且分布的区域差异比较大的时候,一个坐标轴不能兼顾所有数据。这个时候可以设立一个y次轴。两个坐标轴可以分别使用上面的方法进行坐标轴刻度计算,唯一的问题是刻度数是不可控的,有可能两边的计算结果,刻度数不一致,会导致两边的刻度线对不齐。

可以让刻度数小的那个轴延长,使两个轴的刻度数一致,达到较为美观、和谐的显示结果。

完整代码

const getStandarInterval = (t: number) => {
  if (t <= 0.1) {
    t = 0.1;
  } else if (t <= 0.2) {
    t = 0.2;
  } else if (t <= 0.25) {
    t = 0.25;
  } else if (t <= 0.4) {
    t = 0.4;
  } else if (t <= 0.5) {
    t = 0.5;
  } else if (t <= 0.6) {
    t = 0.6;
  } else {
    t = 1;
  }
  return t;
};
/**
 * @param min 数据最小值
 * @param max 数据最大值
 * @param tickInterval 刻度区间
 */
const calcR = (min: number, max: number, tickInterval: number): [number, number, number] => {
  let tempmin = 0;
  if (min < 0) {
    while (tempmin > min) {
      tempmin -= tickInterval;
    }
  } else {
    while (tempmin + tickInterval < min) {
      tempmin += tickInterval;
    }
  }
  min = tempmin;
  let tickCount = 1;
  while (tickCount * tickInterval + min < max) {
    tickCount++;
  }
  max = tickCount * tickInterval + min;
  return [min, max, tickCount];
};
/**
 * 获取符合bizcharts内置逻辑的最大值最小值分布
 * @param list 数据列表
 * @param tickCount 最大刻度数
 * @param startWith0 最大值或者最小值是否从零开始
 */
export function standardRange(
  list: number[],
  tickCount = 5,
  startWith0 = true
): [number, number, number] {
  list = list.map(i => (isNaN(Number(i)) ? 0 : Number(i)));

  const log10 = (n: number) => Math.log(n) / Math.log(10);
  let max = Math.max(...list);
  let min = Math.min(...list);
  // 数据全部分布在x轴同一侧,而且需要从零开始计算
  if (startWith0 && min * max >= 0) {
    min = min > 0 ? 0 : min;
    max = max < 0 ? 0 : max;
  }
  if (max === min) {
    const t = Math.abs(min);
    const mag = t == 0 ? 1 : Math.pow(10, Math.floor(log10(t)));
    return calcR(min, max, mag);
  }

  // 刻度区间长度,和长度数量级
  let tickInterval: number, mag: number;
  const rawTickInterval: number = (max - min) / tickCount;
  // 计算数量级
  mag = Math.pow(10, Math.floor(log10(rawTickInterval)));
  if (mag == rawTickInterval) {
    mag = rawTickInterval;
  } else {
    mag = mag * 10;
  }
  tickInterval = rawTickInterval / mag;

  //选取规范步长
  const stepLen = getStandarInterval(tickInterval);
  tickInterval = stepLen * mag;

  let res = calcR(min, max, tickInterval);

  if (res[2] > tickCount) {
    // 如果最后计算得出的刻度数大于计算值,步长扩大一级
    tickInterval = getStandarInterval(stepLen + 0.1) * mag;
    res = calcR(min, max, tickInterval);
  }
  return res;
}