d3中的axis.ticks详解

d3中有一个和坐标轴相关的方法,即axis.ticks().但是它的用法让人琢磨不透,本文就试图通过一些案例来对其进行详细的解释。

axis.ticks()用来设定分段数量

当通过比例尺创建一个y坐标轴的时候,可能你只想让你的y轴坐标刻度只显示两个,即只有收尾,怎么办?把ticks()参数设为1即可:

let scale = d3.scale.linear()
  .domain([0, 100])
  .range([0, 300])

let axis = d3.svg.axis()
  .scale(scale)
  .orient('left')
  .ticks(1)

设为1表示整个y轴被分为1段,两头都会出现一个刻度,所以就会出现两个刻度。同样的道理,设置为2则会出现两段三个刻度,设置为5则会出现5段6个刻度。

然而,当你设置为3的时候,奇怪的事情发生了。并没有出现3段4个刻度的结果,而是出现了2段3个刻度。这是怎么回事呢?

axis.ticks()的分段规则

之所以3不起作用了,是因为定义域domain中的[0, 100]无法计算得出对应的结果。在这个回答里,mbostock指出,无论你怎么设定,ticks的参数实际上都是在2,5和10这三个中选。

The default ticks for quantitative scales are multiples of 2, 5 and 10. You appear to want multiples of 6; though unusual, this could make sense depending on the nature of the underlying data. So, while you can use axis.ticks to increase or decrease the tick count, it will always return multiples of 2, 5 and 10 — not 6.

(实际上还支持0和1,当超过10之后下文再做详说),所以你传入3实际上相当于传入2,传入4相当于传入5,传入6相当于传入5,传入8相当于传入10.也就是说你传入的值,首先和上面这几个备选参数去比较,越接近哪一个,就用哪个作为值。所以当你传入接近10而距离另一个值更远的值时,就会相当于传入10. 传入1.5时取1,传入1.6时取2.

当超过10之后怎么算?传入大于10之后,就以20,50和100来计算,当然,10还是算数的,传入15相当于传入10,但是传入16就相当于传入20.这个规则跟10以内其实差不多。

axis.ticks()还和定义域相关

上面的演示其实有一个隐含条件,就是我们的定义域规定为[0, 100],也就是说2,5,10的规律是基于100来计算的,如果换成[0, 200], [0, 300]结果又不一样,分段的时候,也不是简单的把轴刻度上的数值改为对应的定义域的值而已。

比如说,如果你的定义域为[0, 200],ticks传入4,那么可以得到分4段5个刻度的轴。这和[0, 100]的结果大不相同,如果按照这个逻辑,100/4=25,为什么不分25为单位的刻度呢?

这里又有一个逻辑,就是分割出来的每一段的值,也必须在1,2,5,10(及其10倍值)中挑选,不能有3,6,8之类的个位值出现。所以分出来的结果中,超出10的,个位值只有0,不会有5,更别提其他值。超出100的,整体上又要遵循这个规律,如此类推下去。

但是正是由于这一规则,导致出现了一个界面上的bug,比如定义域为[0, 300],ticks为2,会出现什么结果?

结果没有在轴末端显示300,而是在2/3处显示了200. 这种情况,对于开发者而言,也是应该注意和尽量避免的。为什么会出现这个情况呢?

当定义域上限为300的时候,分成2段,那么每一段按理应该是150. 但是由于这个值超过了100,所以需要在十位数上进行观察,发现十位数是5,这是不被允许的,单位值超过100的,必须以100及其倍数作为单位值,所以就会被重新计算。按照上文的规则,150按照上文1.5的那种方法去思考,应该使用100作为单位值。

看似去已经遵循规则了,但是并不是这样,当你用100作为单位的时候,300会被分为3段4个刻度,所以ticks的值应该是3才对,而不是2. 我们用代码来实际验证一下:

let scale = d3.scale.linear()
  .domain([0, 300])
  .range([0, 300])

let axis = d3.svg.axis()
  .scale(scale)
  .orient('left')
  .ticks(3)

你会发现,真的被分为了3段4个刻度。这完全打翻了我们上文提到的ticks值只能在2,5,10中挑选的设定。

当定义域为[0, 300]时,ticks值为1和3时,我们都可以非常容易想象出结果,但是为2时d3也无法按照预想结果处理。也就出现了上图的尴尬局面。

axis.ticks()根本不是用来决定分段数量的

通过上面的一系列弯路,你或许已经隐隐察觉,ticks根本不是用来设定分段数量的。真正的分段,是靠每一段的单位来确定的,比如说定义域为[0, 40]想分5段,那么每一段长度应该为8,但是8根本不在2,5,10这个考虑范围内,同样的道理,[0, 700]想分3段也分不出来。

真正的思考逻辑是,用1,2,5及其10倍数作为除数去计算。比如[0, 600],用1,2,5及其10倍数去除,而不要用3,6,9之类的数去除。600/50=12,所以ticks填写12是可行的,600/3=200,所以ticks填写200是不行的。

ticks的值填写什么,根本不是段数决定的,而是除数决定的,先在脑海中想一下除数是否为1,2,5及其10倍数,(不是的话,放心,得不到你想要的效果,)想一下除出来的结果,再填写这个结果

这里的定义域全部都是0开头的,实际上不一定从0开始,比如[300, 600],这个时候被除数应该用300。

如果你一不小心,填写了未经脑海计算的值,会得到什么结果呢?d3会尽可能处理到与你传入的值最接近的结果,但是有的时候就会变成上面那图的最后的效果,会让人摸不着头脑。

d3的代码是怎么处理axis.ticks的?

到底ticks是怎么来处理传入的参数的,还需要通过代码来研究。在d3中ticks的概念是指,从一个值到另一个分割为特定数量的数组:

Returns an array of approximately count + 1 uniformly-spaced, nicely-rounded values between start and stop (inclusive). Each value is a power of ten multiplied by 1, 2 or 5.

译:返回一个start到stop之间(包含stop)的接近count+1个值的数组,这个数组具有均匀的间隔(uniformly-spaced),进行了优雅地(nicely)四舍五入(round)。每个值都是1,2或5乘以10的幂(10x)。

而在axis.ticks上,也必然遵循这一算法。它是以axis的domain作为start和stop,以ticks的值作为count,得到一个数组,这个数组里面的值就一一对应每一个刻度的值。当然它们也遵循10x的规则。

因此,从算法上讲,它根本不涉及我们上文提到的“分多少段”的问题。

ticks的算法源码在这里,核心算法如下:

var e10 = Math.sqrt(50),
    e5 = Math.sqrt(10),
    e2 = Math.sqrt(2);

export default function(start, stop, count) {
  var step = tickStep(start, stop, count);
  return range(
    Math.ceil(start / step) * step,
    Math.floor(stop / step) * step + step / 2, // inclusive
    step
  );
}

export function tickStep(start, stop, count) {
  var step0 = Math.abs(stop - start) / Math.max(0, count),
      step1 = Math.pow(10, Math.floor(Math.log(step0) / Math.LN10)),
      error = step0 / step1;
  if (error >= e10) step1 *= 10;
  else if (error >= e5) step1 *= 5;
  else if (error >= e2) step1 *= 2;
  return stop < start ? -step1 : step1;
}

tickSetp是用来计算step的,也就是我们上文提到的“单位值”,所以可见d3代码内部还是考虑了“分段”问题的。通过tickStep可以获得每一段的值,再用这个值作为step参与range计算。总之,最最核心的,就是tickStep函数,它决定了你传入给ticks的参数,最后实现的效果。

2017-03-12 |