前言
上一篇文章「手把手帶你上手D3.js數據可視化系列(一) - 牛衣古柳 2021.07.30」裏古柳介紹瞭如何添加並設置 SVG 畫布、添加矩形元素、根據數據集來添加多個矩形元素、運用取餘取整操作調整佈局並換行顯示等內容。
文章最後留下一個疑問,就是能否基於數據集大小和畫布大小來自動計算出每個rect的寬高和間距,然後自動佈局?
正好古柳之前啃大西洋手抄本
可視化作品源碼時看到了相關實現方法,這裏就和大家分享下。
相關閱讀:迄今復現過最複雜的可視化作品之「大西洋古抄本」(上) - 牛衣古柳 2021.06.17、迄今復現過最複雜的可視化作品之「大西洋古抄本」(下) - 牛衣古柳 2021.06.22
不過古柳也沒有喫透背後的原理,只能儘量寫下自己的理解,而且一來大家不一定會用到這個自動佈局的方法,二來真要用到直接 copy 拿走也不是不可以,所以如果這部分最終也沒搞懂其實問題不大,對後續沒啥影響,放心。下一篇會回到基礎的 D3.js
數據可視化的講解上。
基礎代碼
首先基本代碼結構和上一篇文章類似,有不懂的地方可以回顧下:「手把手帶你上手D3.js數據可視化系列(一) - 牛衣古柳 2021.07.30」。
這次 SVG
畫布撐滿網頁窗口大小,寬度不再是一半大小;並且 dataset
數據集設置大些,即 [0, 1, 2, ..., 99]
共100條數據,不過後面會自動基於數據量大小計算佈局,所以數據多少並不重要;另外 colors
顏色數組不變,繪製矩形時仍會通過取餘數的方式來取對應顏色,以後也會介紹顏色比例尺,將類別屬性進行映射到對應顏色,到時候再說。
<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>
自動佈局之計算矩形寬度
畫布設置好後,先來整體看看大西洋手抄本
可視化作品源碼裏是如何根據畫布大小和數據多少計算每個矩形的寬度 rectWidth
的,由於矩形高度均是寬度的1.5倍
,所以無需另外計算。(注意:這部分代碼並非完全和源碼裏一致,很多變量名等都爲了講解方便重新改了下,但邏輯一致、計算流程相同)
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
}
接下來拆解代碼,看看都做了哪些事。
畫布容器面積
首先,計算出畫布容器的面積 containerArea
。這裏 containerWidth
和 containerHeight
分別對應 width
和 height
,似乎多此一舉。但有時候畫布寬高並不是手動設置的,而是通過 getBoundingClientRect()
獲取元素的寬高後進行指定,類似這樣的方式 containerWidth = svg.getBoundingClientRect().width
,containerHeight = svg.getBoundingClientRect().height
。總之知道這裏要先計算出面積即可。
鏈接:https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect
const containerWidth = width
const containerHeight = height
const containerArea = containerWidth * containerHeight
空白間距
接着計算出矩形之間的空白間距。這裏矩形上下左右一圈的 halfMargin
是通過容器寬度 containerWidth
計算出來的,即 (containerWidth / 100) * 0.3
,可見容器寬度越大間距越大,反之亦然;totalMargin
就是左邊+右邊或者上邊+下邊的間距,也就是 halfMargin
的2倍。
const halfMargin = (containerWidth / 100) * 0.3
const totalMargin = halfMargin * 2
此時每個矩形包含間距後的整體寬度是 rectWidth + totalMargin
,整體高度是 1.5 * rectWidth + totalMargin
(上面說過矩形實際高度總是寬度的1.5倍)。
初步算出矩形實際寬度
然後源碼裏通過下面的公式初步算出矩形實際寬度 rectWidth
,可以看出來大概是想通過所有矩形整體面積等於容器面積
的方式,但似乎又有點不同。
// 初步計算出矩形實際寬度
let rectWidth = Math.sqrt(containerArea / (1.5 * dataset.length)) - totalMargin
// 變換後
// (rectWidth + totalMargin) * 1.5 * (rectWidth + totalMargin) * dataset.length = containerArea
論理,單個矩形整體面積 = 整體寬度 * 整體寬度 = (rectWidth + totalMargin) * (1.5 * rectWidth + totalMargin)
,原始面積公式應該如下,而源碼裏似乎採用了近似後的計算公式,古柳猜測可能是基於簡化計算的原因,否則照原始公式還要解一元二次方程才能算出 rectWidth
。而且後面實際繪製矩形時,就會發現確實是矩形實際高度爲實際寬度的1.5倍,而不是整體高度爲整體寬度的1.5倍,所以可知這裏是近似後,應該就是爲了簡化計算。
// 原始面積計算公式
(rectWidth + totalMargin) * (1.5 * rectWidth + totalMargin) * dataset.length = containerArea
// 近似後直接算出,不用解一元二次方程
(rectWidth + totalMargin) * 1.5 * (rectWidth + totalMargin) * dataset.length = containerArea
矩形最終寬度
上面說初步計算出矩形實際寬度 rectWidth
,是因爲這裏還通過下面的方式,在比較 rows
和 rest
孰大孰小後,算出最終 rectWidth
。首先是根據容器寬度除以單個矩形整體寬度得到 columns
,由於這裏沒有向下取整,所以帶有小數;接着根據數據多少,算出 rows
,同樣帶有小數;然後根據數據多少和向下取整後的 columns
算出 rest
;最後如果 rest <= rest
則列數多加一列,否則多加兩列,然後計算出最終矩形寬度 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
}
其實這步古柳就不懂爲何這樣算了,雖然可以馬後炮地說,這樣確實能避免矩形超出畫布,而且能儘量佔滿畫布空間,但不確定背後原理。(如果有人看懂了的話可以羣裏告訴古柳!)
但古柳想到類似上篇文章「手把手帶你上手D3.js數據可視化系列(一) - 牛衣古柳 2021.07.30」裏調整佈局,換行顯示的部分,如果這裏也分別對寬高進行限制,即每一行的最後一個矩形整體要在畫布內,並且每一列的最後一個矩形整體要在畫布內
,然後列下公式,看看能不能計算出來。不過這裏暫時不嘗試了,先以介紹大西洋手抄本
裏的源碼爲主。
繪製矩形
算出矩形實際寬度 rectWidth
後,高度也就知道了;這裏重新設置空白間距 rectTotalMargin
,然後得到帶間距矩形整體的寬高 rectTotalWidth
和 rectTotalHeight
;接着容器寬度除以單個矩形整體寬度,並向下取整,就是每行最後矩形個數 columnNum
;最後繪製矩形同樣用這三個步驟 svg.selectAll('rect').data(dataset).join('rect')
,並且採用取餘取整操作,計算出每個矩形的x/y座標值,和上一票最後調整佈局換行顯示的都類似,應該無需過多解釋了。
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])
源碼裏是組件化方式實現
這裏可能需要提下,大西洋古抄本
源碼是用 Vue
框架實現的,可視化部分用的 Vue-Konva
。源碼裏是在父組件裏算出矩形實際寬度 rectWidth
,也就是下面的 elementWidth
後,將數據傳遞給子組件 PageVizCanvas
然後由該組件完成可視化功能,所以像上面的空白間距又重新設置了一遍等操作,也是子組件裏進行的,雖然不確定爲什麼這裏乘以0.005
,和前面的又不一致了,但沒出啥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"
/>
當然新手對 Vue
框架和組件化開發等不瞭解,可以暫時忽略。
小結
文章也不短了,作爲本系列的第二篇文章,古柳簡單分享了下優秀可視化作品源碼裏涉及的基於數據集大小和畫布大小來自動佈局的方法。誠然在古柳自己也沒完全理解的情況下,就這麼寫出來似乎並不好,但還是那句話,本系列都是按照古柳自己想寫的邏輯來寫的,接着上篇文章的順序,就覺得一切並不突兀、比較順理成章,那就寫寫吧,等下一篇會回到基礎的 D3.js
數據可視化的講解上。
另外,如果有人能搞懂上述源碼裏的方法、或者有什麼其他方法,也歡迎告訴古柳、羣裏交流。