在本書的前三部分中,我們一直在應用各種 D3 技術來開發眾所周知的可視化布局,如條形圖、流圖、直方圖、地圖等。但是,如果您選擇 D3 作為數據可視化工具,那么您很有可能還希望構建復雜且不尋常的可視化。若要創建獨特的項目,需要了解 D3 可以使用的不同方法和布局。與其說是詳細了解每種方法,不如說是掌握 D3 背后的哲學,并知道在需要時在哪里查找信息。附錄 C 中,我們映射了所有 D3 模塊及其內容,可以為您提供幫助。創建自定義布局所需的另一項技能是將想法和幾何分解為代碼的能力,我們將在本章的項目中執行此操作。
該項目將帶您了解創建完全自定義可視化的幕后情況,從草圖創意到將項目分解為組件,再到將視覺元素渲染到徑向布局上。我們將建造的項目探索了文森特梵高在他生命的最后十年中產生的藝術遺產。您可以在 https://d3js-in-action-third-edition.github.io/van_gogh_work/ 找到已完成的項目。
我們將遵循一個六步過程來使這個項目栩栩如生。雖然這不是一成不變的,但這大致是任何數據可視化項目都可以遵循的方法。
收集和清理數據是任何數據可視化項目中最關鍵的一步。如果幸運的話,我們得到了現成的數據集,可以直接開始可視化,就像本書以前的項目一樣。但通常情況下,我們需要從不同來源收集數據,對其進行分析,清理數據并對其進行格式化。數據收集和操作可能需要大量的時間。它需要耐心和勤奮。在本節中,我們將討論為本章項目準備數據所經歷的不同步驟。
但在我們尋找數據之前,讓我們花點時間定義我們想要可視化的信息類型。這個項目的靈感來自Frederica Fragapane的數據可視化研討會,在此期間,我們使用文森特梵高寫給他兄弟西奧的信的數據集。我們對梵高的豐富文學遺產感到震驚,并認為將其與他的著名繪畫和素描相結合以深入了解他的整個藝術遺產會很有趣。
所以,我們知道我們想收集有關梵高的繪畫、素描和信件的數據。理想情況下,我們希望及時放置這些作品,以可視化他藝術作品的起伏。經過幾次谷歌搜索,我們找到了以下資源:
通過探索這些資源,我們還注意到,我們可以根據梵高居住的城市將他的生活分解為幾個階段。例如,他于1886年從荷蘭搬到巴黎,在那里他遇到了保羅·高更和亨利·德·圖盧茲-勞特累克,僅舉兩例。這些藝術相遇無疑影響了梵高的作品。我們還知道,他從 1889 年 1890 月到 1890 年 <> 月在圣保羅德莫索萊精神病院住院。在此期間,他開始將漩渦融入他對醫院花園的描繪中。最后,梵高于 <> 年 <> 月自殺身亡,標志著他多產的十年藝術創作的戛然而止。意識到這些事件,我們希望我們的可視化構成梵高過去十年的時間線。
現在,我們需要從找到的資源中提取數據。讓我們以繪畫為例(https://en.wikipedia.org/wiki/List_of_works_by_Vincent_van_Gogh)。這個維基百科頁面包含一系列表格,列出了一千多幅畫作。不是我們想要手動提取的東西!您可以找到從網頁中提取表并將其轉換為 CSV 文件(如 tableconvert.com)的聯機服務。此類工具使用方便快捷。但是如果我們想要更細粒度的控制,我們可以編寫一個簡單的腳本來完成這項工作。
14.1 例包含一個腳本,您可以使用該腳本從維基百科頁面中提取每幅畫的標題、圖像 URL 和媒介。要使用此腳本,請打開瀏覽器的控制臺,復制粘貼整個代碼段,然后單擊 Enter 。
如果我們看一下頁面結構,我們會發現它由一系列HTML表格組成,每個表格都包含使用相同媒介制作的繪畫列表。前六張表是關于油畫的;第七幅包含水彩畫;第八和第九是關于石版畫和蝕刻版畫的,我們將將它們歸入“印刷”媒介。最后一個表格包含字母草圖,我們還不想提取。在示例 14.1 中,我們聲明了一個數組,其中包含我們感興趣的表的索引及其相關介質。
然后,我們使用文檔方法querySelectorAll()和類“wikitable”和“sortable”作為選擇器從頁面中提取所有HTML表。我們通過打開瀏覽器檢查器并仔細查看標記來找到這個選擇器,以找到我們感興趣的表的唯一且通用的選擇器。
在循環遍歷這些表時,我們檢查它們是否已存在于腳本開頭聲明的 tables 數組中。這種驗證使我們能夠避免從字母草圖表中提取信息。然后,我們可以遍歷每個表格行并提取繪畫圖像的標題和 URL。請注意我們必須如何在代碼中適應不同的 DOM 結構,因為表行的格式不一致。與這些HTML結構不匹配的繪畫將被賦予標題和圖像URL為null,稍后將手動完成。處理現實生活中的數據通常是混亂的!您還將看到我們從 srcset 屬性而不是 src 中提取圖像 URL,因為此圖像更小,并且在我們的項目中需要更少的加載時間。
最后,我們將繪畫信息構建成一個對象,并將其推送到一個名為“繪畫”的數組中。但是,通過將此數組記錄到控制臺中,我們可以將其復制粘貼到代碼編輯器中并創建一個 JSON 文件。
此腳本針對此特定示例量身定制,在其他網頁上沒有幫助。但是你可以看到你的JavaScript技能對于從網頁中提取任何信息是多么有價值。
const tables=[ #A
{ index: 0, medium: "oil" }, #A
{ index: 1, medium: "oil" }, #A
{ index: 2, medium: "oil" }, #A
{ index: 3, medium: "oil" }, #A
{ index: 4, medium: "oil" }, #A
{ index: 5, medium: "oil" }, #A
{ index: 6, medium: "watercolor" }, #A
{ index: 7, medium: "print" }, #A
{ index: 8, medium: "print" }, #A
]; #A
const domTables=document.querySelectorAll(".wikitable.sortable"); #B
const paintings=[]; #C
domTables.forEach((table, i)=> { #D
if (i <=tables.length - 1) { #D
const medium=tables[i].medium; #D
#D
const rows=table.querySelectorAll("tbody tr"); #D
rows.forEach(row=> { #D
let title; #E
if (row.querySelector(".thumbcaption i a")) { #E
title=row.querySelector(".thumbcaption i a").textContent; #E
} else if (row.querySelector(".thumbcaption i")) { #E
title=row.querySelector(".thumbcaption i").textContent; #E
} else { #E
title=null; #E
} #E
let imageLink; #F
if (row.querySelector(".thumbinner img")) { #F
const image=row.querySelector(".thumbinner img").srcset; #F
imageLink=`https${image.slice(image.indexOf("1.5x, ")+6,-3)}`; #F
} else { #F
imageLink=null; #F
} #F
paintings.push({ #G
title: title, #G
imageLink: imageLink, #G
medium: medium #G
}); #G
})
}
});
console.log(paintings); #H
清單 14.1 中的腳本示例不完整。我們仍然需要提取每幅畫的日期、尺寸、當前位置和創作位置。為了避免本節太長,我們不會在這里這樣做,但如果您想練習從網頁中提取數據,請嘗試一下!請注意,我們還必須操作提取的數據以分別存儲繪畫的寬度和高度,以及創作的月份和年份。需要一些額外的研究來找到一些繪畫的創作月份并找到它們的主題(肖像、靜物、風景等)。
如果您想直接跳轉到使用數據,本章的代碼文件包含梵高的繪畫、素描、信件和他所居住城市的時間軸的現成數據集(見 https://github.com/d3js-in-action-third-edition/code-files/tree/main/chapter_14/14.4.1-Responsive_SVG_container/start/src/data)。
在第3章中,我們定義了兩類主要數據:定量和定性,如圖14.1所示。定量數據由數字信息組成,例如股票市場行為價值的起伏或教室里的學生人數。定量數據可以是離散的,由無法細分的整數組成,也可以是連續的,其中數字在細分為較小的單位時仍然有意義。另一方面,定性數據是非數字信息,例如國家列表或星巴克咖啡訂單的可用尺寸(矮、高、大、通風等)。定性數據可以是名義數據(值沒有特定順序)或順序(順序很重要)。
由于我們不會使用相同的通道來可視化不同的數據類型,因此編寫一個可用于項目的變量列表并按數據類型組織它們通常很有幫助。此步驟可以幫助我們識別可以使用的不同視覺通道或編碼數據的方法。圖14.2說明了定量數據通常通過位置(如散點圖)、長度(如條形圖)、面積(如我們的羅密歐與朱麗葉項目中節點的大小)(見第12章)、角度(如餅圖)或連續色標進行可視化。另一方面,定性數據通常使用分類色階、圖案、符號、連接(如網絡圖)或分層數據的外殼(如圓形包)進行翻譯。這樣的列表只能是不完整的,因為只要有一點創造力,我們就可以設計出可視化數據的新方法。但它提供了我們可以使用的主要視覺編碼的概述。
在這一點上,一個有用的練習包括列出數據集中包含的不同數據屬性,識別定量和定性數據,并集思廣益我們希望如何可視化主要屬性。在圖 14.3 中,我們列出了該項目的四個數據集(梵高的畫作列表、他的繪畫列表、他每月寫的信數量以及他職業生涯中居住的城市的時間線),并確定數據屬性是定量的(藍點)還是定性的(紅點)。基于這些信息,我們可以開始考慮要創建的可視化。
在這個項目中,我們希望在時間軸上展示梵高的藝術作品(繪畫、素描和信件),以探索每種表達方式的使用與藝術家在荷蘭和法國的移動如何演變之間的相關性。我們希望更多地關注繪畫,并允許用戶單獨探索它們。如果圓圈代表每幅畫,我們可以使用圓圈的顏色來傳達繪畫的主題(肖像、靜物、風景等),使用它們的大小作為作品的尺寸,并用圓圈的邊框突出顯示介質(油畫、水彩或印刷品),如圖 14.4 所示。這些圓圈將定位在某種時間軸上。
每月制作的圖紙和字母數量可以通過條形圖或面積圖的長度作為次要信息添加。最后,我們知道我們需要一些可點擊的時間線來選擇和突出梵高在他生命不同時期的作品。
一旦選擇了視覺通道,我們就可以開始繪制項目的布局。我們已經確定每幅畫將由一個圓圈表示并定位在時間軸上。水平軸或垂直軸可以工作,盡管它對于屏幕來說可能太大。一個有趣的解決方法可能是徑向時間軸。與其有一個很難適應移動屏幕的大圓圈,不如使用小倍數方法。小型序列圖是一系列可視化效果,使用相同的比例和軸,但表示數據的不同方面。通過這種方法,我們可以每年有一個輪子,允許我們將它們定位到一個網格中,如圖 14.5 所示。在桌面上,我們將在左側顯示可點擊的時間線,在右側以三列網格形式布置小型序列可視化。在平板電腦上,網格將減少到兩列,而在移動設備上,我們將使用沒有時間軸功能的單列網格。
每個小倍數將可視化一整年,月份沿圓周分布。對于每個月,圖紙的數量將由面積圖和條形長度的字母數量表示。代表一個月內繪畫的圓圈將聚集在一起,如圖 14.6 所示。
下一步是創建調色板并選擇字體。我們需要為八種不同的繪畫主題提供一個分類的調色板:自畫像、肖像、農民生活、室內場景、靜物、風景、城市景觀建筑等,以及字母和素描的另一種顏色。創建任何調色板時,請考慮要在項目中安裝的氛圍。例如,在這里,我們想使用一種歡快的調色板,靈感來自梵高生命中最后幾年的畫作中的色調。我們通過從繪畫中提取金色并使用 coolors.co 生成匹配的顏色,從圖 14.7 創建了分類調色板。對于分類調色板來說,八種顏色已經很多了,因此我們不得不對某些類別使用類似的色調。例如,我們為肖像(#c16e70)選擇了舊玫瑰色,為自畫像選擇了相同顏色的較亮版本(#f7a3a6)。您還可以在 adobe.color.com 和 colorhunt.co 上找到調色板的靈感。
對于字體,我們發現 font.google.com 是免費網絡字體的絕佳資源。通常,您希望每個項目最多堅持兩個字體系列,一個用于標題,一個用于文本正文。一個簡單的谷歌搜索將為谷歌字體組合提供很多想法。對于這個項目,我們選擇了“Libre Baskerville Bold”作為標題,一種與19世紀相呼應的襯線字體,文本和標簽為“Source Sans Pro”,一種無襯線字體,對用戶界面具有出色的可讀性。
一旦我們知道我們想要構建什么,我們必須決定我們要使用的基礎設施。因為這個項目比我們在本書前面創建的項目更復雜,所以我們將轉向JavaScript框架。使用框架將使我們能夠將項目分解為小組件,使其更易于開發和維護。我們已經在第 8 章中使用 React 構建了一個項目,所以這一次,我們將選擇 Svelte,一個數據可視化社區特別喜歡的簡單編譯器。如果您還不熟悉Svelte,請不要擔心。本章的重點仍將放在創建復雜數據可視化項目背后的一般原則上。您可以一起閱讀并收集一點點智慧,而不必潛入 Svelte。如果您以前玩過 Svelte 或想嘗試一下,您會發現它非常直觀,并且可以很好地與 D3 配合使用。您可以在附錄 E 中找到對 Svelte 的簡要介紹,并在 https://svelte.dev/tutorial 中找到方便的一口大小的教程。
我們希望在將 D3 與 JavaScript 框架或 Svelte 等編譯器相結合時將職責分開。該框架負責添加、刪除和操作 DOM 元素,而 D3 用于執行與比例、形狀生成器、力布局等可視化相關的計算。簡而言之,您需要忘記數據綁定模式,并謹慎使用 D3 轉換以避免 D3 和 Svelte 之間的沖突。回到第8章,深入討論將D3與框架相結合的可能方法。
若要開始處理本章的項目,請在代碼編輯器中打開 https://github.com/d3js-in-action-third-edition/code-files/tree/main/chapter_14/14.4.1-Responsive_SVG_container/start/src/data 的開始文件夾。打開集成終端并使用 npm install 安裝項目依賴項。然后用 npm run dev 啟動項目。該項目將在您的瀏覽器中提供,網址為 http://localhost:5173/ .您將在 src/ 文件夾中找到我們將處理的所有文件。
從第一章開始,我們采用了一種簡單而有效的方法來使SVG圖形響應:通過設置SVG容器的viewBox屬性并將寬度和高度屬性留空。這種方法非常容易實現,并且開箱即用。唯一真正的缺點是,當 SVG 容器變小時,它包含的文本元素會按比例變小,使它們可能難以閱讀。
在此項目中,我們將采用不同的方法,設置 SVG 容器的寬度和高度屬性并將 viewBox 留空。每當屏幕尺寸發生變化時,我們將使用事件偵聽器來更新這些屬性。盡管這種方法需要我們作為程序員和瀏覽器付出更多的努力,但它使我們能夠根據屏幕寬度調整可視化的布局。此外,隨著屏幕尺寸的減小,它會保持文本標簽的大小。
在之前的項目討論中,我們決定顯示一個由小型多個可視化效果組成的網格。所有這些可視化將包含在單個 SVG 元素中。此外,我們將使用一個 12 列的 flexbox 網格,類似于第 9 章中討論的網格,用于包括時間軸和可視化效果在內的整體頁面布局。
在圖 14.8 中,您可以看到三種不同屏幕寬度的頁面布局:大于或等于 1400px(flexbox 網格容器的寬度)、小于 1400px 但大于 748px 和小于 748px。對于這三種屏幕尺寸中的每一種,SVG 容器寬度的計算略有不同。當屏幕大于或等于 748px 時,時間線顯示在左側,從 12 列網格中取出兩列,可視化效果或 SVG 容器顯示在右側剩余的十列上。當屏幕小于 748px 時,我們會刪除時間線,可視化效果可以擴展到 12 列。彈性框網格容器還應用了 30px 的填充到左側和右側。
彈性框網格在其 CSS 屬性中的最大寬度限制為 1400 像素。這意味著即使在較大的屏幕上,內容也不會超過此寬度。要計算寬度超過 1400px 的屏幕上 SVG 容器的寬度,我們可以減去 flexbox 網格容器兩側的填充,將其乘以 12,然后除以 <>。這是因為 SVG 容器跨越十二列中的十列。
svg寬度=10/12 * (網格容器 - 2*填充)
當屏幕小于 1400 像素時,SVG 容器的大小會按比例變小。在 svgWidth 的方程中,我們只需要更改窗口寬度的 gridContainer。
svg寬度=10/12 * (窗口寬度 - 2*填充)
最后,當屏幕小于 768px 時,SVG 容器將分布在屏幕的整個寬度減去填充。
svg寬度=窗口寬度 - 2*填充
在示例 14.2 中,我們使用這些方程來動態計算 SVG 容器的寬度。為此,我們在文件Grid.svelte中工作。我們首先聲明兩個變量,一個用于 windowWidth,一個用于 SVG width。使用 switch 語句,我們根據屏幕的寬度和剛才討論的方程設置 SVG width 變量的值。因為switch語句是用Svelte反應符號($)聲明的,所以只要變量包含更改,它就會運行。
請注意我們如何將 windowWidth 變量綁定到 window 對象的 innerWidth 屬性。在 Svelte 中,我們可以從任何組件訪問窗口對象 <svelte:window /> .
最后,我們使用 svgWidth 變量動態設置 SVG 容器的 width 屬性。因為我們使用的是 JavaScript 框架,所以我們不使用 D3 將 SVG 元素附加到 DOM 中,而是直接將其添加到組件的標記中。
<script>
let windowWidth;
const gridContainer=1400;
const padding=30;
let svgWidth;
$: switch (true) { #A
case windowWidth >=gridContainer: #A
svgWidth=(10 / 12) * (gridContainer - 2 * padding); #A
break; #A
case windowWidth < gridContainer && windowWidth >=768: #A
svgWidth=(10 / 12) * (windowWidth - 2 * padding); #A
break; #A
default: #A
svgWidth=windowWidth - 2 * padding; #A
} #A
</script>
<svelte:window bind:innerWidth={windowWidth} /> #B
<svg width={svgWidth} /> #C
<style>
svg {
border: 1px solid magenta;
}
</style>
在“樣式”部分中,已將洋紅色邊框添加到 SVG 元素中。您可以嘗試調整屏幕大小,以查看它如何影響 SVG 元素的寬度。
響應式 SVG 寬度
現在處理了 SVG 容器的寬度,我們需要設置它的高度。由于 SVG 元素將包含一個由多個小型可視化組成的網格,因此如果我們知道:可視化的數量、它們的高度和網格中的列數,我們可以計算它的高度。在示例 14.3 中,我們首先聲明了我們想要可視化梵高工作的年份數組。我們使用 D3 的范圍方法做到這一點。然后,我們根據屏幕的寬度設置網格的列數。如果屏幕大于 900px,我們需要三列,如果小于 600px,我們需要一列,在兩者之間,我們需要兩列。我們現在使用大概的數字,如果需要,我們會在以后進行調整。
一旦我們知道了列數,我們就可以通過將小型多個可視化的數量除以列數并將結果四舍五入來計算行數。通過將 SVG 元素的寬度除以列數來找到每個小序列圖的寬度。我們還任意將它們的高度設置為寬度加 40px。最后,我們通過將行數乘以每個小倍數的高度來找到 SVG 元素的總高度。
由于 svgWidth 和 svgHeight 變量在組件掛載時為 null,因此瀏覽器將引發錯誤。這就是為什么我們僅在定義了這兩個變量后才使用條件語句將 SVG 元素添加到標記中。請注意 switch 語句和維度變量如何使用 $ 符號進行響應。每次屏幕寬度更改時,它們都會更新。
我們有一個響應式 SVG 元素!這個實現需要比我們以前的策略更多的工作,但在下一節中使用響應式 SVG 網格時會很有幫助。
<script>
import { range } from "d3-array";
...
const years=range(1881, 1891); #A
let numColumns; #B
$: switch (true) { #B
case windowWidth > 900: #B
numColumns=3; #B
break; #B
case windowWidth <=900 && windowWidth > 600: #B
numColumns=2; #B
break; #B
default: #B
numColumns=1; #B
} #B
$: numRows=Math.ceil(years.length / numColumns); #C
$: smWidth=svgWidth / numColumns; #D
$: smHeight=smWidth + 40; #D
$: svgHeight=numRows * smHeight; #E
</script>
<svelte:window bind:innerWidth={windowWidth} />
{#if svgWidth && svgHeight} #F
<svg width={svgWidth} height={svgHeight} /> #F
{/if} #F
<style>
svg {
border: 1px solid magenta;
}
</style>
在最后一個列表中,我們使用變量 smWidth 和 smHeight 確定每個網格項的寬度和高度。使用這些值,我們將構建將保存所有可視化效果的網格。由于我們在 SVG 容器中工作,因此我們將使用組元素來包圍每個小倍數。
首先,在清單 14.4 中,我們在 SVG 容器中插入一個 each 塊,用于遍歷先前創建的 years 數組。值得注意的是,我們可以訪問每年的索引(i)作為第二個參數。我們每年創建一個組元素,然后使用 transform 屬性應用翻譯。為了確定每個組屬于哪一列,我們使用索引的余數,也稱為模數(% ),除以列數。下面的等式說明了三列布局中介于 <> 和 <> 之間的索引的余數。然后,我們通過將余數乘以 smWidth 來計算水平平移。
0 % 3=0
1 % 3=1
2 % 3=2
3 % 3=0
4 % 3=1
5 % 3=2
等等...
對于垂直平移,我們將索引四舍五入除以列數,以了解我們在哪一行,然后將結果乘以網格元素的高度。然后,我們在組中附加一個矩形元素,將其尺寸設置為網格項的寬度和高度,并為其提供藍色筆觸。我們添加此矩形以確保網格按預期工作,并在屏幕寬度更改時正確調整大小,但我們不會將其保留在最終可視化效果中。
{#if svgWidth && svgHeight}
<svg width={svgWidth} height={svgHeight}>
{#each years as year, i} #A
<g transform="translate( #B
{(i % numColumns) * smWidth}, #B
{Math.floor(i / numColumns) * smHeight})" #B
> #B
<rect x={0} y={0} width={smWidth} height={smHeight} /> #C
</g>
{/each}
</svg>
{/if}
<style>
svg {
border: 1px solid magenta;
}
rect {
fill: none;
stroke: cyan;
}
</style>
實現網格后,調整屏幕大小以確保網格項的列數和位置按預期調整。當屏幕大于 900px 時,網格應有三列,600 到 900px 之間應有兩列,如果小于 600px,則有一列,如圖 14.9 所示。
響應式 SVG 網格
準備好項目骨架后,我們可以開始利用 D3 來創建梵高作品的可視化!在本節中,我們將構建我們的小的多重可視化,從軸和標簽開始,繼續繪畫,最后是繪圖和字母。
我們的小型多重可視化的主干可以簡化為背景圓圈和年份標簽。但在實施這些元素之前,我們需要定義它們的確切位置。圖 14.10 顯示了在定位圓圈和年份標簽之前需要考慮的不同參數的草圖。我們已經計算了每個小倍數的寬度(smWidth)和高度(smHeight)。為了確保可視化之間有足夠的空間并為月份標簽留出空間,我們可以定義要在每個圓圈周圍應用的填充,比如說 60px。根據這個值和網格元素的寬度,我們可以計算背景圓的半徑。
我們將開始在 Grid.svelte 的子組件中構建可視化,名為 GridItem.svelte 。在清單 14.5 中,我們首先將此組件導入到 Grid.svelte 中。然后,我們將 GridItem 附加到每個塊中,這將從年份數組中生成每年的 GridItem。我們將 smWidth 、smHeight 和當前年份作為道具傳遞給這個子組件。
<script>
import GridItem from "./GridItem.svelte"; #A
...
</script>
{#if svgWidth && svgHeight}
<svg width={svgWidth} height={svgHeight}>
{#each years as year, i}
<g transform="translate(
{(i % numColumns) * smWidth},
{Math.floor(i / numColumns) * smHeight})"
>
<rect x={0} y={0} width={smWidth} height={smHeight} />
<GridItem {smWidth} {smHeight} {year} /> #B
</g>
{/each}
</svg>
{/if}
在清單 14.6 中,我們開始在 GridItem.svelte 中工作。我們在腳本標簽中導入道具 smWidth , smHeight 和 year。然后,我們將填充常量設置為值 60,并根據填充和 smWidth 計算圓的半徑。因為半徑被聲明為一個反應變量 ($),所以只要 smWidth 發生變化,它就會被重新計算。
在標記中,我們使用兩個組元素來設置可視化的相對坐標系的原點。第一個水平轉換為半 smWidth .它用作年份標簽的參考點,然后只需將其垂直平移到網格項的底部。第二組元素垂直平移到背景圓的中心。當我們開始向可視化追加其他形狀以表示繪畫、素描和字母時,此策略將特別方便。
<script>
export let smWidth; #A
export let smHeight; #A
export let year; #A
const padding=60; #B
$: radius=(smWidth - 2 * padding) / 2; #B
</script>
<g transform="translate({smWidth / 2}, 0)"> #C
<g transform="translate(0, {padding + radius})"> #C
<circle cx={0} cy={0} r={radius} />
</g>
<text x={0} y={smHeight - 5} text-anchor="middle">{year}</text>
</g>
<style lang="scss">
circle {
fill: none;
stroke: $text;
}
</style>
下一步是為每個月添加一個軸和標簽,如圖 14.10 所示。此圖顯示圓坐標系的原點位于其中心,這要歸功于之前翻譯的 SVG 組。每個月的軸將是一條從原點開始并到達圓周的線,每個月的角度都不同。
為了計算軸端點的位置,我們需要做一些三角函數。讓我們以二月的軸為例。在圖 14.11 的右側,您可以看到我們可以通過將軸與其水平 (x) 和垂直 (y) 邊長連接起來來形成一個直角三角形(其中一個角為 90° 角的三角形)。我們也可以稱θ(θ)為12點鐘位置(零度時)與二月軸之間的角度。
三角函數告訴我們,θ 的正弦等于 x 除以二月軸的長度或背景圓的半徑。因此,我們可以通過將半徑乘以sinθ來計算端點的水平位置。類似地,θ 的余弦等于 y 除以二月軸的長度。因此,我們可以通過將半徑乘以 cosθ 和 -1 來計算端點的垂直位置,因為我們正朝著垂直軸的負方向前進。
sinθ=x / 半徑=> x=半徑 * sinθ
余量θ=y / 半徑=> y=半徑 * 余量θ
為了繪制月份軸,我們繼續在 網格項目.svelte .我們首先聲明一個點刻度,該刻度將月份數組作為輸入(該數組在文件 /utils/months.js 中可用)并返回相應的角度。我們希望 12 月顯示在 360 點鐘位置,對應于零角度。我們知道,一個完整的圓覆蓋 2° 或 2π 弧度。因為一年有十二個月,所以我們將刻度中的最后一個角度設置為 2π - 12π/<> 弧度,或一個完整的圓減去十二分之一的圓。
在標記中,我們使用每個塊為每個月附加一個行元素。每條線的起點是 (0, 0) ,而它的端點是用剛才討論的三角函數計算的。
<script>
import { scalePoint } from "d3-scale";
import { months } from "../utils/months";
export let smWidth;
export let smHeight;
export let year;
const padding=60;
$: radius=(smWidth - 2 * padding) / 2;
const monthScale=scalePoint() #A
.domain(months) #A
.range([0, 2 * Math.PI - (2 * Math.PI) / 12]); #A
</script>
<g transform="translate({smWidth / 2}, 0)">
<g transform="translate(0, {padding + radius})">
<circle cx={0} cy={0} r={radius} />
{#each months as month} #B
<line #B
x1="0" #B
y1="0" #B
x2={radius * Math.sin(monthScale(month))} #B
y2={-1 * radius * Math.cos(monthScale(month))} #B
stroke-linecap="round" #B
/> #B
{/each} #B
</g>
<text x={0} y={smHeight - 5} text-anchor="middle">{year}</text>
</g>
<style lang="scss">
circle {
fill: none;
stroke: $text;
}
line {
stroke: $text;
stroke-opacity: 0.2;
}
</style>
作為最后一步,我們要在每個月的軸上添加一個標簽,在圓圈外 30px。在示例 14.8 中,我們為每個月附加一個文本元素,并使用 JavaScript slice() 方法將文本設置為該月的前三個字母。為了正確定位文本標簽,我們執行翻譯,然后旋轉。我們發現帶有三角函數的平移,類似于我們計算軸端點的方式。對于圓圈上半部分(9 點鐘和 3 點鐘之間)顯示的標簽,旋轉與其軸相同。對于下半部分(3點鐘和9點鐘之間)的標簽,我們給它們額外的180°旋轉,以便它們更容易閱讀。
雖然我們在 JavaScript Math.sin() 和 Math.cos() 函數中使用弧度,但旋轉的轉換屬性需要度數。為了便于從弧度到度數的轉換,我們創建了一個名為 radiansToDegrees() 的輔助函數,您可以在 /utils/helper.js 中找到該函數。它采用弧度的角度作為輸入,并返回相同的弧度角度。
<script>
import { scalePoint } from "d3-scale";
import { months } from "../utils/months";
import { radiansToDegrees } from "../utils/helpers";
export let smWidth;
export let smHeight;
export let year;
const padding=60;
$: radius=(smWidth - 2 * padding) / 2;
const monthScale=scalePoint()
.domain(months)
.range([0, 2 * Math.PI - (2 * Math.PI) / 12]);
</script>
<g transform="translate({smWidth / 2}, 0)">
<g transform="translate(0, {padding + radius})">
<circle cx={0} cy={0} r={radius} />
{#each months as month}
<line
x1="0"
y1="0"
x2={radius * Math.sin(monthScale(month))}
y2={-1 * radius * Math.cos(monthScale(month))}
stroke-linecap="round"
/>
<text #A
class="month-label"
transform="translate( #B
{(radius + 30) * Math.sin(monthScale(month))}, #B
{-1 * (radius + 30) * Math.cos(monthScale(month))} #B
) #B
rotate({ #C
monthScale(month) <=Math.PI / 2 || #C
monthScale(month) >=(3 * Math.PI) / 2 #C
? radiansToDegrees(monthScale(month)) #C
: radiansToDegrees(monthScale(month)) - 180 #C
})" #C
text-anchor="middle"
dominant-baseline="middle">{month.slice(0, 3)}</text #D
>
{/each}
</g>
<text x={0} y={smHeight - 5} text-anchor="middle">{year}</text>
</g>
<style lang="scss">
circle {
fill: none;
stroke: $text;
}
line {
stroke: $text;
stroke-opacity: 0.2;
}
.month-label {
font-size: 1.4rem;
}
</style>
準備好軸后,我們可以開始繪制可視化效果了。我們將首先用一個圓圈來表示梵高的每幅畫。這些圓圈將圍繞相應月份軸的端點按年份分組,然后按創建月份分組。如第14.3節所述,圓圈的顏色將基于繪畫的主題及其在媒介上的邊框。最后,我們將圓的面積設置為與相關圖稿的尺寸成比例。圖 14.12 顯示了我們所追求的效果。為了在避免重疊的同時生成圓簇,我們將使用 D3 的力布局。
在進一步進入代碼之前,讓我們花點時間思考一下組件的體系結構,并制定最佳前進方向的戰略。我們的小倍數可視化由三層組件組成,如圖 14.13 所示。第一個由 Grid.svelte 組件持有,該組件負責將 SVG 容器添加到標記中,并將年份分解為類似網格的布局。該組件“知道”我們將生成可視化的所有年份。
第二層由 GridItem.svelte 處理。此組件僅“感知”單個年份的數據,并顯示其相應的年份標簽和月份軸。最后,還有組件 繪畫.svelte , 圖紙.svelte 和 字母.svelte 。我們還沒有處理這些文件,但它們包含在 chart_components/ 文件夾中。顧名思義,這些組件負責可視化一年中產生的繪畫、素描和信件。因為它們將從GridItem.svelte調用,所以它們也將知道一年的數據。
考慮到這種分層架構,我們看到加載整個繪畫數據集的最佳位置是 Grid.svelte ,因為該組件監督可視化的整體性,并且在應用程序中僅加載一次。然后,該組件會將每年對應的繪畫作為道具傳遞給GridItem.svelte,然后將它們傳遞給Paintings.svelte。
基于這個邏輯,在清單 14.9 中,我們回到 Grid.svelte 并導入繪畫數據集。由于我們稍后希望根據繪畫的尺寸調整代表繪畫的圓圈的大小,因此我們計算這些作品的面積,并使用此信息查找數據集中最大的繪畫尺寸。請注意,數據集中有一些繪畫的尺寸不可用。在這種情況下,我們將相應圓的半徑設置為 3px。
要將繪畫的面積(以cm 2為單位)縮放為屏幕上的圓形區域(以px2為單位),我們可以使用線性比例。我們稱此比例為繪畫AreaScale,并使用圓的面積公式找到范圍覆蓋的最大面積:
a=πr2
最后,我們將顯示繪畫所需的數據和函數傳遞給 GridItem .請注意我們如何過濾繪畫數據集以僅傳遞與當前年份對應的繪畫。
<script>
import { range, max } from "d3-array";
import { scaleLinear } from "d3-scale";
import paintings from "../data/paintings.json";
...
paintings.forEach((painting)=> { #A
if (painting.width_cm && painting.height_cm) { #A
painting["area_cm2"]=painting.width_cm * painting.height_cm; #A
} #A
}); #A
const maxPaintingArea=max(paintings, (d)=> d.area_cm2); #A
const maxPaintingRadius=8; #B
const paintingDefaultRadius=3; #B
const paintingAreaScale=scaleLinear() #B
.domain([0, maxPaintingArea]) #B
.range([0, Math.PI * Math.pow(maxPaintingRadius, 2)]); #B
</script>
<svelte:window bind:innerWidth={windowWidth} />
{#if svgWidth && svgHeight}
<svg width={svgWidth} height={svgHeight}>
{#each years as year, i}
<g
transform="translate(
{(i % numColumns) * smWidth},
{Math.floor(i / numColumns) * smHeight})"
>
<rect x={0} y={0} width={smWidth} height={smHeight} />
<GridItem
{smWidth}
{smHeight}
{year}
{paintingAreaScale} #C
{paintingDefaultRadius} #C
paintings={paintings.filter((painting)=> #C
painting.year===year)} #C
/>
</g>
{/each}
</svg>
{/if}
在 GridItem.svelte 中,我們所要做的就是聲明從 Grid.svelte 接收的道具,導入 Paintings 組件,將 Paintings 組件添加到標記中,然后傳遞相同的 props,如清單 14.10 所示。
<script>
import Paintings from "../chart_components/Paintings.svelte"; #A
export let paintingAreaScale; #B
export let paintingDefaultRadius; #B
export let paintings; #B
...
</script>
<g transform="translate({smWidth / 2}, 0)">
<g transform="translate(0, {padding + radius})">
<circle ... />
{#each months as month}
<line ... />
<text ... >{month.slice(0, 3)}</text>
{/each}
<Paintings #C
{paintingAreaScale} #C
{paintingDefaultRadius} #C
{paintings} #C
{monthScale} #C
{radius} #C
/> #C
</g>
<text ... >{year}</text>
</g>
最后,真正的動作發生在《畫畫》中。現在,我們循環瀏覽作為道具收到的畫作,并在每幅畫的標記中添加一個圓圈。這些圓的初始位置是它們相關月份軸的尖端,這可以通過我們之前使用的三角函數找到。我們還必須考慮我們不知道它們是在哪個月創作的畫作。我們將它們放置在可視化效果的中心。
為了計算圓的半徑,我們稱之為 繪畫面積比例 .由于此刻度返回一個面積,因此我們需要使用以下公式計算相應的半徑:
r=√(a/π)
<script>
export let paintingAreaScale; #A
export let paintingDefaultRadius; #A
export let paintings; #A
export let monthScale; #A
export let radius; #A
</script>
{#each paintings as painting} #B
<circle #B
cx={painting.month !=="" #C
? radius * Math.sin(monthScale(painting.month)) #C
: 0} #C
cy={painting.month !=="" #C
? -1 * radius * Math.cos(monthScale(painting.month)) #C
: 0} #C
r={painting.area_cm2 #D
? Math.sqrt(paintingAreaScale(painting.area_cm2) / Math.PI) #D
: paintingDefaultRadius} #D
/> #D
{/each}
在這個階段,繪畫的圓圈在其月軸的頂端重疊,如圖 14.14 所示。我們將在一分鐘內通過 D3 的力布局解決這個問題。
為了在每個月軸的尖端創建節點集群,我們將使用 D3 的力布局。這種布局有點復雜,所以如果你需要更深入的介紹,我們建議閱讀第12章。在示例 14.12 中,我們使用 forceSimulation() 方法初始化一個新的力模擬,我們將繪畫數組傳遞給該方法。我們還聲明了一個空節點數組,在每次報價后,我們使用模擬的節點進行更新。然后,我們遍歷此節點數組而不是繪畫,以將圓圈附加到標記中。
我們計算施加在反應塊($ )內節點的力,以便在相關變量發生變化時觸發重新計算。在這個塊內,定位力(forceX和forceY)將節點推向其月軸的尖端,而碰撞力(forceCollide)確保節點之間沒有重疊。
我們還降低了 alpha(模擬的“溫度”)并提高了 alpha 衰減率,以幫助模擬更快地收斂。這種調整需要反復試驗的方法才能找到正確的設置。
最后,我們使用仿真添加到節點的 x 和 y 屬性來設置相應圓的 cx 和 cy 屬性。
<script>
import { forceSimulation, forceX, forceY, forceCollide } from "d3-force";
...
let simulation=forceSimulation(paintings); #A
let nodes=[]; #A
simulation.on("tick", ()=> { #B
nodes=simulation.nodes(); #B
}); #B
$: { #C
simulation #C
.force("x", #D
forceX((d)=> d.month !=="" #D
? radius * Math.sin(monthScale(d.month)) #D
: 0 #D
).strength(0.5) #D
) #D
.force("y", #D
forceY((d)=> d.month !=="" #D
? -1 * radius * Math.cos(monthScale(d.month)) #D
: 0 #D
).strength(0.5) #D
) #D
.force("collide", #E
forceCollide() #E
.radius((d)=> d.width_cm===null && d.height_cm===null #E
? paintingDefaultRadius + 1 #E
: Math.sqrt(paintingAreaScale(d.area_cm2) / Math.PI) + 1 #E
).strength(1) #E
) #E
.alpha(0.5) #F
.alphaDecay(0.1); #F
}
</script>
{#each nodes as node}
<circle
cx={node.x} #G
cy={node.y} #G
r={node.area_cm2
? Math.sqrt(paintingAreaScale(node.area_cm2) / Math.PI)
: paintingDefaultRadius}
/>
{/each}
現在,您應該會看到節點群集出現在月份軸的提示處。為了完成繪畫可視化,我們將根據圓圈相應繪畫的主題設置圓圈的顏色。該文件實用程序/主題.js包含可用的繪畫主題及其顏色的數組。在示例 14.13 中,我們聲明了一個序數尺度,它將主題作為輸入并返回相應的圓圈。然后,我們所要做的就是通過調用此刻度來設置圓圈的填充屬性。
<script>
import { scaleOrdinal } from "d3-scale";
import { subjects } from "../utils/subjects";
...
const colorScale=scaleOrdinal() #A
.domain(subjects.map((d)=> d.subject)) #A
.range(subjects.map((d)=> d.color)); #A
</script>
{#each nodes as node}
<circle
cx={node.x}
cy={node.y}
r={node.area_cm2
? Math.sqrt(paintingAreaScale(node.area_cm2) / Math.PI)
: paintingDefaultRadius}
fill={colorScale(node.subject)} #B
/>
{/each}
我們已經完成了對繪畫的可視化!此時,您的可視化效果將類似于圖 14.15 中的可視化效果。
我們的下一步是繪制一個面積圖,可視化梵高每年完成的繪畫數量。在第 4 章中,我們學習了如何使用 D3 的形狀生成器來計算折線圖和面積圖路徑元素的 d 屬性。在這里,我們將使用與形狀生成器 lineRadial() 類似的策略,該策略在 d3 形狀模塊中可用。
與上一節一樣,我們希望考慮用于渲染可視化的三層 Svelte 組件。我們將在 Grid.svelte 中加載整個圖紙數據集,并計算一個月的最大圖紙數量。我們還將重新組織數據集以每年拆分信息,如清單 14.14 所示。我們將此信息傳遞給 GridItem.svelte,并初始化一個刻度,負責計算與許多繪圖對應的沿月軸的徑向位置(參見示例 14.15),并將所有這些信息傳遞給 Drawings.svelte,后者將繪制面積圖。
<script>
import drawings from "../data/drawings.json";
import { months } from "../utils/months";
...
const yearlyDrawings=[]; #A
years.forEach((year)=> { #A
const relatedDrawings={ year: year, months: [] }; #A
months.forEach((month)=> { #A
relatedDrawings.months.push({ #A
month: month, #A
drawings: drawings.filter(drawing=> #A
drawing.year===year.toString() && #A
drawing.month===month), #A
}); #A
}); #A
yearlyDrawings.push(relatedDrawings); #A
}); #A
const maxDrawings=max(yearlyDrawings, d=> #B
max(d.months, (i)=> i.drawings.length) #B
); #B
</scrip>
<svelte:window bind:innerWidth={windowWidth} />
{#if svgWidth && svgHeight}
<svg width={svgWidth} height={svgHeight}>
{#each years as year, i}
<g
transform="translate(
{(i % numColumns) * smWidth},
{Math.floor(i / numColumns) * smHeight})"
>
<rect x={0} y={0} width={smWidth} height={smHeight} />
<GridItem
{smWidth}
{smHeight}
{year}
{paintingAreaScale}
{paintingDefaultRadius}
paintings={paintings.filter((painting)=>
painting.year===year)}
{maxDrawings} #C
drawings={yearlyDrawings.find((d)=> d.year===year).months} #C
/>
</g>
{/each}
</svg>
{/if}
<script>
import Drawings from "../chart_components/Drawings.svelte";
export let maxDrawings;
export let drawings;
...
$: radialScale=scaleLinear() #A
.domain([0, maxDrawings]) #A
.range([0, 2 * radius]); #A
</script>
<g transform="translate({smWidth / 2}, 0)">
<g transform="translate(0, {padding + radius})">
<circle ... />
...
<Drawings {drawings} {monthScale} {radialScale} /> #B
</g>
<text ... >{year}</text>
</g>
在清單 14.16 中,我們使用 D3 的 lineRadial() 方法來初始化一個線生成器。如第 4 章所述,我們設置其訪問器函數來計算每個數據點的位置。但是這一次,我們使用的是極坐標而不是笛卡爾坐標,因此有必要使用 angle() 和 radius() 函數。當我們將 path 元素附加到標記時,我們調用行生成器來設置其 d 屬性。在樣式中,我們給它一個半透明的填充屬性。
<script>
import { lineRadial, curveCatmullRomClosed } from "d3-shape";
export let drawings;
export let monthScale;
export let radialScale;
const lineGenerator=lineRadial() #A
.angle((d)=> monthScale(d.month)) #A
.radius((d)=> radialScale(d.drawings.length)) #A
.curve(curveCatmullRomClosed); #A
</script>
<path d={lineGenerator(drawings)} /> #B
<style lang="scss">
path {
fill: rgba($secondary, 0.25);
pointer-events: none;
}
</style>
圖14.16顯示了1885年的面積圖。
我們將可視化梵高作品的最后一部分是他每個月寫的信的數量。因為您現在擁有所有必需的知識,所以請自己試一試!
您可以自己完成此項目,也可以按照以下說明進行操作:
1. 在 Grid.svelte 中加載字母數據集。此數據集包含每月寫的信件總數。
2. 通過道具將當前年份對應的字母傳遞給 GidItem.svelte。
3. 在 GidItem.svelte 中,導入字母組件。將其添加到標記中,并將字母數據和比例作為道具傳遞。
4. 在 Letters.svelte 中,為每個月附加一行,然后根據相關字母的數量并使用三角函數設置行的端點。
如果您在任何時候遇到困難或想將您的解決方案與我們的解決方案進行比較,您可以在附錄 D 的 D.14 節和本章代碼文件的文件夾 14.5.5-Radial_bar_chart / 末尾找到它。但是,像往常一樣,我們鼓勵您嘗試自己完成它。您的解決方案可能與我們的略有不同,沒關系!
為了完成可視化的靜態版本,我們注釋掉之前用于查看網格布局的矩形和圓形并添加時間線。由于時間軸與 D3 沒有太大關系,因此我們不會解釋代碼,但您可以在附錄 D 的清單 D.14.4 和本章代碼文件的文件夾 14.5.4 中找到它。您也可以將其視為自己構建的挑戰!帶有時間軸的完整靜態布局如圖 14.7 所示。
現在我們的靜態項目已經準備就緒,必須退后一步,考慮未來的用戶可能想要如何探索它。他們將尋找哪些其他信息?他們會問哪些問題?我們可以通過互動來回答這些問題嗎?以下是用戶可能會提出的三個問題示例:
我們可以通過簡單的交互來回答這些問題:前兩個帶有工具提示,最后一個帶有交叉突出顯示。由于本章已經很長了,并且此類交互與 D3 無關(在框架中,我們傾向于避免使用 D3 的事件偵聽器,因為我們不希望 D3 與 DOM 交互),因此我們不會詳細介紹如何實現它們。本節的主要重點是為您提供一個示例,說明如何規劃對項目有意義的交互。您可以在在線托管項目 (https://d3js-in-action-third-edition.github.io/van_gogh_work/) 上使用這些交互,并在本章代碼文件的文件夾 14.6 中找到代碼。下圖也顯示了它們的實際效果。
我們的項目到此結束!我們希望它能激發您對可視化的創意。如果您想更深入地了解將 D3 與 Svelte 相結合以實現交互式數據可視化,我們強烈推薦 Connor Rothschild 的 Svelte 更好的數據可視化課程 (https://www.newline.co/courses/better-data-visualizations-with-svelte)。
篇文章就來介紹下如何使用 vue3 + ts + svg + ECharts 實現一個如下所示的雙十一數據大屏頁面:
執行命令 npm create vue@latest 創建基于 Vite 構建的 vue3 項目,功能選擇如下:
我選擇使用 pnpm 安裝項目依賴:pnpm i,各安裝包的版本號可見于下圖:
在 vite.config.ts 中添加配置,以便在項目啟動時能自動打開瀏覽器:
typescript
復制代碼
export default defineConfig({ // ... server: { open: true } })
現在,就可以通過 pnpm dev 啟動新建的項目了。
大屏適配的方案有很多,比如 rem、vw 和 flex 布局等,我選擇使用縮放(scale)的方式來適配大屏,因為該方案使用起來比較簡單,也不用考慮第三方庫的單位等問題。
假設設計稿的尺寸為 1920 * 1080px,為了保證效果,在大屏中放大時應該保持寬高比 designRatio 不變,designRatio 為 1920 / 1080 ≈ 1.78。放大的倍數 scaleRatio,可以分為以下 2 種情況計算:
具體代碼我封裝成了一個 hook:
// 屏幕適配,src\hooks\useScreenAdapt.ts
import _ from 'lodash'
import { onMounted, onUnmounted } from 'vue'
export default function useScreenAdapt(dWidth: number=1920, dHeight: number=1080) {
// 節流
const throttleAdjustZoom=_.throttle(()=> {
AdjustZoom()
}, 1000)
onMounted(()=> {
AdjustZoom()
// 響應式
window.addEventListener('resize', throttleAdjustZoom)
})
// 釋放資源
onUnmounted(()=> {
window.removeEventListener('resize', throttleAdjustZoom)
})
function AdjustZoom() {
// 設計稿尺寸及寬高比
const designWidth=dWidth
const designHeight=dHeight
const designRatio=designWidth / designHeight // 1.78
// 當前屏幕的尺寸及寬高比
const deviceWidth=document.documentElement.clientWidth
const devicHeight=document.documentElement.clientHeight
const deviceRatio=deviceWidth / devicHeight
// 計算縮放比
let scaleRatio=1
// 如果當前屏幕的寬高比大于設計稿的,則以高度比作為縮放比
if (deviceRatio > designRatio) {
scaleRatio=devicHeight / designHeight
} else {
// 否則以寬度比作為縮放比
scaleRatio=deviceWidth / designWidth
}
document.body.style.transform=`scale(${scaleRatio}) translateX(-50%)`
}
}
最后是給 body 添加了 transform 屬性,為了實現居中效果,還需要給 body 添加上相應樣式:
/* \src\assets\base.css */
* {
box-sizing: border-box;
}
body {
position: relative;
margin: 0;
width: 1920px;
height: 1080px;
transform-origin: left top;
left: 50%;
background-color: black;
}
為避免改變屏幕尺寸時過于頻繁觸發 AdjustZoom,我借助 lodash 的 throttle 方法做了個節流,這就需要安裝 lodash:pnpm add lodash。因為用到了 ts,如果直接引入使用 lodash 會遇到如下報錯:
我們需要引用它的聲明文件,才能獲得對應的代碼補全、接口提示等功能。提示里已經告訴了我們解決辦法,就是去安裝 @types/lodash:pnpm add -D @types/lodash,之后就能在 ts 文件中正常使用 lodash 了。
頁面頭部使用的就是一張 svg,樣式中給 #top 添加絕對定位 position: absolute; ,目的在于開啟一個單獨的渲染層,以減少之后添加動畫造成的回流損耗:
<template>
<main class="main-bg">
<div id="top"></div>
</main>
</template>
<style scoped>
#top {
position: absolute;
width: 100%;
height: 183px;
background-size: cover;
background-image: url(@/assets/imgs/top_bg.svg);
}
</style>
作為背景引入的 top_bg.svg 是我使用 Illustrator 繪制后導出的,繪制時注意做好圖層的命名:
因為圖層的名稱會影響到導出的 svg 文件中元素的 id 名稱。另外導出的 svg 文件中也可能存在一些中文命名或一些不必要的代碼,我們可以自行修改:
使用 Illustrator 繪制的都是靜態圖形,現在我們以其中一個圓球為例,添加上平移的動畫以及高斯模糊的濾鏡:
<!-- top_bg.svg 部分代碼 -->
<?xml version="1.0" encoding="UTF-8"?>
<svg id="top-bg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1920 183">
<defs>
<style>
#circle-1 {
opacity: 0;
transform: translate(800px, -18px) scale(0.5);
animation: circle-1-ani 1.8s ease-out forwards infinite;
}
@keyframes circle-1-ani {
90%,
100% {
opacity: 0.95;
transform: translate(600px, 80px) scale(1);
}
}
</style>
<filter id="blurMe">
<feGaussianBlur stdDeviation="2" />
</filter>
</defs>
<circle id="circle-1" class="cls-1" r="12.96" filter="url(#blurMe)" />
</svg>
動畫使用 css 定義,可以直接寫在 <defs> 里的 <style> 中。一旦用到 transform,那么圓的坐標系就會移動到圓的中心點,所以我將原本 <circle> 中的用于定義圓心坐標的 cx 和 cy 屬性刪除了,通過在 #circle-1 中直接使用 transform: translate(800px, -18px); 來定位圓的初始位置:
濾鏡定義在 <defs> 里的 <filter> 中,使用的是高斯模糊 <feGaussianBlur>, stdDeviation 用于指定鐘形(bell-curve),可以理解為模糊程度。在圓形 <circle> 上通過 filter 屬性,傳入濾鏡的 id 應用濾鏡。
首先是安裝 ECharts:pnpm add echarts。在 npm 的倉庫搜索 echarts 可以看到其帶有如下所示的 ts 標志:
說明它的庫文件中已經包含了 .d.ts 文件:
所以不需要像上面使用 lodash 那樣再去額外安裝聲明文件了。
接著就可以封裝 echarts 組件了。組件中只需要提供一個展示圖表的 dom 容器 <div>,然后在 onMounted(確保可以獲取到 dom 容器) 中創建一個 ECharts 實例 myChart,最后通過 myChart.setOption(option) 傳入從父組件獲取的圖表實例的配置項以及數據 option:
<!-- src\components\BaseEChart.vue -->
<template>
<div ref="mainRef" :style="{ width: width, height: height }"></div>
</template>
<script lang="ts" setup>
import * as echarts from 'echarts'
import { onMounted, onUnmounted, ref } from 'vue'
interface IProps {
width?: string
height?: string
chartOption: echarts.EChartsOption
}
const props=withDefaults(defineProps<IProps>(), {
width: '100%',
height: '100%'
})
const mainRef=ref(null)
let myChart: echarts.ECharts | null=null
onMounted(()=> {
myChart=echarts.init(mainRef.value, 'dark', { renderer: 'svg' })
const option=props.chartOption
myChart.setOption(option)
})
onUnmounted(()=> {
// 銷毀 echart 實例,釋放資源
myChart?.dispose()
})
</script>
以左上角的“人均消費金額排名”柱狀圖為例,代碼如下:
<!-- src\views\HomeView.vue -->
<template>
<main class="main-bg">
<div id="left-top">
<div class="title">人均消費金額排名</div>
<div class="sub-title">Ranking of per capita consumption amount</div>
<BaseEChart :chartOption="amountRankOption" />
</div>
</main>
</template>
<script setup lang="ts">
import BaseEChart from '@/components/BaseEChart.vue'
import { amountRankOption } from './config/amount-rank-option'
</script>
<style scoped>
#left-top {
position: absolute;
top: 130px;
left: 20px;
width: 500px;
height: 320px;
}
</style>
在頁面引入 BaseEChart 后,傳入定義好的 amountRankOption 即可:
// 人均消費金額排名柱狀圖配置
import * as echarts from 'echarts'
type EChartsOption=echarts.EChartsOption
export const amountRankOption: EChartsOption={
grid: {
top: 20,
bottom: 50,
left: 40,
right: 40
},
xAxis: {
axisTick: {
show: false // 隱藏 x 坐標軸刻度
},
data: ['思明', '湖里', '集美', '同安', '海滄', '翔安']
},
yAxis: {
axisLabel: {
show: false // 隱藏 y 坐標軸刻度標簽
},
splitLine: {
show: false // 隱藏平行于 x 軸的分隔線
}
},
series: [
{
type: 'bar',
data: [5, 20, 36, 10, 10, 20],
barWidth: 20 // 設置柱形的寬度
}
]
}
至于剩下的圖表的實現,只是配置不同而已,如有興趣可以去該項目的 git 倉庫查看。
最后添加成交額的數字滾動動畫,用到了 countup.js,需要先安裝: pnpm add countup.js。
使用時,直接 new CountUp() 生成 countUp,第 1 個參數為要添加動畫的 dom 的 id,第 2 個參數為動畫結束時顯示的數字,還可以傳入第 3 個參數 options 實現一些配置,比如設置前綴,小數點等。然后通過 countUp.start() 即可實現動畫效果:
<!-- src\components\Digital.vue -->
<template>
<div>
<span class="t1">成交額</span>
<span id="amount" class="t2">150</span>
<span class="t1">億</span>
</div>
</template>
<script lang="ts" setup>
import { CountUp } from 'countup.js'
import { onMounted } from 'vue'
onMounted(()=> {
const countUp=new CountUp('amount', 150)
if (!countUp.error) {
countUp.start()
} else {
console.error(countUp.error)
}
})
</script>
原文鏈接:https://juejin.cn/post/7305434729527181322
)實驗平臺:正點原子開拓者FPGA 開發板
2)摘自《開拓者 Nios II開發指南》關注官方微信號公眾號,獲取更多資料:正點原子
3)全套實驗源碼+手冊+視頻下載地址:http://www.openedv.com/docs/index.html
第十四uC/GUI顯示線/點實驗
我們在使用 Nios II 的時候會移植 uC/GUI 來制作精美的 UI,所謂 UI 就是 User Interface 的
縮寫、GUI 就是 Graphical User Interface 的縮寫,即圖形用戶接口。uC/GUI 是 Micrium 公司研
發的通用的嵌入式用戶圖像界面軟件,可以給任何使用圖像 LCD 的應用程序提供單獨于處理
器和 LCD 控制器之外的有效的圖形用戶接口,能夠應用于單一任務環境,也能夠應用于多任
務環境中。本章我們將向大家介紹如何在 Qsys 中移植 uC/GUI,并以 RGB 接口的 4.3 寸、
480*272 分辨率的 LCD 屏幕為例實現基本的打點畫線功能,本章包括以下幾個部分:
14.1 簡介
14.2 實驗任務
14.3 硬件設計
14.4 軟件設計
14.5 下載驗證
簡介
當前主流的小型嵌入式 GUI 主要有:emWin(uC/GUI)、TouchGFX、Embedded Wizard
GUI、uGFX 和 MicroChip GUI。當然,還有其它的 GUI,以上所列的 GUI 基本上都是收費的,
但由于 ST 公司購買了 emWin 的版權,得到了定制版的 emWin,然后改了名字叫 StemWin,
所以當用戶在 STM32 芯片上使用 emWin 軟件庫時,是不需要向 emWin 或 ST 公司付費的。也
正因為 STM32 在工商業的大范圍使用,使得 emWin 的使用場合更廣、學習資料也更多。另外
uC/GUI 和 emWin 還是有區別的。uC/GUI 的核心代碼并不是 Micrium 公司開發的,而是 Segger
公司為 Micrium 公司定制的圖形軟件庫,當然也是基于 Segger 公司的 emWin 圖形軟件庫開發
的。在以前較早的版本程序中 uC/GUI 的源代碼是開源的(可以在網上能夠找到),但是新版
本的程序 emWin 和 uC/GUI 只對用戶提供庫文件,是不開源的。這里為了方便對 GUI 感興趣
的讀者可以查看 uC/GUI 的底層,我們采用開源的 uC/GUI3.90 版本進行移植。那么 uC/GUI 有
什么特點呢?
μC/GUI 是一種用于嵌入式應用的圖形支持軟件,一種用于為任何使用一個圖形 LCD 的
應用提供一個高效率的,與處理器和 LCD 控制器無關的圖形用戶界面。它適合于單一任務和
多任務環境,專用的操作系統或者任何商業的實時操作系統(RTOS)。μC/GUI 以 C 源代碼形
式提供。它可以適用于任何尺寸的物理和虛擬顯示,任何 LCD 控制器和 CPU。
μC/GUI 很適合大多數的使用黑色/白色和彩色 LCD 的應用程序。它有一個很好的顏色管
理器,允許它處理灰階。μC/GUI 也提供一個可擴展的 2D 圖形庫和一個視窗管理器,在使用
一個最小的 RAM 時能支持顯示窗口。它的架構基于模塊化設計,由不同的模塊中的不同層組
成,主要包括:液晶驅動模塊,內存設備模塊,窗口系統模塊,窗口控件模塊,反鋸齒模塊和
觸摸屏及外圍模塊。其主要特性包括豐富圖形庫,多窗口、多任務機制,窗口管理及豐富窗口
控件類(按鈕、檢驗框、單/多行編輯框、列表框、進度條、菜單等),多字符集和多字體支持,
多種常見圖像文件支持,鼠標、觸摸屏支持,靈活自由配制等。另外 μC/GUI 可以在嵌入式系
統上運行也可以裸機運行。
μC/GUI 對內存的需求如下:
小的系統(沒有視窗管理器)
? RAM:100 字節
? 堆棧:500 字節
? ROM:10~25KB(取決于使用的功能)
大的系統(包括視窗管理器和控件)
? RAM:2~6KB(取決于所需窗口的數量)
? 堆棧:1200 字節
? ROM:30~60KB(取決于使用的功能)
注意,如果應用程序使用許多字體的話,ROM 的需求將增加。以上所有的數值都是粗略
的估計,根據實際的應用有所區別。
另外我們需要說明的一點是對于 μC/GUI 而言屏幕坐標如下:
圖 14.1.1 屏幕坐標
顯示平面由二維坐標 X 軸和 Y 軸表示,即值(X,Y)。水平刻度被稱作 X 軸,而垂直刻度被
稱作 Y 軸。在程序中需要用到 X 和 Y 坐標時,X 坐標總在前面。顯示屏(或者一個窗口)的
左上角為一默認的坐標(0,0)。正的 X 值方向總是向右;正的 Y 值方向總是向下。上圖說明
該坐標系和 X 軸和 Y 軸的方向。另外所有傳遞到一個 API 函數的坐標總是以像素(屏幕由能
夠被單獨控制的許多點組成,這些點被稱作像素)為單位所指定。大部分 μC/GUI 在它的 API
中向用戶程序提供的文本和繪圖函數能夠在任何指定像素上寫或繪制。
實驗任務
本章我們首先將 μC/GUI 移植到 Nios II 上運行,然后實現基本的打點畫線功能。
硬件設計
本章實驗工程可基于《lcd_all_Colorbar》實驗上搭建,所以這里我們直接在該實驗工程
上進行移植效果顯示。硬件設計部分不變,只需修改軟件設計部分。
軟件設計
我們打開軟件工程后,關閉原先的工程,新建一個工程,命名為 qsys_gui,然后將原先工
程的源代碼文件添加進來(APP 目錄),并將之前的 main.c 替換現在的 hello_world.c。現在我
們開始移植 uC/GUI。
在開始移植 uC/GUI 之前,我們建議大家最好先瀏覽一下《uC/GUI 中文手冊》,該手冊可
以在網上下載,也可在我們提供的軟件資料中找到,《uC/GUI 中文手冊》里面詳細的介紹了
uC/GUI 的所有 API 函數及相關例程,并提供了配置說明。通過閱讀《uC/GUI 中文手冊》,我
們可以進一步了解 uC/GUI,加快移植的速度,減少移植的彎路。下面我們就開始進行移植。
第一步:添加需要的功能文件。
首先我們新建一個名為 uCGUI 的文件夾,用來存放我們需要的 uCGUI 源碼,新建好文件
夾以后,我們將光盤中的 uCGUI3.90 源碼復制出來并解壓,解壓完成以后,我們可以看到該源
碼中有三個文件夾分別為:Sample 文件夾、Start 文件夾和 Tool 文件夾。首先,我們將 Start 文
件夾中的 Config 文件夾復制到 uCGUI 中,然后我們將 Start 文件夾下的 GUI 文件夾中的所有
文件夾都復制到 uCGUI 中,最后我們再將 Sample 文件夾下的 GUI_X 和 GUIDemo 這兩個文
件夾復制到 uCGUI 中,至此我們就完成了移植第一步,最終我們 uCGUI 文件夾中的內容,如
下圖所示。
圖 14.4.1 uCGUI文件夾中的內容
上圖各個文件夾的內容如下:
圖 14.4.2 文件夾內容詳解
其中 AntiAlias、JPEG、Mendev、Widget、WM 和 GUIDemo 為可選項,前四項可以依據
項目的需要而增刪,GUIDemo 是 uC/GUI 自帶的 Demo,如果不需要演示該 Demo,則可以不
添加。
第二步:修改相應的配置文件。
首先我們修改 Config 文件夾下的 GUIConf.h 文件,該文件修改后代碼如下所示。
1 #ifndef GUICONF_H
2 #define GUICONF_H
3
4 #define GUI_OS (0) /* 支持多任務處理 */
5 #define GUI_SUPPORT_TOUCH (0) /* 支持觸摸 */
6 #define GUI_SUPPORT_UNICODE (1) /* 支持 Unicode */
7
8 #define GUI_DEFAULT_FONT &GUI_Font6x8 /* GUI 默認字體 */
9 #define GUI_ALLOC_SIZE 12500 /* 動態內存的大小*/
10 //#define GUI_ALLOC_SIZE 1024*1024
11
12 #define GUI_WINSUPPORT 1 /* 支持窗口管理 */
13 #define GUI_SUPPORT_MEMDEV 1 /* 支持內存設備 */
14 #define GUI_SUPPORT_AA 1 /* 支持抗鋸齒顯示 */
15
16 #endif /* Avoid multiple inclusion */
該文件是GUI的基本屬性配置文件,它有一些開關可以配置,比如是否支持系統(GUI_OS),
是否支持觸摸(GUI_SUPPORT_TOUCH)等。如果我們需要支持系統,可將相應的值設為 1,
如果不需要就設為 0,此處我們不需要系統,所以將其設置為 0,其余以次類推。動態內存大
小 GUI_ALLOC_SIZE 可根據需求設置,這里我們設置為 12500,修改好該文件后,我們修改
Config 文件夾下的 GUITouchConf.h 文件,即 GUI 的觸摸配置文件,因為在 GUIConf.h 文件中
我們將觸摸的宏設置為 0,即不使用觸摸功能,所以無需配置該文件,但還是可以看一下該文
件的內容,如下所示。
1 #ifndef GUITOUCH_CONF_H
2 #define GUITOUCH_CONF_H
3
4
5 #define GUI_TOUCH_AD_LEFT 3750
6 #define GUI_TOUCH_AD_RIGHT 300
7 #define GUI_TOUCH_AD_TOP 420
8 #define GUI_TOUCH_AD_BOTTOM 3850
9 #define GUI_TOUCH_SWAP_XY 0
10 #define GUI_TOUCH_MIRROR_X 0
11 #define GUI_TOUCH_MIRROR_Y 1
12
13 #endif /* GUITOUCH_CONF_H */
該文件用來配置觸摸屏的一些參數,可根據實際需求來配置。接下來我們修改 Config 文
件夾下的 LCDConf.h 文件,該文件修改后代碼如下所示。
1 #ifndef LCDCONF_H
2 #define LCDCONF_H
3
4 /*********************************************************************
5 *
6 * General configuration of LCD
7 *
8 **********************************************************************
9 */
10
11 #define LCD_XSIZE (272) /* 配置 TFT 的水平分辨率 */
12 #define LCD_YSIZE (480) /* 配置 TFT 的垂直分辨率 */
13
14 #define LCD_BITSPERPIXEL (16) /* 每個像素的位數 */
15
16 #define LCD_CONTROLLER (666) /* TFT 控制器的名稱 */
17 #define LCD_FIXEDPALETTE (565) /* 調色板格式 */
18 #define LCD_SWAP_RB (1) /* 紅藍反色交換 */
19 // #define LCD_SWAP_XY (1)
20 #define LCD_INIT_CONTROLLER() LCD_L0_Init(); /* TFT 初始化函數 */
21
22 #endif /* LCDCONF_H */
該文件用來設置 TFT LCD 相關的參數,比如 TFT LCD 的分辨率、像素位數等,另外還可
以配置 TFT LCD 的寄存器(若有)和 TFT LCD 初始化入口等,這個文件與硬件直接相關,一
般是根據使用的 TFT LCD 來配置。
第三步:與硬件底層對接
TFT LCD 的對接:uC/GUI 自帶了很多驅動,支持很多屏幕,由于我們使用的 4.3 寸 RGB
TFT LCD 屏幕,uC/GUI 自帶的驅動中并沒有該屏幕的驅動,所以這里先將 uCGUI/LCDDriver
目錄下的文件先全部刪除,然后添加修改好的屏幕驅動文件 LCD_driver 文件,如下圖所示:
圖 14.4.3 與硬件底層對接文件
對該文件我們簡單的介紹下如果需要修改需要注意的事項。首先 TFT 控制器的名稱需要
對應。下圖 53 行的 LCD_CONTROLLER 應與我們上面修改的 LCDConf.h 里的#define
LCD_CONTROLLER 相對應。
圖 14.4.4 修改參數
其次對于不同的屏幕需要相應修改下面的函數。
圖 14.4.5 修改函數
另外如果需要觸摸支持的話,還需要觸摸的對接,因為這里我們不使用觸摸,就不做介紹。
第四步:添加到工程
現在,我們進行最后一步,將 uC/GUI 添加到工程中,實驗一下可行性。添加的方法很簡
單,將 uCGUI 文件夾復制到該工程目錄下,即 qsys/software/qsys_gui 文件夾下,然后我們在
Eclipse 軟件工程中刷新該工程(在左邊的工程欄按快捷鍵 F5,或右鍵點擊應用工程文件夾
qsys_gui 后點擊 fresh),當然了,我們也可以直接將我們的 uCGUI 文件夾粘貼至我們 Eclipse
軟件工程中的結構下。添加完成后,如下圖:
圖 14.4.6 添加uC/GUI
此時我們還不能使用 uCGUI,還需要將該文件夾的路徑添加到我們的工程中。添加方法如
下:我們右鍵點擊應用工程文件夾 qsys_gui,在彈出的菜單欄中點擊【Properties】菜單,彈出
屬性頁面如下圖,點擊 Nios IIApplication Properties 下的 Nios IIApplication Paths,在 Applicatuin
include directories 欄下點擊 Add…按鈕,將工程下的 uCGUI文件目錄下的子目錄除了 GUIDemo
外一個個添加進來(也可只添加我們需要的功能目錄)。
圖 14.4.7 添加子目錄
添加完成后,點擊“OK”按鈕即可。現在我們 ucgui 的移植基本完成。試一下基本的打點
畫線功能。
我們修改 qsys_gui.c 的代碼如下:
1 #include <stdio.h>
2 #include "system.h"
3 #include "io.h"
4 #include "alt_types.h"
5 #include "altera_avalon_pio_regs.h"
6 #include "sys/alt_irq.h"
7 #include "unistd.h"
8 #include <string.h>
9 #include "mculcd.h"
10 #include "GUI.h"
11
12 _lcd_gui lcdgui;
13 extern _lcd_dev lcddev; //管理 LCD 重要參數
14
15 //SDRAM 顯存的地址
16 alt_u16 *ram =(alt_u16 *)(SDRAM_BASE + SDRAM_SPAN - 2049000);
17
18 int main()
19 {
20 printf("Hello from NiosII!\n");
21
22 MY_LCD_Init(); //LCD 初始化
23 GUI_Init(); //uC/GUI 初始化
24
25 lcdgui.width =lcddev.height;
26 lcdgui.height =lcddev.width;
27
28 GUI_SetBkColor(GUI_VERYLIGHTCYAN); //設置 GUI 背景色
29 GUI_Clear(); //GUI 清屏
30
31 GUI_SetPenSize(10); //設置點的大小
32 GUI_SetColor(GUI_RED); //設置 GUI 前景色
33 GUI_DrawPoint(lcdgui.width/2,lcdgui.height/2); //畫點
34 GUI_DrawLine(0,lcdgui.height/2 + 11,lcdgui.width,lcdgui.height/2 + 11); //畫線
35
36 alt_dcache_flush_all();
37
38 return 0;
39 }
這里我們先設置了 GUI 背景色,然后清屏,此時清屏會以當前的背景色清屏。使用的帶
GUI 的函數都可從《uC/GUI 中文手冊》中找到,這里我們就不再做詳細的介紹。該代碼實現的
功能是在屏幕的正中間畫了一個點,然后在點的下面畫了一條橫線。
軟件部分就介紹到這里,接下來我們進行下載驗證。
下載驗證
講完了軟件工程,接下來我們就將該實驗下載至我們的開拓者開發板進行驗證。
首先我們將 4.3 寸的 ATK-4.3’RGBLCD 與開發板上的 RGB LCD 接口連接。再將下載器
一端連電腦,另一端與開發板上對應端口連接,最后連接電源線并打開電源開關。
我們在 Quartus II 軟件中將 lcd_all_colorbar.sof 文件下載至我們的開拓者開發板,下載完
成后,我們還需要在 Nios II SBT for Eclipse 軟件中將 qsys_gui.elf 文件下載至我們的開拓者開
發板,qsys_gui.elf 下載完成以后,我們的 C 程序將會執行在我們的開拓者開發板上。顯示的
效果如下圖所示。
圖 14.5.1 實驗結果圖
至此,我們的 uC/GUI 移植和實現打點畫線實驗就完成了。
*請認真填寫需求信息,我們會在24小時內與您取得聯系。