Wendy Shijia 的「 Escher‘s Gallery」可视化作品复现系列文章(三)
 網(wǎng)頁(yè)演示:https://desertsx.github.io/dataviz-in-action/02-eschers-gallery/index.html
 開(kāi)源代碼(可點(diǎn) Star 支持):DesertsX/dataviz-in-action
Wendy Shijia 的「 Escher’s Gallery」可視化作品復(fù)現(xiàn)系列文章(一)
 Wendy Shijia 的「 Escher’s Gallery」可視化作品復(fù)現(xiàn)系列文章(二)
通過(guò)前兩篇文章,古柳拼湊出了一個(gè) cube,并且構(gòu)造偽數(shù)據(jù)將整體布局效果大致搞定,在第二篇文章最后古柳給出了更優(yōu)雅的、和 Wendy 原始方式一致的 unit/cube 實(shí)現(xiàn)代碼,不過(guò)新的實(shí)現(xiàn)在后續(xù)尺寸和布局上無(wú)法完全替換舊的實(shí)現(xiàn),“牽一發(fā)而動(dòng)全身”,已開(kāi)源的代碼需要較多改動(dòng),因而只能暫時(shí)按最初的實(shí)現(xiàn)來(lái)講解復(fù)現(xiàn)過(guò)程,感興趣的可以自行基于新的實(shí)現(xiàn)來(lái)修改。
 
書(shū)接上文,用偽數(shù)據(jù)搞定布局后,就該替換成真實(shí)數(shù)據(jù)了,其實(shí)想想 Wendy 的作品發(fā)布在 tableau public 上,仔細(xì)找下應(yīng)該也會(huì)有數(shù)據(jù)集,但沒(méi)準(zhǔn)需要下載 tableau 就有些麻煩,想著去原始網(wǎng)站爬取應(yīng)該也不難,就采取了寫(xiě)個(gè) Python 爬蟲(chóng)自行爬取的方案。
 鏈接:https://public.tableau.com/profile/wendy.shijia#!/vizhome/MCEschersGallery_15982882031370/Gallery
 
當(dāng)然爬蟲(chóng)不是重點(diǎn),爬取的數(shù)據(jù)也開(kāi)源了,大家直接關(guān)注可視化部分即可,這里簡(jiǎn)單看下源網(wǎng)站頁(yè)面結(jié)構(gòu)/數(shù)據(jù)情況:下圖分別是一個(gè)包含470個(gè)作品的列表頁(yè)和其中1個(gè)作品的詳情頁(yè),抽取出相應(yīng)數(shù)據(jù)即可。
 
 
存儲(chǔ)的數(shù)據(jù)格式如下,挺好懂,就不多余解釋了。
[{"id": 0,"url": "https://www.wikiart.org/en/m-c-escher/bookplate-bastiaan-kist","img": "https://uploads4.wikiart.org/images/m-c-escher/bookplate-bastiaan-kist.jpg","title": "Bookplate Bastiaan Kist","date": "1916","style": "Surrealism","genre": "symbolic painting"},... ]接下來(lái)就是本次復(fù)現(xiàn)的代碼部分,習(xí)慣看源碼的可直接去 GitHub 里閱讀即可。
開(kāi)源代碼(可點(diǎn) Star 支持):DesertsX/dataviz-in-action
雖然古柳也不喜歡在文章里大段大段貼代碼片段,但還是有必要簡(jiǎn)單講解下,自然看到這篇文章的讀者背景/基礎(chǔ)可能都不同,一定會(huì)有不少人不一定能完全看懂,本系列也并非 D3.js 入門(mén)教程,所以可能無(wú)法顧及所有讀者,雖然并沒(méi)有過(guò)于深?yuàn)W的地方,但若是有疑惑可評(píng)論或群里交流。
首先用的是 D3.js v5 版本,由于用到 d3.rollup() 方法,需要另外引入 d3-array.v2.min.js,如果用最新的 D3.js v6 版本就無(wú)需另外引入后者了。
<script src="../d3.js"></script> <script src="https://d3js.org/d3-array.v2.min.js"></script>HTML 頁(yè)面結(jié)構(gòu)并不復(fù)雜,主要是整個(gè)圖表 svg 部分加上交互顯示每件作品信息時(shí)的 tooltip。其中 svg 里放了上篇文章里實(shí)現(xiàn)的不太優(yōu)雅的三個(gè) unit 多邊形,后續(xù)用 D3.js 繪圖時(shí)通過(guò)生成 use 標(biāo)簽分別進(jìn)行調(diào)用即可。
<body><div id="container"><div id="main"><svg id="chart" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><polygon id="unit-0" points="0,0 16,0 16,16 32,16 32,32 0,32"transform="scale(1.4,.8) rotate(-45) translate(-20, 132.3)" /><polygon id="unit-1" points="0,0 32,0 32,32 16,32 16,16 0,16"transform="skewY(30) translate(111, 22)" /><polygon id="unit-2" points="0,0 32,0 32,16 16,16 16,32 0,32"transform="skewY(-30) translate(143, 187)" /></defs></svg></div><div id="tooltip" class="tooltip"><div class="tooltip-title" id="title"></div><div class="tooltip-date" id="date"></div><div class="tooltip-type" id="type"><span id='style'></span> | <span id='genre'></span></div><div class="tooltip-image" id="image"><img alt=""></div><div class="tooltip-url" id="url"><a target="_blank">go to link</a></div></div></div><script src="./app.js"></script> </body>app.js 里就是所有實(shí)現(xiàn)代碼,且都寫(xiě)在了 drawChart() 里。讀取數(shù)據(jù)并對(duì) date 年份以及作品類(lèi)型進(jìn)行處理。
async function drawChart() {const data = await d3.json("./data.json");const svg = d3.select("#chart");const bounds = svg.append("g");// console.log([...new Set(data.map((d) => d.style))]);// ["Surrealism", "Realism", "Expressionism", "Cubism", "Op Art", "Art Nouveau (Modern)", "Northern Renaissance", "Art Deco"]data.map((d) => {d.date = d.date !== "?" ? +d.date : "?";d.style = d.style === "Op Art" ? "Optical art" : d.style;d.style2 = ["Surrealism", "Realism", "Expressionism", "Cubism", "Optical art"].includes(d.style) ? d.style : "Other";});console.log(data);const colorScale = {"Optical art": "#ffc533",Surrealism: "#f25c3b",Expressionism: "#5991c2",Realism: "#55514e",Cubism: "#5aa459",Other: "#bdb7b7",};// more...}drawChart();style2 作品類(lèi)型會(huì)通過(guò) colorScale() 和顏色相對(duì)應(yīng),styleCount 會(huì)用于 drawStyleLegend() 繪制類(lèi)型圖例。這里用 d3.rollup() 統(tǒng)計(jì)各類(lèi)型的數(shù)量,其它實(shí)現(xiàn)方式亦可。
 鏈接:https://observablehq.com/@d3/d3-group
既然講到了圖例,就先看看類(lèi)型圖例的實(shí)現(xiàn),很常規(guī)的 D3.js 繪圖的內(nèi)容。
// style bar chartfunction drawStyleLegend() {const countScale = d3.scaleLinear().domain([0, d3.max(styleCount, (d) => d.count)]).range([0, 200]);const legend = bounds.append("g").attr("transform", "translate(1000, 40)");const legendTitle = legend.append("text").text("Number of artworks by style").attr("x", 20).attr("y", 10);const legendGroup = legend.selectAll("g").data(styleCount.sort((a, b) => b.count - a.count)).join("g").attr("transform", (d, i) => `translate(110, ${28 + 15 * i})`);const lengedStyleText = legendGroup.append("text").text((d) => d.style) // this's style2.attr("x", -90).attr("y", 6).attr("text-anchor", "start").attr("fill", "grey").attr("font-size", 11);const lengedRect = legendGroup.append("rect").attr("width", (d) => countScale(d.count)).attr("height", 8).attr("fill", (d) => colorScale[d.style]);const lengedStyleCountText = legendGroup.append("text").text((d) => d.count).attr("x", (d) => countScale(d.count) + 10).attr("y", 8).attr("fill", (d) => colorScale[d.style]).attr("font-size", 11);}drawStyleLegend();當(dāng)然實(shí)在不想自己從頭繪制圖例,也可以用 Susie Lu 的 d3 SVG Legend (v4) 庫(kù)。
 
接著,通過(guò) getXY() 函數(shù)返回作品 unit 布局時(shí)會(huì)用到的組內(nèi)順序、列數(shù)、行數(shù),在上一篇文章Wendy Shijia 的「 Escher’s Gallery」可視化作品復(fù)現(xiàn)系列文章(二)里已經(jīng)有過(guò)介紹,基本相同。
const getXY = (idx) => {let col;let row;if (idx < 14) {col = 1;row = parseInt((idx % 24) / 3) + 1;groupIdx = idx;} else if (idx < 99) {groupIdx = idx - 14;col = 1 + parseInt(groupIdx / 24) + 1;row = parseInt((groupIdx % 24) / 3) + 1;} else if (idx < 273) {groupIdx = idx - 99;col = 5 + parseInt(groupIdx / 24) + 1;row = parseInt((groupIdx % 24) / 3) + 1;} else if (idx < 335) {groupIdx = idx - 273;col = 13 + parseInt(groupIdx / 24) + 1;row = parseInt((groupIdx % 24) / 3) + 1;} else if (idx < 416) {groupIdx = idx - 335;col = 16 + parseInt(groupIdx / 24) + 1;row = parseInt((groupIdx % 24) / 3) + 1;} else if (idx < 457) {groupIdx = idx - 416;col = 20 + parseInt(groupIdx / 24) + 1;row = parseInt((groupIdx % 24) / 3) + 1;} else {groupIdx = idx - 457;col = 22 + parseInt(groupIdx / 24) + 1;row = parseInt((groupIdx % 24) / 3) + 1;}return [groupIdx, col, row];};通過(guò) drawArtwork() 函數(shù)生成所有作品的 use 標(biāo)簽,調(diào)用 defs 標(biāo)簽里的 unit,結(jié)合 getXY() 函數(shù)傳入正確的x/y坐標(biāo)及 unit id,繪制出圖表主體的內(nèi)容即可。注意每列高度隔行相等,簡(jiǎn)單處理下即可。
const cubeWidth = 32;// 2%3=2 parseInt(4/3)=1 or Math.floor(4/3)const artworkGroup = bounds.append("g").attr("class", "main-chart").attr("transform", `scale(1.12)`);function drawArtwork() {const artworks = artworkGroup.selectAll("use.artwork").data(data).join("use").attr("class", "artwork").attr("xlink:href", (d, i) =>getXY(i)[0] % 3 === 0? "#unit-0": getXY(i)[0] % 3 === 1? "#unit-1": "#unit-2").attr("fill", (d) => colorScale[d.style2]).attr("stroke", "white").attr("data-index", (d) => d.style2).attr("id", (d, i) => i).attr("x", (d, i) => getXY(i)[1] * 1.5 * cubeWidth - 80).attr("y",(d, i) =>110 +getXY(i)[2] * 1.5 * cubeWidth +(getXY(i)[1] % 2 === 0 ? 0 : 0.75 * cubeWidth));}drawArtwork();接著加上背景的空白 cube,古柳復(fù)現(xiàn)時(shí)還原了原作這部分效果,雖然可加可不加,偷個(gè)懶也沒(méi)事,但一開(kāi)始覺(jué)得沒(méi)準(zhǔn)這部分和埃舍爾的藝術(shù)風(fēng)格有關(guān),于是還是加上了。后來(lái)看 Wendy 關(guān)于該可視化作品的分享 「VizConnect - Drawing Polygons in Tableau: The processing of making Escher’s Gallary」,從中了解到背景這部分是最后才加上的,大概是 Wendy 覺(jué)得每組之間有空隙所以加上背景紋理進(jìn)行填充。
 
構(gòu)造需要添加空白 unit 的數(shù)據(jù),blankData 數(shù)據(jù)分成兩部分,一部分是每列上方和下方完整的那些 cube,即 d3.range(1, 24).map() 里遍歷的那些 x/y 行列位置,重復(fù)3次把3個(gè) unit 都列出來(lái),其中 rawMax 是每列的 cube 數(shù)、每列上方起始位置隔列不同、每列下方根據(jù) rawMax 里對(duì)應(yīng)的值把剩余的空白位置填滿(mǎn)即可;另一部分是每組年齡段最后一個(gè) cube 可能需要另外補(bǔ)充的那些 unit ,可通過(guò) specialBlank 列舉出所有特殊情況。最后同樣生成 use 標(biāo)簽以繪制出空白 unit 即可。
這里的實(shí)現(xiàn)不一定是最好的,可按照自己的思路實(shí)踐,僅供參考。
function drawBlankArtwork() {// bottom odd 9 / even 10const rawMax = [5, 8, 8, 8, 5, 8, 8, 8, 8, 8, 8, 8, 2, 8, 8, 5, 8, 8, 8, 3, 8, 6, 5, ];// console.log(rawMax.length); // 23const blank = [];d3.range(1, 24).map((d) => {// top odd 0/-1 / even 0d % 2 === 0? blank.push({ x: d, y: 0 }): blank.push({ x: d, y: 0 }, { x: d, y: -1 });// bottom odd 9 / even 10if (d % 2 === 0) {for (let i = rawMax[d - 1] + 1; i <= 10; i++)blank.push({ x: d, y: i });} else {for (let i = rawMax[d - 1] + 1; i <= 9; i++) blank.push({ x: d, y: i });}});let blankData = [];blank.map((d) => {// repeat 3 timesd3.range(3).map(() => blankData.push({ x: d.x, y: d.y }));});const specialBlank = [{ x: 1, y: 5, unit: 2 },{ x: 5, y: 5, unit: 1 },{ x: 5, y: 5, unit: 2 },{ x: 16, y: 5, unit: 2 },{ x: 22, y: 6, unit: 2 },{ x: 23, y: 5, unit: 1 },{ x: 23, y: 5, unit: 2 },];blankData = [...blankData, ...specialBlank];const blankArtworks = artworkGroup.selectAll("use.blank").data(blankData).join("use").attr("class", "blank").attr("xlink:href", (d, i) =>d.unit? `#unit-${d.unit}`: i % 3 === 0? "#unit-0": i % 3 === 1? "#unit-1": "#unit-2").attr("fill", "#f2f2e8").attr("stroke", "white").attr("stroke-width", 1).attr("x", (d) => d.x * 1.5 * cubeWidth - 80).attr("y",(d) =>110 + d.y * 1.5 * cubeWidth + (d.x % 2 === 0 ? 0 : 0.75 * cubeWidth));}drawBlankArtwork();然后每組加上文字信息。
function drawDateInfo() {const dateText = [{ col: 1, shortLine: false, age: "age<20", range: "1898-" },{ col: 2, shortLine: true, age: "20-29", range: "1918-1927" },{ col: 6, shortLine: true, age: "30-39", range: "1928-1937" },{ col: 14, shortLine: true, age: "40-49", range: "1938-1947" },{ col: 17, shortLine: false, age: "50-59", range: "1948-1957" },{ col: 21, shortLine: false, age: "60-69", range: "1958-1972" },{ col: 23, shortLine: false, age: "", range: "Year Unknown" },];const dateTextGroup = artworkGroup.selectAll("g").data(dateText).join("g");dateTextGroup.append("text").text((d) => d.age).style("text-anchor", "start").attr("x", (d, i) => d.col * 1.5 * cubeWidth + (i === 0 ? 34 : 42)).attr("y", 195).attr("font-size", 13);dateTextGroup.append("text").text((d) => d.range).style("text-anchor", "start").attr("x", (d, i) => d.col * 1.5 * cubeWidth + (i === 6 ? 30 : 35)).attr("y", 210).attr("fill", "grey").attr("font-size", 11);dateTextGroup.append("line").attr("x1", (d, i) => d.col * 1.5 * cubeWidth + 63).attr("x2", (d, i) => d.col * 1.5 * cubeWidth + 63).attr("y1", 215).attr("y2", (d) => (d.shortLine ? 246 : 270)).attr("stroke", "#2980b9").attr("stroke-dasharray", "1px 1px");}drawDateInfo();然后把標(biāo)題、下方文字描述等剩余部分都加上即可,都是些細(xì)枝末節(jié)的工作了,沒(méi)啥難度看源碼即可,這里就不放了。需要說(shuō)明的是下方文字內(nèi)容原本古柳用 HTML+CSS 實(shí)現(xiàn),但可能太菜總感覺(jué)效果不理想,最后也還是用 D3.js SVG text 等各種拼接出來(lái),也不夠優(yōu)雅、略顯冗余。
 
最后是加上交互,點(diǎn)擊每個(gè) unit 時(shí)顯示相應(yīng)作品數(shù)據(jù),點(diǎn)擊 svg 其余區(qū)域時(shí)隱藏 tooltip。交互也很簡(jiǎn)陋,有改進(jìn)空間。
const tooltip = d3.select("#tooltip");svg.on("click", displayTooltip);function displayTooltip() {tooltip.style("opacity", 0);}d3.selectAll("use.artwork").on("click", showTooltip);function showTooltip(datum) {tooltip.style("opacity", 1);tooltip.select("#title").text(datum.title);tooltip.select("#date").text(datum.date !== "?" ? datum.date : "Year Unknown");tooltip.select("#style").text(datum.style);tooltip.select("#genre").text(datum.genre);tooltip.select("#image img").attr("src", datum.img);tooltip.select("#url a").attr("href", datum.url);let [x, y] = d3.mouse(this);x = x > 700 ? x - 300 : x;y = y > 450 ? y - 300 : y;tooltip.style("left", `${x + 100}px`).style("top", `${y + 50}px`);d3.event.stopPropagation();}以上就是本文全部?jī)?nèi)容,真的只是簡(jiǎn)單的講下一些要點(diǎn),其實(shí)大家只要大致知道實(shí)現(xiàn)的思路,就完全可以靠自己的理解去復(fù)現(xiàn)了,古柳的復(fù)現(xiàn)代碼也有很多不足,僅供參考,仍有困惑的可以評(píng)論或群里交流。
如果大家還想看到更多干貨,歡迎【點(diǎn)贊】、【評(píng)論】、【分享】,多多捧場(chǎng),古柳也有持續(xù)創(chuàng)作的動(dòng)力,畢竟這慘淡的閱讀量實(shí)在也是有點(diǎn)說(shuō)服不了自己太頻繁更新,還真不是因?yàn)閼小L印?/p>
照例
歡迎加入可視化交流群哈。可加古柳微信「xiaoaizhj」備注「可視化加群」拉你進(jìn)群哈!
 
最后,歡迎關(guān)注古柳的公眾號(hào)「牛衣古柳」,以便第一時(shí)間收到更新。
 
總結(jié)
以上是生活随笔為你收集整理的Wendy Shijia 的「 Escher‘s Gallery」可视化作品复现系列文章(三)的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
                            
                        - 上一篇: 词汇挖掘与实体识别(未完)
 - 下一篇: 【小技巧】【Java】 创建指定数目m的