Day17-D3 的 Scale( ) 比例尺

本篇大纲:Domain & Range 输入域与输出域、Interpolate 插补值、continuous & discrete 连续性与离散性、scale 的五种三类、简单比较

比例尺是 D3 另一个很重要的功能!!因此,今天也会是很长的篇章啦(灿笑摸头),大家请备好零食饮料,我们准备开始罗!

不知道大家有没有看过「搞笑的网购商品」这系列呢?就是大家把自己网路上购买的商品 vs 实际收到货的商品 拍照给大家看,其中有不少的照片长这样
https://ithelp.ithome.com.tw/upload/images/20210929/20134930aQLe6AUvGH.jpg

是不是超级搞笑,这是给小矮人的椅子吗?卖家的图片跟买家实际收到商品有一大段落差,是怎麽回事呢?这是因为卖方并没有附上比例尺去告知这个椅子应该是多大,买家也就自行脑补正常椅子的比例才导致这样的结果。我们的生活中充满各种需要比例尺的时候,像是 google map、商品图都会使用到比例尺。那这跟D3 图表到底有什麽关系呢?别急,让我们继续看下去

SVG 的 viewport 视窗范围

之前在svg的篇章中我们有说过,svg 理论上是无限大的,它所设定的 width 跟 height 其实只是 viewport (视窗范围)。所以,即使我们的数据资料超过 svg 的 viewport,它一样会存在跟绘制,只是我们看不到而已。就拿以下的范例来说明:

画面上有个 svg ,它的视窗范围:高100 X 宽500

  • 当我们的线条长度为300,比 viewport 小:
    https://ithelp.ithome.com.tw/upload/images/20210929/201349302kLQHWtHMK.jpg

  • 当我们的线条长度为600,比 viewport 大:虚线的部分就是仍然存在但我们看不到的地方
    https://ithelp.ithome.com.tw/upload/images/20210929/20134930jZtlj5k3Z6.jpg

由此可知,我们的资料需要在 svg 的 viewport 范围内,我们才能完整看到所有的资讯,但是我们拿到的资料数据不可能这麽完美的符合svg的可视范围呀,怎麽办?这时就要派出 D3 的 scale 上场啦!其实 d3.scale 要做的事情很单纯,就是比例换算,但比例换算也牵扯到非常多的细节,我们接着就来仔细的看一下吧!


Scale 在干嘛?

D3 的 scale 方法是将资料(通常为阵列)转换成视觉变量(visual variables),例如:位置、长度、颜色等等,这样一来我们才能使用视觉变量把资料视觉化。举例而言,scale 可以把资料转换成以下几种视觉变量:

  • 转换成长度 ⇒ 以供长条图来设定长度
  • 转换成位置 ⇒ 以供折线图来设定位置
  • 把百分比资料转换成连续数值 ⇒ 以供设定颜色的范围
  • 把时间资料转换成位置 ⇒ 以供轴线使用

Domain & Range 输入域与输出域

要讲 scale 之前,我们要先来说说 domain & range (输入域与输出域)的概念。刚刚提到 scale 是在进行比例的换算,那既然是「换算」的话,那就要能产生换算前跟换算後的数值吧?这边的 Domain 跟 Range 就是在处理这个概念

  • Domain 输入域是在进行比例尺换算前,资料的整个数值范围
  • Range 输出域则是进行比例尺换算後,得到换算之後的资料数值范围

https://ithelp.ithome.com.tw/upload/images/20210929/20134930KnDKWrXp8I.jpg

一般来说,我们会将A范围换算到B范围的概念称为映射。举例来说,我们设定输入域是 [0~100] 的范围,输出域是 [0~10] 的范围,设定好这个范围後当我们输入数据50时,透过比例尺的换算,最後就会得到5的换算值

// 输入与输出比例换算范例
const convert = d3.scaleLinear()
                  .domain([0, 100])
                  .range([0, 10])

console.log(convert(50)); // 5,换算输出比例完成

由於输入域跟输出域是比例尺必备的基本要素,因此每个比例尺的 API 旗下都会有 .domain( ) 跟 .range( )的方法。但看到这边你有没有觉得有点疑惑呢?为什麽设定输入域跟输出域的范围後,我们随便输入的数值(但要在输入域内)就能被换算成对应的输出值呢?这是因为在比例尺的运作中,还使用了另一种重要方法:interpolate 插补值

Interpolate 插补器与插补值

插补是D3的一个蛮重要的应用,但若要详细解说的话恐怕要另开一个长篇来讲,因此我们这边只会简单讲解它的原理与运作方式,有兴趣了解更多的人可以自行上官方文件查看。

插补器说白了就是在两个值之间平顺的插入一些值,这个应用十分广泛,例如:建立一个平顺的动画效果、设定一个渐层的颜色梯度,这些都是应用了插补的原理。而且 D3 的插补不仅可以应用在数值之间,还可以用在「日期、颜色、字串」等等各种资料型态间,是个非常神奇的功能。

对於插补器我们目前只要了解到这样就行了,虽然 D3.scale的底层运作使用插补器,但我们并不需要完全了解这个运作也能顺利地使用scale,因为困难的地方 D3 都帮我们做掉了XD。大致了解插补器在做什麽之後,我们还要知道另一个重要的小知识:连续值与离散值

continuous & discrete 连续性与离散性

连续性跟离散性是我们在使用scale 时也需要了解的一个观念,它直接牵涉到 scale 的分类以及映射方式。先前我们提到 domain 输入域与 range 输出域的概念,连续性与离散性指的便是 domain 输入域与 range 输出域的映射方式

  • continuous 连续性:指的是资料之间具备关联的特性,可以用某些运算方式找出彼此的关联,这类资料通常为数字、日期等等
  • discrete 离散性:指的是则是资料之间并没有任何关联,无法用任何运算方式找出彼此的关联,这类资料通常为字串

将连续性或离散性搭配输入域与输出域的概念,就可以得出四种结果

  • continuous input 输入的资料是连续性资料
  • continuous output 输出的资料是连续性资料
  • discrete input 输入的资料是离散性资料
  • discrete output 输出的资料是离散性资料

上面四种输入输出资料的搭配组合,就成了 scale 的主要分类依据,接着来看D3总共提供哪些比例尺的方法吧!


D3 scale 比例尺分类

D3 的官方文件将比例尺分成五种,分别是

  • Continuous Scale 连续性比例尺
  • Sequential Scale 序列比例尺
  • Diverging Scale 发散比例尺
  • Quantize Scale 量化比例尺
  • Ordinal Scale 次序/序位比例尺

但若按照输入与输出的资料来分类,这五类比例尺又可以被归纳为三大类:

  • 「连续性资料输入」与「连续性资料输出」的比例尺
    包含 Continuous Scale、Sequential Scale、Diverging Scale

  • 「连续性资料输入」与「离散性资料输出」的比例尺
    包含Quantize Scale

  • 「离散性资料输出」与「离散性资料输出」的比例尺
    包含 Ordinal Scale

以下就让我们按照这三大类别来看看 D3 scale 的特色吧!


◆ 「连续性资料输入」与「连续性资料输出」的比例尺

这一类的比例尺都是将一组连续性的资料,映射到另一个连续性的资料中,并依此对照去进行数值转换。这一类比例尺又可以被细分成三小类:

Continuous Scale 连续性比例尺

连续性比例尺指的是资料可以被以某种运算方式找到关联,像是月份的数值递增、数字与数字间可以透过加减乘除找到规律等等,这些就叫做连续性比例尺;非连续性比例尺则是资料间无法透过运算找出关联,例如:男女分类、喜欢的宠物(猫、狗、鱼、兔)等等。

根据官方文件的叙述,连续性比例尺可以把连续的、定量的 domain (输入域) 映射到连续的 range (输出域)。而且如果输出范围也是数值,这个映射关系还可以被反转 (使用 continuous.intert 方法),意思就是我们可以透过反推输出的值去找到输入的值。除了反转之外,连续性比例尺还有这些不同的 API 可以进行相关设定,比较常用的设定我们晚点也会介绍
https://ithelp.ithome.com.tw/upload/images/20210929/20134930Dr3QcptiKR.jpg

不过连续性比例尺只是大分类,不能直接使用,我们要使用它旗下的比例尺方法来进行操作,它旗下的方法包含:

  • scaleLinear 线性比例尺
  • scalePow 幂比例尺
  • scaleLog 对数比例尺
  • scaleIdentity 恒等比例尺
  • scaleRadial 放射比例尺
  • scaleTime 时间比例尺

虽然有这麽多比例尺,但我们比较常用到的只有 scaleLinear 跟 scaleTime 比例尺,我们接下来就仔细的讲解一下吧!

★ d3.scaleLinear

线性比例尺是画图表时最常用到的比例尺,它最适合将资料转换成位置或长度,通常会用在绘制折线图。
https://ithelp.ithome.com.tw/upload/images/20210929/20134930NFZFRJ5vhx.png

线性比例尺的 domain 跟 range 都必须是连续性资料,而且由於是连续性资料,因此可以用阵列带入最小值与最大值即可,d3.scaleLinear 就会搭配 d3.Interpolate 去自动计算要输出的数值

let linearScale = d3.scaleLinear()
                  .domain([0, 100])
                  .range([0, 50]);

linearScale(0);   // return 0
linearScale(50);   // returns 25
linearScale(100);  // returns 50

除了转换长度跟位置之外,线性比例尺也可以用来换算颜色的色度

const colorScale = d3.scaleLinear()
                    .domain([0, 10])
                    .range(['yellow', 'red']);

colorScale(0);   // returns "rgb(255, 255, 0)"
colorScale(5);   // returns "rgb(255, 128, 0)"
colorScale(10);  // returns "rgb(255, 0, 0)"

★ d3.scaleTime

时间比例尺主要是用来换算日期、时间等等资料的方法,它的用法跟线性比例尺很类似,但不同的是时间比例尺的 domain 输入域必需输入日期阵列

timeScale = d3.scaleTime()
              .domain([new Date(2021, 0, 1), new Date(2022, 0, 1)])
              .range([0, 700]);

timeScale(new Date(2021, 0, 1));   // returns 0
timeScale(new Date(2021, 6, 1));   // returns 348.00...
timeScale(new Date(2022, 0, 1));   // returns 700

看完连续性比例尺的两个方法後,接着我们来讲讲先前提到的细节设定吧

  • continuous.clamp( ) 截断

    我们了解 domain 跟 range的概念,也知道输入domain范围内的数字,就能够被换算成相对应的range数值,但其实如果我们输入超出domain范围的数值也一样能被换算。

    let linearScale = d3.scaleLinear()
      .domain([0, 10])
      .range([0, 100]);
    
    linearScale(20);  // returns 200
    linearScale(-10); // returns -100
    

    如果我们不希望超出domain范围的数值被换算,就可以使用 continuous.clamp( ) 这个方法,这个方法会将超过的数值直接换成domain 范围的极端值

    let linearScale = d3.scaleLinear()
      .domain([0, 10])
      .range([0, 100])
      .clamp(true) // 斩断锁链~~~
    
    linearScale(20);  // returns 100
    linearScale(-10); // returns 0
    
  • continuous.nice( )

    这个 API 是用来延展 domain 的值,让 domain 的起始值跟终止值变成比较漂亮的数值。有时候我们的domain范围会直接从後端给的资料抓,但资料不一定是漂亮的数值,这样反应到轴线上时可能就会让轴线不那麽漂亮。

    • 要注意的是,这个方法只能用在scale上,而且也只会将数字延展成最接近的完整数值
    let data = [0.243, 0.584, 0.987, 0.153, 0.433];
    let extent = d3.extent(data);
    
    let linearScale = d3.scaleLinear()
      .domain(extent)
      .range([0, 100]);
    

    画出来的轴线是这样,由於起始值跟终点值不在X轴线可以设定的范围内,因此X轴的前後就没有值 (有关轴线的建立下一篇会讲解)

    https://ithelp.ithome.com.tw/upload/images/20210929/20134930lkHkkzVygi.jpg

    这时我们就可以使用 .nice( ) 这个方法让起始值跟终点值变成漂亮的数值

    let data = [0.243, 0.584, 0.987, 0.153, 0.433];
    let extent = d3.extent(data);
    
    let linearScale = d3.scaleLinear()
      .domain(extent)
      .range([0, 100])
      .nice()
    

    https://ithelp.ithome.com.tw/upload/images/20210929/20134930flcD4P0Zf8.jpg

  • continuous.invert( ) 反推转换

.invert( ) 这个方法把range的数值换算成domain的数值,通常用在轴线刻度的text显示,这边等到轴线的篇章会更仔细讲解,目前只要知道它怎麽用就好

let linearScale = d3.scaleLinear()
          .domain([0, 10])
          .range([0, 100]);

linearScale.invert(50);   // returns 5
linearScale.invert(100);  // returns 10

Sequential Scale 序列比例尺

序列比例尺与连续性比例尺和发散比例尺很类似,一样是将连续数值输入域映射到连续数值的输出域。但跟连续性比例尺不同的是: sequential scales 的输出域是根据指定的内建插补器来进行设定,而且输出域不可更动、插补方式也不可更动。举例来说

let sequentialScale = d3.scaleSequential()
  .domain([0, 100])
  .interpolator(d3.interpolateRainbow);

这个例子中,我们设定了domain,但range的部分变成用 d3.interpolator( ) 取代,而且参数带入d3内建好的 d3.interpolateRainbow 方法用来建立彩虹的色阶,我们不可以自己任意改变成 range(...)

let sequentialScale = d3.scaleSequential()
				  .domain([0, 100])
				  .interpolator(d3.interpolateRainbow);

sequentialScale(0);   // returns 'rgb(110, 64, 170)'
sequentialScale(50);  // returns 'rgb(175, 240, 91)'
sequentialScale(100); // returns 'rgb(110, 64, 170)'

D3内建好的颜色插补器除了interpolatorRainbow 之外,还有许多其他不同的方法
https://ithelp.ithome.com.tw/upload/images/20210929/20134930dUl8VaCI5a.jpg

有兴趣的可以到 d3-scale-chromatic的官方文件来查看~这边就不多做说明了

Diverging Scale 发散比例尺

发散比例尺是将一个「连续性、定量的输入资料」转换成「连续性、固定的插补器」,不过这个方法我自己到目前为止还没有使用过~

const spectral = d3.scaleDiverging(d3.interpolateSpectral);

◆ 「连续性资料输入」与「离散性资料输出」的比例尺

这一类的比例尺是将一组连续性的资料,映射到另一组离散性的资料中,并依此对照去进行转换。这一类的比例尺被称为「量化比例尺」

Quantize Scale 量化比例尺

量化比例尺包含以下几个比例尺

  • scaleQuantize 量化比例尺
  • scaleQuantile 分位数比例尺
  • scaleThreshold 阈值(临界值)比例尺

这些API 中比较常用到的是 scaleQuantize 量化比例尺 ,下面我们就来简单介绍一下

★ d3.scaleQuantize

这个方法使接收一组连续性的数值,并映射到一组离散性的数值中,接着根据离散性数值的数量把连续性数值分成不同区段,再将输入的数值映射到相对应的区段数值,举例来说:

let quantizeScale = d3.scaleQuantize()
		  .domain([0, 100])
		  .range(['lightblue', 'orange', 'lightgreen', 'red']);

此处用 scaleQuantize 的方法,会把 [0-100] 的范围根据range资料切段

— 0-24 ⇒ lightblue
— 25-49 ⇒ orange
— 50-74 ⇒ lightgreen
— 75-100 ⇒ red

我们输入的数值就会根据这个区段去照到对应的值

quantizeScale(10);  // returns 'lightblue'
quantizeScale(30);  // returns 'orange'
quantizeScale(90);  // returns 'red'

◆ 「离散性资料输入」与「离散性资料输出」的比例尺

这类比例尺跟连续性比例尺都很常被使用,也很常拿来互相做比较。这一类的比例尺是将一组离散性的资料,映射到另一组离散性的资料中,并依此对照去进行转换。由於输入与输出的均是离散性资料,而离散性资料之间是没有相关联的,因此使用这一类的比例尺时,一定要把要换算的资料一对一搭配好,否则未搭配到的资料就没有办法转换。这一类的比例尺被称为「次序/序位比例尺」

Ordinal Scale 次序/序位比例尺

次序/序位比例尺又包含以下三种比例尺API:

  • scaleOrdinal 次序比例尺
  • scaleBand 区段比例尺
  • scalePoint 点比例尺

我们来一一说明一下这些API的使用方法

★ d3.scaleOrdinal 次序比例尺

次序比例尺会遍历输入的离散性资料 (必须是阵列),并一一映射到输出的离散性资料(也必须是阵列)。由於数值中没有关联性,因此必须将所有要对应的资料都一一列出,如果输入域的资料比输出域多的话,输出域的资料阵列会从头重复运算

let myData = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']

let ordinalScale = d3.scaleOrdinal()
  .domain(myData)
  .range(['black', 'red', 'green']);

ordinalScale('Jan');  // returns 'black';
ordinalScale('Feb');  // returns 'red';
ordinalScale('Mar');  // returns 'green';
ordinalScale('Apr');  // returns 'black'; range 从头重复一次

如果输入的数值不在domain输入域资料内的话,会自动被加进domain中

ordinalScale('Monday');  // returns 'black';

★ d3.scaleBand 区段比例尺

这个方法最常用来绘制长条图表,它不仅能用来建立长条状几何图形,也会将图形间的间距 (padding) 考虑进去。scaleBand 会将输入域的资料传换成输出域的区段
https://ithelp.ithome.com.tw/upload/images/20210929/20134930uAyMHo0BJI.png

domain输入域的资料必须是阵列,阵列中的每笔资料代表一条长条图;range 输出域则定义图表范围的最小与最大值 (ex: 整张图表的宽度)

let bandScale = d3.scaleBand()
  .domain(['狗', '猫', '天竺鼠', '乌龟', '海豚']) // 共有五条长条图
  .range([0, 200]); // 整张图表的范围

scaleBand 这个方法会将 range 根据 domain 的数量去切分区段,然後根据这个区段的资料去计算长条图的位置与宽度

bandScale('狗'); // returns 0
bandScale('猫'); // returns 40
bandScale('海豚'); // returns 160

scaleBand 也提供了一些细节设定的API,让我们能依此去设定长条图的宽度、间距等等
https://ithelp.ithome.com.tw/upload/images/20210929/20134930VayBvLSnzB.jpg

如果要设定长条图的宽度,我们会使用 .bandwidth( ) 这个方法

bandScale.bandwidth();  // returns 40

要设定长条图间的间距则有以下两个 API

  • .paddingInner( ) 每条长条图之间的距离
  • .paddingOuter( ) 第一条长条图跟最後一条长条图的距离

https://ithelp.ithome.com.tw/upload/images/20210929/20134930AveNeNgZXu.jpg

★ d3.scalePoint 点比例尺

点比例尺跟区段比例尺很类似,但差别在於点比例尺是换算的是点的位置,区段比例尺则是换算区段的范围
https://ithelp.ithome.com.tw/upload/images/20210929/20134930cgeXnjhZms.jpg

也因为两个方法换算的方式不同,因此取出来的质也会有所差异

scaleBand

let bandScale = d3.scaleBand()
  .domain(['狗', '猫', '天竺鼠', '乌龟', '海豚'])
  .range([0, 200]);

bandScale('狗'); // returns 0
bandScale('猫'); // returns 40
bandScale('海豚'); // returns 160

scalePoint

let pointScale = d3.scalePoint()
	  .domain(['狗', '猫', '天竺鼠', '乌龟', '海豚'])
	  .range([0, 200]);

pointScale('狗');  // returns 0
pointScale('猫');  // returns 50
pointScale('海豚');  // returns 200

这样有看出两者的差异了吗~了解 scalePoint 的用法後,我们也来看看它旗下的API,并且来进行一些相关细节设定吧

https://ithelp.ithome.com.tw/upload/images/20210929/20134930hviekLtffG.jpg

  • point.step( )

    这个方法使用来求取两个point之间的距离

    let pointScale = d3.scalePoint()
    	  .domain(['狗', '猫', '天竺鼠', '乌龟', '海豚'])
    	  .range([0, 200]);
    
    pointScale.step();  // returns 50 ,每个点之间的距离
    
  • point.padding( )

    这个方法是用来设定第一个点跟最後一个点分别对外的距离

    let pointScale = d3.scalePoint()
    	  .domain(['狗', '猫', '天竺鼠', '乌龟', '海豚'])
    	  .range([0, 200])
        .padding(3)
    

https://ithelp.ithome.com.tw/upload/images/20210929/20134930hZ4I0xvgqH.jpg


简单比较

看完以上的分类有没有很崩溃?我只是想画图表啊!为什麽要把比例尺搞得这麽复杂?别紧张别担心,画图表时比较常用的比例尺其实也只有 Continuous ScaleOrdinal Scale 而已。我们最後再来简单比较一下这两种比例尺

https://ithelp.ithome.com.tw/upload/images/20210929/20134930NNErBbbnum.jpg

连续性比例尺:连续性的比例尺,适用於连续性质的资料,举例来说:时间、数值;折线图
非连续性比例尺:非连续性的比例尺,适用於非连续性质的资料,举例来说:性别分为男、女;长条图

以上!终於讲完了我的天,scale 真的有许多小细节要注意,而且它的运作也相对复杂不少,但如果掌握好它的使用方法,画起图表来真的事半功倍!


Github Page 图表与 Github 程序码

最後附上本章的程序码与图表 GithubGithub Page,需要的人请自行取用~


<<:  [D14] 卷积 Convolution

>>:  Unity与Photon的新手相遇旅途 | Day14-生成敌人

Day28 小乌龟自动掘井挖隧道

介绍过 CC: Tweaked Turtle 的特性和指令後 今天来直接看看,怎麽让 Mining ...

# Day 12 Cache and TLB Flushing Under Linux (四)

Cache and TLB Flushing Under Linux 的最後一部份,一样文件! 文件...

从零开始的8-bit迷宫探险【Level 9】与 SpriteKit 的初次见面 (二)

今日目标 在 SKScene 中加入节点 (Node) 认识座标系统 SKNode SKNode 是...

资料结构的重要性

在程序入门的讨论社团中有一种类型的年经文,像是: 资料结构到底重不重要? 不会资料结构可以写程序吗?...

Day 23 - p5的WebGL应用 3D 设定

3D场景的基础 基础的要素:物体、光源、材质与摄影机 基础几何形状 平面 plane() 长方体 b...