Wendy Shijia 的「 Escher's Gallery」可視化作品復現系列文章(二)

開始填坑。太多坑沒填以致可以從容選擇先填哪個,然而也忘了坑長什麼樣、怎麼填。不過還是希望該填的坑能儘量於月底前填完,畢竟拖到新的一年感覺也不好。

先填之前用 D3.js 復現 Wendy Shijia「Escher's Gallery/埃舍爾畫廊」 可視化作品的覆盤文章的坑。
網頁演示:https://desertsx.github.io/dataviz-in-action/02-eschers-gallery/index.htm
開源代碼(可點 Star 支持):DesertsX/dataviz-in-action

Wendy Shijia 的「 Escher's Gallery」可視化作品復現系列文章(一)裏,古柳寫到自己想起 CSS Tricks 上的 「Use and Reuse Everything in SVG… Even Animations!」 這篇文章,裏面實現了單個立方體/cube,並且使用 <use> 標籤複用立方體進行堆疊,“他山之石,可以攻玉”,於是想到可以用於「 Escher's Gallery」復現中。

簡單看下代碼實現思路:在 defs 標籤裏通過3個寬高21*24rect/長方形transform/變形拼出一個 cube,這一步是定義圖形,實際圖形不會顯示在 svg 中;

然後使用 use 標籤通過 xlink:href="#cube" 指定上一步定義的 cube,此時只需改變x/y座標,調用27次就能拼出一個 3*3*3 的大立方體。

值得注意的是:每一層x/y座標變化是有規律的,x以21的倍數移動,y以12的倍數移動,而每層之間y座標相差24,均和長方形寬高相關,可見佈局很簡單。

<svg viewBox="0 0 300 230" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <defs>
    <g id="cube" class="cube-unit">
      <rect width="21" height="24" fill="#00affa" stroke="#0079ad" transform="skewY(30)"/>
      <rect width="21" height="24" fill="#008bc7" stroke="#0079ad" transform="skewY(-30) translate(21 24.3)"/>
      <rect width="21" height="21" fill="#009CDE" stroke="#0079ad" transform="scale(1.41,.81) rotate(45) translate(0 -21)"/>
    </g>
  </defs>
  <!-- 三層從下往上擺 -->
  <!-- 最下層:每層裏面從上至下一行行排列下來 -->   <!-- x 以21的倍數移動 / y 以12的倍數移動 -->
  <use xlink:href="#cube" x="121" y="112"/>
  <use xlink:href="#cube" x="100" y="124"/>
  <use xlink:href="#cube" x="142" y="124"/>
  <use xlink:href="#cube" x="121" y="136"/>
  <use xlink:href="#cube" x="79" y="136"/>
  <use xlink:href="#cube" x="163" y="136"/>
  <use xlink:href="#cube" x="142" y="148"/>
  <use xlink:href="#cube" x="100" y="148"/>
  <use xlink:href="#cube" x="121" y="160"/>

  <!-- 中間層:每層在y上相差 24/height -->
  <use xlink:href="#cube" x="121" y="88"/>
  <use xlink:href="#cube" x="100" y="100"/>
  <use xlink:href="#cube" x="142" y="100"/>
  <!-- ... -->

  <!-- 最上層 -->
  <use xlink:href="#cube" x="121" y="64" />
  <!-- ... -->
</svg>

當然如果你眼尖的話,或許會注意到上面每個 recttransform 參數都不同,skewY/scale/rotate/translate 之間似乎沒啥關係,到底怎麼拼到一起的,看起來有點玄學,但暫且先這麼模仿着實現出來再說。

首先本次用的不再是簡單的長方形,而是缺了1/4的正方形,即多邊形,直接用 polygon 標籤給定6個頂點座標即可繪製出來,邊長暫定36,由最終圖表成圖效果決定是否進行調整。

<polygon id="unit-0" points="0,0 18,0 18,18 36,18 36,36 0,36" style="fill:#f25c3b;stroke:white;stroke-width:1" />

同理,畫出另外兩個多邊形,不斷調試transform的參數,拼到一起組成一個cube即可(其實調起來還是蠻繁瑣的,稍後介紹更優雅的實現方式),當然這裏每個多邊形unit都有各自id,實際也是對應埃舍爾的每件作品,所以最小元素是一個unit而不是一個cube,且unit順序依次爲上、左、右

<polygon id="unit-0" points="0,0 18,0 18,18 36,18 36,36 0,36" style="fill:#f25c3b;stroke:white;stroke-width:1" transform="scale(1.4,.8) rotate(-45) translate(-19.5, 132)" />
<polygon id="unit-1" points="0,0 36,0 36,36 18,36 18,18 0,18" style="fill:#ffc533;stroke:white;stroke-width:1" transform="skewY(30) translate(111, 22)" />
<polygon id="unit-2" points="0,0 36,0 36,18 18,18 18,36 0,36" style="fill:#5991c2;stroke:white;stroke-width:1" transform="skewY(-30) translate(147, 191.6)" />

拼出cube後,就可以把這段代碼放defs標籤裏,當然填充的顏色需要去掉,顏色在use使用時由綁定的作品數據類別來指定。

<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 18,0 18,18 36,18 36,36 0,36" style="stroke:white;stroke-width:1"
            transform="scale(1.4,.8) rotate(-45) translate(-19.5, 132)" />
        <polygon id="unit-1" points="0,0 36,0 36,36 18,36 18,18 0,18" style="stroke:white;stroke-width:1"
            transform="skewY(30) translate(111, 22)" />
        <polygon id="unit-2" points="0,0 36,0 36,18 18,18 18,36 0,36" style="stroke:white;stroke-width:1"
            transform="skewY(-30) translate(147, 191.6)" />
    </defs>
</svg>

至此,最基本的元素定義完成了,接下來就是結合數據,通過 D3.js 來生成所有 use 標籤,並傳入相應的x/y座標以及對應顏色,從而繪製出整個可視化作品即可。

不過古柳一開始完全就是對照着這張圖一路復現出來的,手頭沒有數據集,還需自行爬取。”雖說巧婦難爲無米之炊“,但在此之前,古柳想先構造僞數據把佈局搞定,實現出整體效果再說,免得萬一連佈局都搞不定,白花時間去爬數據。

僞數據構造也很簡單,470件作品就是470條數據,每件作品只取類型顏色,按照各自數量生成每種顏色,並打亂順序即可。


const colorScale = {
    'yellow': '#ffc533',
    'red': '#f25c3b',
    'blue': '#5991c2',
    'black': '#55514e',
    'green': '#5aa459',
    'grey': '#bdb7b7',
};

const piecesColor = d3.range(470).map(d => {
    if (d < 165) return '#ffc533';
    else if (d < 296) return '#f25c3b';
    else if (d < 411) return '#5991c2';
    else if (d < 462) return '#55514e';
    else if (d < 467) return '#5aa459';
    else return "#bdb7b7"
});
d3.shuffle(piecesColor);
console.log(piecesColor);

數據有了,就到了最核心的問題,該如何佈局了?而佈局無非就是要確定每個cube、每個unit的x/y座標,爲了簡化問題,這裏按照列和行來表示,如左上角的cube爲第一列第一行,以(1, 1)表示,依次從上到下,從左到右排列......因而需要知道每條數據的行列位置,比如圖中箭頭所指向的cube的列數和行數分別應該如何計算?

其實本質就是找規律、理清背後的計算公式,是個有點難度,但並不複雜的數學問題,感興趣的可以先不看後面內容,自己嘗試下解決,出錯的過程沒準也能看到很有趣的圖形。



首先,很明顯所有數據按照年齡被分成了7組;而每組內的cube的列數與行數是不僅取決於前幾組的行列數,而且與其在本組內的順序有關。下面簡單每個年齡組的unit個數,當然更好的方式是基於數據本身來計算每組年齡的作品數,這裏偷懶仍直接人工數下。

分成7個年齡組;一列最多8個cube共24個unit
1898-1917 = 14
1918-1927 = 28 * 3 + 1 = 85
1928-1937 = 7 * 8 * 3 + 6 = 174
1938-1947 = 62
1948-1957 = 27 * 3 = 81
1958-1972 = 14 * 3 - 1 = 41
Year Unknown = 13

1938-1947這組爲例,idxpiecesColor的索引值,即數據的順序,取值範圍爲0-469。前面3組共有273個unit(14+85+174=273)、有13列,對於這組內的unit,均需要減去前幾組的數量後再計算組內行列數:由於每列有24個unit,因而組內列數只需除24取整再加1即可,parseInt((idx-273)/24)+1,而組內行數則需除24取餘數,再考慮到cube要除3再加1即可,parseInt((idx-273)%24/3)

if (idx < 335) {
  group = 4;
  col = 13 + parseInt((idx - 273) / 24) + 1;
  row = parseInt((idx - 273) % 24 / 3) + 1;
}

由此寫出完整的獲取行列數的函數即可。

const getXY = (idx) => {
    let group;
    let groupIdx;
    let col;
    let row;
    if (idx < 14) {
        group = 1;
        col = 1;
        row = parseInt(idx % 24 / 3) + 1;
        groupIdx = idx;
    }
    else if (idx < 99) {
        group = 2;
        groupIdx = idx - 14;
        col = 1 + parseInt(groupIdx / 24) + 1;
        row = parseInt(groupIdx % 24 / 3) + 1;
    }
    else if (idx < 273) {
        group = 3;
        groupIdx = idx - 99;
        col = 5 + parseInt(groupIdx / 24) + 1;
        row = parseInt(groupIdx % 24 / 3) + 1;
    }
    else if (idx < 335) {
        group = 4;
        groupIdx = idx - 273;
        col = 13 + parseInt(groupIdx / 24) + 1;
        row = parseInt(groupIdx % 24 / 3) + 1;
    }
    else if (idx < 416) {
        group = 5;
        groupIdx = idx - 335;
        col = 16 + parseInt(groupIdx / 24) + 1;
        row = parseInt(groupIdx % 24 / 3) + 1;
    }
    else if (idx < 457) {
        group = 6;
        groupIdx = idx - 416;
        col = 20 + parseInt(groupIdx / 24) + 1;
        row = parseInt(groupIdx % 24 / 3) + 1;
    }
    else {
        group = 7;
        groupIdx = idx - 457;
        col = 22 + parseInt(groupIdx / 24) + 1;
        row = parseInt(groupIdx % 24 / 3) + 1;
    }
    return [col, row];
};

接着用 D3.js 生成所有 use 標籤即可。注意每列高度隔行相等,以及指定unitxlink:href時先直接簡單用總索引值除3取餘數(其實應該用組內索引值groupIdx除3取餘數),所以每組最後的cube可能略有問題。

// svg#chart
const svg = d3.select('#chart').append('g');
const cubeWidth = 36; // 40
svg.selectAll('use')
    .data(piecesColor)
    .join('use')
    .attr('xlink:href', (d, i) => i % 3 === 0 ? '#unit-0' : i % 3 === 1 ? '#unit-1' : '#unit-2')
    .attr('fill', (d) => d)
    .attr('x', (d, i) => getXY(i)[0] * 1.5 * cubeWidth - 80)
    .attr('y', (d, i) => 110 + getXY(i)[1] * 1.5 * cubeWidth + (getXY(i)[0] % 2 === 0 ? 0 : 0.75 * cubeWidth));

不過上述步驟主要目的是用僞數據大致理清計算公式、跑通整個佈局,所以略有瑕疵可以不用太在意。

截至目前,本次復現的難點其實都解決的差不多了,接下去無非就是爬取數據、替換掉僞數據,然後不斷將效果優化到和 Wendy 原始 Tabelau 版本相似即可,這些就留到下一篇文章再講好了。

最後再回過頭把上面不太優雅的cube實現改的優雅些。

其實古柳在復現完,和原作比對時(下圖爲原作,上圖爲自己復現的)才突然意識到自己的 cube 圖形比較扁,而 Wendy 作品裏每個 cube 中間的3條白線是差不多一樣長的,也就是說3個 unit 是相同大小的,完全可以用一個旋轉出另外兩個,只不過當時優化了太久,實在不想回過頭再去修改就暫不改進了。

Wendy Shijia 的「 Escher's Gallery」可視化作品復現系列文章(一) - 20201029一文發佈後,也將新的實現思路和 Wendy 交流了下。

「盤點這個月可視化的那些事 - 20201128」一文裏,古柳也提到11月17號晚上看到 Wendy 13號晚上的分享 VizConnect - Drawing Polygons in Tableau: The processing of making Escher's Gallary 錄播已經傳到油管,於是看了下 Escher's Gallary 作品背後創作過程以及 Wendy 如何繪製的多邊形,也確認了下實現方式和古柳所想大致相同。
鏈接:https://www.youtube.com/watch?v=5AqLHDtGtBg

一個unit由兩個不完整的正三角形排成,根據簡單的數學計算,可以得出所有頂點的座標,然後用 polygon 畫出一個unit,再分別轉動-120/120度就可以拼出一個cube

a=36b=18*Math.sqrt(3)取兩位小數帶入即可(需平移到畫布合適位置,方便查看)。

<polygon points="0,0 -31.18,-18 -15.59,-27 0,-18 15.59,-27 31.18,-18" style="fill:#f25c3b;stroke:white;stroke-width:1" transform="translate(100,100)" />

這樣更爲優雅的unit/cube就繪製出來了!

<!-- b=18*Math.sqrt(3)=31.18  b/2=15.59-->
<polygon points="0,0 -31.18,-18 -15.59,-27 0,-18 15.59,-27 31.18,-18" style="fill:#f25c3b;stroke:white;stroke-width:1" transform="translate(100,100)" />
<polygon points="0,0 -31.18,-18 -15.59,-27 0,-18 15.59,-27 31.18,-18" style="fill:#ffc533;stroke:white;stroke-width:1" transform="translate(100,100) rotate(-120)" />
<polygon points="0,0 -31.18,-18 -15.59,-27 0,-18 15.59,-27 31.18,-18" style="fill:#5991c2;stroke:white;stroke-width:1" transform="translate(100,100) rotate(120)" />

以上就是本文內容,如果大家還想看到更多幹貨,歡迎【點贊】、【評論】、【分享】,多多捧場,古柳也有持續創作的動力,畢竟這慘淡的閱讀量實在也是有點說服不了自己太頻繁更新,還真不是因爲懶。逃。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章