手把手帶你上手D3.js數(shù)據(jù)可視化系列(二)

本系列配套代碼和用到的數(shù)據(jù)都會(huì)開源到這個(gè)倉(cāng)庫(kù),歡迎大家 Star,其他有任何問(wèn)題可以群里交流:https://github.com/DesertsX/d3-tutorial

前言

上一篇文章「手把手帶你上手D3.js數(shù)據(jù)可視化系列(一) - 牛衣古柳 2021.07.30」里古柳介紹了如何添加并設(shè)置 SVG 畫布、添加矩形元素、根據(jù)數(shù)據(jù)集來(lái)添加多個(gè)矩形元素、運(yùn)用取余取整操作調(diào)整布局并換行顯示等內(nèi)容。

文章最后留下一個(gè)疑問(wèn),就是能否基于數(shù)據(jù)集大小和畫布大小來(lái)自動(dòng)計(jì)算出每個(gè)rect的寬高和間距,然后自動(dòng)布局?

正好古柳之前啃大西洋手抄本可視化作品源碼時(shí)看到了相關(guān)實(shí)現(xiàn)方法,這里就和大家分享下。
相關(guān)閱讀:迄今復(fù)現(xiàn)過(guò)最復(fù)雜的可視化作品之「大西洋古抄本」(上) - 牛衣古柳 2021.06.17、迄今復(fù)現(xiàn)過(guò)最復(fù)雜的可視化作品之「大西洋古抄本」(下) - 牛衣古柳 2021.06.22

不過(guò)古柳也沒(méi)有吃透背后的原理,只能盡量寫下自己的理解,而且一來(lái)大家不一定會(huì)用到這個(gè)自動(dòng)布局的方法,二來(lái)真要用到直接 copy 拿走也不是不可以,所以如果這部分最終也沒(méi)搞懂其實(shí)問(wèn)題不大,對(duì)后續(xù)沒(méi)啥影響,放心。下一篇會(huì)回到基礎(chǔ)的 D3.js 數(shù)據(jù)可視化的講解上。

基礎(chǔ)代碼

首先基本代碼結(jié)構(gòu)和上一篇文章類似,有不懂的地方可以回顧下:「手把手帶你上手D3.js數(shù)據(jù)可視化系列(一) - 牛衣古柳 2021.07.30」。

這次 SVG 畫布撐滿網(wǎng)頁(yè)窗口大小,寬度不再是一半大??;并且 dataset 數(shù)據(jù)集設(shè)置大些,即 [0, 1, 2, ..., 99] 共100條數(shù)據(jù),不過(guò)后面會(huì)自動(dòng)基于數(shù)據(jù)量大小計(jì)算布局,所以數(shù)據(jù)多少并不重要;另外 colors 顏色數(shù)組不變,繪制矩形時(shí)仍會(huì)通過(guò)取余數(shù)的方式來(lái)取對(duì)應(yīng)顏色,以后也會(huì)介紹顏色比例尺,將類別屬性進(jìn)行映射到對(duì)應(yīng)顏色,到時(shí)候再說(shuō)。

<body>
    <div id="chart"></div>
    <script src="./d3.js"></script>
    <script>
        function drawChart() {
            const width = window.innerWidth
            const height = window.innerHeight

            const svg = d3.select('#chart')
                .append('svg')
                .attr('width', width)
                .attr('height', height)
                .style('background', '#FEF5E5')

            const dataset = d3.range(100)
            console.log(dataset) // [0, 1, 2, ..., 99]

            const colors = ['#00AEA6', '#DB0047', '#F28F00', '#EB5C36', '#242959', '#2965A7']

            // ....
        }

        drawChart()
    </script>
</body>

自動(dòng)布局之計(jì)算矩形寬度

畫布設(shè)置好后,先來(lái)整體看看大西洋手抄本可視化作品源碼里是如何根據(jù)畫布大小和數(shù)據(jù)多少計(jì)算每個(gè)矩形的寬度 rectWidth 的,由于矩形高度均是寬度的1.5倍,所以無(wú)需另外計(jì)算。(注意:這部分代碼并非完全和源碼里一致,很多變量名等都為了講解方便重新改了下,但邏輯一致、計(jì)算流程相同)

const containerWidth = width
const containerHeight = height
const containerArea = containerWidth * containerHeight

const halfMargin = (containerWidth / 100) * 0.3
const totalMargin = halfMargin * 2

let rectWidth = Math.sqrt(containerArea / (1.5 * dataset.length)) - totalMargin

const columns = containerWidth / (rectWidth + totalMargin)
const rows = dataset.length / columns
const rest = dataset.length % parseInt(columns)

if (rest <= rows) {
    rectWidth = containerWidth / (columns + 1) - totalMargin
} else if (rest > rows) {
    rectWidth = containerWidth / (columns + 2) - totalMargin
}

接下來(lái)拆解代碼,看看都做了哪些事。

畫布容器面積

首先,計(jì)算出畫布容器的面積 containerArea。這里 containerWidthcontainerHeight 分別對(duì)應(yīng) widthheight,似乎多此一舉。但有時(shí)候畫布寬高并不是手動(dòng)設(shè)置的,而是通過(guò) getBoundingClientRect() 獲取元素的寬高后進(jìn)行指定,類似這樣的方式 containerWidth = svg.getBoundingClientRect().width,containerHeight = svg.getBoundingClientRect().height??傊肋@里要先計(jì)算出面積即可。
鏈接:https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect

const containerWidth = width
const containerHeight = height
const containerArea = containerWidth * containerHeight

空白間距

接著計(jì)算出矩形之間的空白間距。這里矩形上下左右一圈的 halfMargin 是通過(guò)容器寬度 containerWidth 計(jì)算出來(lái)的,即 (containerWidth / 100) * 0.3,可見容器寬度越大間距越大,反之亦然;totalMargin 就是左邊+右邊或者上邊+下邊的間距,也就是 halfMargin 的2倍。

const halfMargin = (containerWidth / 100) * 0.3
const totalMargin = halfMargin * 2


此時(shí)每個(gè)矩形包含間距后的整體寬度是 rectWidth + totalMargin,整體高度是 1.5 * rectWidth + totalMargin(上面說(shuō)過(guò)矩形實(shí)際高度總是寬度的1.5倍)。

初步算出矩形實(shí)際寬度

然后源碼里通過(guò)下面的公式初步算出矩形實(shí)際寬度 rectWidth,可以看出來(lái)大概是想通過(guò)所有矩形整體面積等于容器面積的方式,但似乎又有點(diǎn)不同。

// 初步計(jì)算出矩形實(shí)際寬度
let rectWidth = Math.sqrt(containerArea / (1.5 * dataset.length)) - totalMargin

// 變換后
// (rectWidth + totalMargin) * 1.5 * (rectWidth + totalMargin) * dataset.length = containerArea

論理,單個(gè)矩形整體面積 = 整體寬度 * 整體寬度 = (rectWidth + totalMargin) * (1.5 * rectWidth + totalMargin),原始面積公式應(yīng)該如下,而源碼里似乎采用了近似后的計(jì)算公式,古柳猜測(cè)可能是基于簡(jiǎn)化計(jì)算的原因,否則照原始公式還要解一元二次方程才能算出 rectWidth。而且后面實(shí)際繪制矩形時(shí),就會(huì)發(fā)現(xiàn)確實(shí)是矩形實(shí)際高度為實(shí)際寬度的1.5倍,而不是整體高度為整體寬度的1.5倍,所以可知這里是近似后,應(yīng)該就是為了簡(jiǎn)化計(jì)算。

// 原始面積計(jì)算公式
(rectWidth + totalMargin) * (1.5 * rectWidth + totalMargin) * dataset.length = containerArea

// 近似后直接算出,不用解一元二次方程
(rectWidth + totalMargin) * 1.5 * (rectWidth + totalMargin) * dataset.length = containerArea

矩形最終寬度

上面說(shuō)初步計(jì)算出矩形實(shí)際寬度 rectWidth,是因?yàn)檫@里還通過(guò)下面的方式,在比較 rowsrest 孰大孰小后,算出最終 rectWidth。首先是根據(jù)容器寬度除以單個(gè)矩形整體寬度得到 columns,由于這里沒(méi)有向下取整,所以帶有小數(shù);接著根據(jù)數(shù)據(jù)多少,算出 rows,同樣帶有小數(shù);然后根據(jù)數(shù)據(jù)多少和向下取整后的 columns 算出 rest;最后如果 rest <= rest 則列數(shù)多加一列,否則多加兩列,然后計(jì)算出最終矩形寬度 rectWidth。

let rectWidth = Math.sqrt(containerArea / (1.5 * dataset.length)) - totalMargin

const columns = containerWidth / (rectWidth + totalMargin)
const rows = dataset.length / columns
const rest = dataset.length % parseInt(columns)

if (rest <= rows) {
    rectWidth = containerWidth / (columns + 1) - totalMargin
} else if (rest > rows) {
    rectWidth = containerWidth / (columns + 2) - totalMargin
}

其實(shí)這步古柳就不懂為何這樣算了,雖然可以馬后炮地說(shuō),這樣確實(shí)能避免矩形超出畫布,而且能盡量占滿畫布空間,但不確定背后原理。(如果有人看懂了的話可以群里告訴古柳!)

但古柳想到類似上篇文章「手把手帶你上手D3.js數(shù)據(jù)可視化系列(一) - 牛衣古柳 2021.07.30」里調(diào)整布局,換行顯示的部分,如果這里也分別對(duì)寬高進(jìn)行限制,即每一行的最后一個(gè)矩形整體要在畫布內(nèi),并且每一列的最后一個(gè)矩形整體要在畫布內(nèi),然后列下公式,看看能不能計(jì)算出來(lái)。不過(guò)這里暫時(shí)不嘗試了,先以介紹大西洋手抄本里的源碼為主。

繪制矩形

算出矩形實(shí)際寬度 rectWidth 后,高度也就知道了;這里重新設(shè)置空白間距 rectTotalMargin,然后得到帶間距矩形整體的寬高 rectTotalWidthrectTotalHeight;接著容器寬度除以單個(gè)矩形整體寬度,并向下取整,就是每行最后矩形個(gè)數(shù) columnNum;最后繪制矩形同樣用這三個(gè)步驟 svg.selectAll('rect').data(dataset).join('rect'),并且采用取余取整操作,計(jì)算出每個(gè)矩形的x/y坐標(biāo)值,和上一票最后調(diào)整布局換行顯示的都類似,應(yīng)該無(wú)需過(guò)多解釋了。

const rectHeight = 1.5 * rectWidth
const rectTotalMargin = containerWidth * 0.005
const rectTotalWidth = rectWidth + rectTotalMargin
const rectTotalHeight = rectHeight + rectTotalMargin

const columnNum = Math.floor(containerWidth / rectTotalWidth)

const rects = svg.selectAll('rect')
    .data(dataset)
    .join('rect')
    .attr('x', d => rectTotalMargin + d % columnNum * rectTotalWidth)
    .attr('y', d => rectTotalMargin + Math.floor(d / columnNum) * rectTotalHeight)
    .attr('width', rectWidth)
    .attr('height', rectHeight)
    .attr('fill', d => colors[d % colors.length])

源碼里是組件化方式實(shí)現(xiàn)

這里可能需要提下,大西洋古抄本源碼是用 Vue 框架實(shí)現(xiàn)的,可視化部分用的 Vue-Konva。源碼里是在父組件里算出矩形實(shí)際寬度 rectWidth,也就是下面的 elementWidth后,將數(shù)據(jù)傳遞給子組件 PageVizCanvas 然后由該組件完成可視化功能,所以像上面的空白間距又重新設(shè)置了一遍等操作,也是子組件里進(jìn)行的,雖然不確定為什么這里乘以0.005,和前面的又不一致了,但沒(méi)出啥bug就先隨它去吧。
鏈接:https://cn.vuejs.org/
鏈接:https://github.com/konvajs/vue-konva

<PageVizCanvas
    :inputData="filteredData"
    :viewPages="viewPages"
    :width="elementWidth"
    :height="1.5 * elementWidth"
    :activePages="activePages"
    :navigateTo="navigateTo"
/>

當(dāng)然新手對(duì) Vue 框架和組件化開發(fā)等不了解,可以暫時(shí)忽略。

小結(jié)

文章也不短了,作為本系列的第二篇文章,古柳簡(jiǎn)單分享了下優(yōu)秀可視化作品源碼里涉及的基于數(shù)據(jù)集大小和畫布大小來(lái)自動(dòng)布局的方法。誠(chéng)然在古柳自己也沒(méi)完全理解的情況下,就這么寫出來(lái)似乎并不好,但還是那句話,本系列都是按照古柳自己想寫的邏輯來(lái)寫的,接著上篇文章的順序,就覺(jué)得一切并不突兀、比較順理成章,那就寫寫吧,等下一篇會(huì)回到基礎(chǔ)的 D3.js 數(shù)據(jù)可視化的講解上。

另外,如果有人能搞懂上述源碼里的方法、或者有什么其他方法,也歡迎告訴古柳、群里交流。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容