用D3.js製作視覺化的日常作息方塊圖(直播筆記)

本文翻自 [週四寫程式系列] – 來做視覺化日常作息的方塊圖吧!,若是對文章內容有疑問,或是想要老闆手把手帶你飛,都可以觀看影片跟著動手做,也附上這次成品

D3.js視覺化日常作息方塊圖成品
D3.js視覺化日常作息方塊圖成品

在資料科學中經常會面臨到的難關,就是資料看起來髒亂必須要重新整理,僅是物件或陣列,無法快速了解資料內容,透過資料視覺化,可以將資料轉換成另一種形式呈現,方便使用者閱讀。想做這個主題的緣由是因為,老闆會去紀錄每天的工作項目,想要利用這個工具,瞭解每天的工作內容及分布。

接下來老闆就要帶大家使用 D3 來達成資料視覺化,將原本無趣的資料,透過 D3 變成七個長條圖 ,一眼看出一周行事曆中每天各時段的工作分配。

製作的過程中可以分為兩個層次去思考:

  1. 一天的工作分配要怎麼畫?在繪製一天的行程中,帶大家認識 D3 繪製資料的方式。
  2. 一周的行程怎麼繪製?當完成一天後,我們只要將這個流程重複執行,就能產出一周的畫面。

此外,老闆也出了一個小題目給大家挑戰:如何實現多重選單中,項目不能重複。有興趣的同學可以在影片中,看看老闆如何解決這個問題。希望透過這篇文章讓大家發現 D3 真正的魅力,接著就讓我們拿起神奇的木槌 D3,將資料變得有趣吧!

如果對於老闆的教學方式有興趣,或是想看看老闆其他創作內容,歡迎大家去支持老闆的網頁程式入門或是網頁特效入門課程。

這次直播筆記會帶大家學會以下內容:

  • 利用 D3 繪製視覺化資料
  • 利用 js api ( map, filter )進行資料再處理
  • 製作一周工作項目產生器

事前準備

資料處理

《D3視覺化日常作息方塊圖》資料處理概念說明
《D3視覺化日常作息方塊圖》資料處理概念說明

資料視覺化之前,必須先準備好資料才能繼續,這邊會需要一周的行程,記錄每小時的工作項目。

第一步:先理解單日的行程,完成後就能將這個流程重複七次,達成需求。

第二步:將每天的行程(上圖左)群組化(上圖右),例如時段 1~3 都在做 b 這件事,則產出的資料會有類似以下結構。

var day = [
  {
    name: 'b',
    startTime: 1,
    endTime: 3,
  },
  ...
]

了解資料處理的邏輯後,需要原始的資料檔才能有下一步的畫面處理與呈現,可以利用關鍵字查詢 csv to json,將完成的一周行事曆檔案(excel)匯出成 csv 檔,再透過線上工具轉成 json 格式,這邊提供老闆使用的線上工具。如果想直接進入開發,除了可以使用下方老闆提供的24小時 raw_data 外,後面的內容老闆也會教大家亂數產生資料的方法。

var raw_data = [
  {
    "time": 0,
    "thing": "工作"
  },
  {
    "time": 1,
    "thing": "工作"
  },
  {
    "time": 2,
    "thing": "工作"
  },
  {
    "time": 3,
    "thing": "睡覺"
  },
  {
    "time":4,
    "thing": "睡覺"
  },
  {
    "time": 5,
    "thing": "睡覺"
  },
  {
    "time": 6,
    "thing": "睡覺"
  },
  {
    "time": 7,
    "thing": "工作"
  },
  {
    "time": 8,
    "thing": "睡覺"
  },
  {
    "time": 9,
    "thing": "睡覺"
  },
  {
    "time": 10,
    "thing": "做作業"
  },
  {
    "time": 11,
    "thing": "做作業"
  },
  {
    "time": 12,
    "thing": "工作"
  },
  {
    "time": 12,
    "thing": "工作"
  },
  {
    "time": 13,
    "thing": "工作"
  },
  {
    "time": 14,
    "thing": "睡覺"
  },
  {
    "time": 15,
    "thing": "睡覺"
  },
  {
    "time": 16,
    "thing": "工作"
  },
  {
    "time": 17,
    "thing": "工作"
  },
  {
    "time": 18,
    "thing": "睡覺"
  },
  {
    "time": 19,
    "thing": "散步"
  },
  {
    "time": 20,
    "thing": "工作"
  },
  {
    "time": 21,
    "thing": "工作"
  },
  {
    "time": 22,
    "thing": "散步"
  },
  {
    "time": 23,
    "thing": "工作"
  }]

開發環境

開發使用 codepen 線上撰寫程式碼,大家可以先將環境設定成跟老闆一樣,如果想知道較詳細的設定,可以參考成品老闆的設定。

會使用到 ES6 的語法,增加開發的效率

  • html: Pug
  • javascript:
    • Babel:為了加速開發的時間,我們會使用到 ES6 的語法
    • D3.js

接下來會使用到的 API:

這次專案會使用以下的 api,先重點整理給大家,不清楚的地方,可以一步步跟著老闆操作,了解每個 api 使用時機。

D3

  • .range(1, 5):產出陣列,陣列內為 [ 1, 2, 3, 4 ]
  • 選擇器
    • .select(“body”):取得 body 這個 tag (可填入其他值來取得其他目標物)。
    • .selectAll(“rect.num”):選擇畫面全部的 rect 且含有 class=”num” 的 tag。
  • .data(data1):將資料 data1 填入。
  • 資料綁定 tag 的方式:
    • .enter():綁定資料時,選取的元素不夠綁定時,該筆資料會被分配為 enter 類型,不足的 tag 可以搭配 .append 使用。
      • .append(“svg”):插入一個 svg tag (可選擇其他 tag)
    • .exit():綁定資料時,選取的元素多於資料時,該元素會被分配為 exit 類型。
      • .remove():刪除符合條件的元素,可以搭配 .exit() 刪除多餘的元素。
    • .update() 將新的資料直接覆蓋到對應的元素上。
  • .attr(“width”, 700) 為目標物賦予寬 700px 的屬性 (可填入其他值,賦予其他動態屬性,例如:邊框、顏色、x 座標…等)。
  • .domain(ary1):可以與 .range(ary2) 搭配,將定義的資料 ary1 對應到 range 中的 ary2。

javascript

  • .filter(d, i, arr):將原本陣列中符合條件的值回傳成新陣列,文件
    • d 為每次要處理的該筆資料
    • i 為該筆資料的索引值
    • arr 為原始的整串陣列
  • .map():把原本陣列中每個物件再處理後回傳組成新陣列。
  • .reduce():將一組陣列資料經由累加器,利用回呼函式,再處理成為單一值,文件

跟著老闆開始動手做

D3 是資料導向的工具,會針對一筆資料去做事情,也可以將 D3 理解成是一個工具,能對一組資料用同樣的顯示方法去展示資料,所以最後的程式的流程會是,將處理後的一周行事曆陣列資料,分成七天的群組資料渲染,再針對每一天的工作內容進行渲染,就能顯示視覺化的資料。

處理資料前,老闆先帶大家將 D3 這把槌子準備好,了解基本操作後,只要將資料填入,就能跑出我們要的結果。

D3 小試身手

進入製作前,老闆先帶大家畫出七個方塊,來熟悉 D3 的操作。

產出陣列 1~7

透過 D3 的 API 我們能夠快速產出一組陣列資料,如果使用 javascript 會寫了一大段 code。

// D3
var dataD3 = d3.range(1,8)
console.log(dataD3) // [1,2,3,4,5,6,7]

// javascript
var data = Array.from({length: 7}, function (d, i) {
  return i + 1
})
console.log(data) // [1,2,3,4,5,6,7]

// ES6
var dataES6 = Array.from({length: 7}, (d, i) =>i + 1)
console.log(dataES6) // [1,2,3,4,5,6,7]

資料與畫面綁定邏輯

在繪製畫面之前,讓我們先了解 D3 資料繪製到畫面的操作邏輯,假設今天有多筆資料,想要綁定到畫面中 class=”target” 的 div 上,第一步驟會先取得畫面符合規則的 DOM,接著會出現以下三種狀況與處理方式:

  • 指定 div 數量與資料數量相同:直接使用 update() 更新畫面
  • 指定 div 不存在或數量不夠:將未放入畫面的資料利用 enter() 存著,再使用 .append() 增加不足的 tag
  • 指定 div 數量多於資料數量:將多餘的 DOM 利用 .exit() 存著,使用 remove() 移除多餘的 DOM

產出 svg 圖表

有了資料後,我們也要產出畫面來將資料顯示,這邊要提醒大家 D3 是動態屬性,不是使用 css 改變外觀,而是使用 attr 來改變屬性。我們先存一個變數 svg 作為待會繪製圖表的位置,使用 api 選到 body,插入一個 DOM svg,將這個 DOM 的寬高皆設定成 700,畫面上看不到,這時去檢查開發者工具,會發現跑出一個新的 svg tag。

var data = d3.range(1,8)
var svg = d3.select("body")
            .append("svg")
            .attr("width", 700)
            .attr("height", 700)

有了資料和放這些畫面的位置(var = svg)後,我們來試著將前面產出的七筆資料放到畫面中,首先選擇畫面全部的 rect 且含有 class=”num” 的 tag,將前面產出的 data 使用 .data() 這個 api 插入畫面中。由於 svg 中目前完全沒有符合的 tag 來展示這些資料,我們使用 .enter() 定義這些新資料,並透過 .append() 來增加不足數量的 rect,最後賦予這些 tag 動態屬性。

其中比較特別的是 .attr(“x”, (d, i) => d*100 ),傳入的 function 第一個參數 d 是這筆資料內容,第二個參數 i 是這個參數的索引值。例如以陣列 [ 1, 2, 3 ] 來說,第一個傳入的 (d, i) 參數值分別是 d = 1,i =0。大家也可以將這些值 print 出來了解,譬如在這邊回傳 d * 50 作為 x 的值,就會做出七個水平間距為 50 的方塊了。

影片中老闆是使用 d * 100,由於一開始的 svg 寬度設定只有 700 ,會導致後面的方塊無法顯示,大家在實作上不用擔心。

svg.selectAll("rect.num")
  .data(data)
  .enter().append("rect")
  .attr("width", 50)
  .attr("height", 50)
  .attr("x", (d, i) => d * 100 ) // 與左上角的距離與方塊間距
  .attr("fill", "transparent") // 填色
  .attr("stroke", "black") // 框線
產出SVG七方塊
產出SVG七方塊

處理重複的標籤

我們來製作一整天幾種不同的工作項目,利用這個目標來練習 js 中的 filter 與 map,後面會頻繁地使用到這兩個功能。

我們可以拿前面老闆提供的 raw_data 來加工,首先只需要 raw_data 中做了什麼事情,什麼時間點做的對這個需求來說不重要。透過 map 會回傳新的陣列,其中的條件我們只要改成回傳 thing 即可。map 裡面的 function 為 ES6 的寫法,傳入參數為 o,為當下正在處裡的單筆資料,每次都會回傳 o.thing,所以會產出一個只有工作項目的陣列。

接著利用 filter 來篩掉重複的項目,filter 也會利用傳入的 function 作為規則,回傳一個新的陣列,傳入的三個參數分別為:

  • d : 目前處理的單筆資料
  • i :該資料的 index 值
  • arr:原始的陣列

回傳的條件為arr.indexOf(d) === i,indexOf 會從該陣列中找到第一個符合傳入條件的 index 值,所以當每筆資料在原始陣列搜尋時,和他自己的 index 值一樣,表示它是第一個出現的獨特值。利用這兩個 api 就能獲得所有工作的單一標籤。大家如果不清楚原因,可以在回傳的條件 return 前,console.log 將值 print 出來。

var new_data = raw_data.map((o) => o.thing )
                       .filter((d, i, arr) => arr.indexOf(d) === i)

相同工作的片段時間組合在一起

接下來要處理重複工作的時間段,將相同工作項目的連續時間段再處理成一個一個的群組。我們會使用到 reduce 將一天的行程處理成塊狀的陣列。首先讓我們了解 reduce 傳入的參數以及用法:

.reduce(回傳函式(累加器, 傳入的值, 傳入值的 index, 初始陣列), 初始值)

reduce 能將一個累加器及 array 中的每一個元素,透過回傳函式化為單一值。從下面的程式碼可以知道,我們將一個原始陣列,透過 reduce(),每次傳入兩個值,一開始累加器沒有值,b 為 1,第二次則處理累加器 1 與 2 後為 3,以此類推。

累加器圖示說明
累加器圖示說明
const ary = [1, 2, 3, 4, 5]
const result = ary.reduce((a, b) => a+b)
console.log(result) // 15

傳入 reduce 的參數除了處理的函數外,也可以傳入第二個參數 – 累加器的初始值。跟前面不同的地方是,我們最後想獲得一個陣列,所以將累加器初始值設定為 array。先取得累加器陣列中最後一個值。使用 slice 能將原始陣列依照傳入的數值進行陣列的切割,若是傳入 -1 ,則從原始陣列中的最後一個開始切割,並產生一個新的陣列包含原始陣列的最後一個值。

接著判斷最後做的事情 last_thing 的值,如果 last_el 存在,則最後做的事就是這件事情的 thing 值,也就是 last_el.thing ,否則代表這件事情不存在,也就是這個事件是新的群組。再來該如何判斷與前面的事件不同及如何處理呢?

  • 下一個時段的工作項目與目前時段工作項目一樣,結束時間增加
  • 上一個工作項目不存在或是與目前的工作項目不一樣,新增一筆新的資料,並將它組合進累加器

工作項目不同,表示這是一個新的工作項目,我們必須將它新增到陣列中,這邊使用到 .concat() 來將 accumulator array 與新的陣列合併。如果不清楚中間的操作,可以在處理過程中使用 console.log 去了解每個步驟值的變化。

var join_data = raw_data.reduce((accumulator, el, idx)=>{
  var last_el = accumulator.slice(-1)[0]
  var last_thing = last_el ? last_el.thing : null
  if (last_thing === el.thing) { // 上一件事情與現在的事情相同,調整結束時間
    last_el.end_hour = el.time
    return accumulator
  } else { // 上一件事情與現在的事情不同或是上一件事情不存在,新增一筆新的工作資料到累加器中
    return accumulator.concat([
      {
        thing: el.thing,
        start_hour: el.time,
        end_hour: el.time
      }
    ])
  }
}, []) // 累加器初始值為陣列

畫出一天的行程

有了處理過後的資料,就能夠將資料轉視覺化。前面有提到 D3 畫圖的方式,這邊依序跟大家解釋下面的程式碼:

  • 變數設定:將常用的單位設定成變數,包含單日圖表的寬度、單位小時的高度
  • 準備 svg 畫布:宣告畫布的方式如下,在 body 新增 svg ,並設定長寬
  • 資料填入:先選擇畫面中 svg 中所有有 hour class 的 rect 元件,將資料填入後,因為資料過多, rect 數量不夠,利用 enterappend 來增加數量,並賦予 class="hour" 的屬性
  • y 的起點:每個工作項目 y 座標的起始點都不同,利用開始時間 d.start_hour乘上 hour_height 決定每個 rect 的起始位置
  • rect 的寬高:有了初始位置,我們要將這些 rect 加上寬度及高度,寬度使用我們前面設定的 day_width,高度則是用結束時間 d.end_hour 減掉起始時間d.start_hour乘上單位小時高度 hour_height 即可,要注意的是,相減之後的數值要加上 1,因為若是結束時間和起始時間一樣,表示這個工作維持了一小時
  • 填色:將時段的 rect 加上黑色邊框
var day_width = 30
var hour_height = 20

var svg = d3.select("body")
            .append("svg")
            .attr("width", 700)
            .attr("height", 700)

var hour_rect = svg.selectAll('rect.hour')
                   .data(join_data)
                   .enter().append("rect")
                     .attr("class", "hour")
                     .attr("y", (d) => d.start_hour * hour_height)
                     .attr("width", day_width )
                     .attr("height", (d) => (d.end_hour - d.start_hour + 1) * hour_height )
                     .attr("stroke", "black")

完成之後就可以看到一條全黑的圖,接下來我們要來教大家如何填加上不同顏色

不同工作項目填上不同色

老闆這邊介紹兩個產出色碼的方式給同學

  • scaleLinear:將數值轉換為視覺變量,domain 內容為這個變量的值從多少到多少,range 的內容則是使用什麼值去對應,這邊是利用顏色去對應(圖左)。
  • scaleOrdinal:一個資料對應一個值,不用使用 domain 是因為他會直接依照順序,一個一個對應到後面 range 中的顏色(圖中),超過的值再從頭算起。大家也可以使用這個工具,直接使用 D3 提供的顏色填入 range (圖右)。
var colorize1 = d3.scaleLinear().domain([0,5]).range(["#f22", "#000"])
var colorize2 = d3.scaleOrdinal().range(["#f22", "#000"])
var colorize3 = d3.scaleOrdinal().range(d3.schemePaired)

var hour_rect = svg.selectAll('rect.hour')
  .data(join_data)
  .enter().append("rect")
    .attr("class", "hour")
    .attr("y", (d) => d.start_hour * hour_height)
    .attr("width", day_width )
    .attr("height", (d) => (d.end_hour - d.start_hour + 1) * hour_height )
    .attr("stroke", "black")
    .attr("fill", (d,i) => colorize3(i)) // 將填入的顏色換成變數代入
《D3視覺化日常作息方塊圖》三種不同的填色方式圖示
《D3視覺化日常作息方塊圖》三種不同的填色方式圖示

想達成「同種工作會對應到同一個顏色」,針對這個需求,我們要使用第二種方法。還記得前面有將工作整理成唯一值的陣列 new_data 嗎?利用 indexOf 就能取得這個值在 new_data 中的 index 值,再將這個值傳進前面使用 scaleOrdinal 做出來的工具,就能填上對應的顏色。

接著要在畫面中顯示文字,用前面產出 rect 方式,改產出一樣數量的 text 的元素。將 text 放到畫面中之後,會發現第一筆文字資料消失在畫面中了,因為 svg 文字對齊的屬性與一般文字的樣式不同,只要調整 y 的定位和 sass 樣式,按照下面去操作,文字就會在時段的框框中出現,大家也能按照自己的喜好去調整字級與位置。

JavaScript

var day_width = 80 // 調整單日寬度,讓文字能夠在 rect 內
var hour_height = 20

var hour_rect = svg.selectAll('rect.hour')
  .data(join_data)
  .enter().append("rect")
    .attr("class", "hour")
    .attr("y", (d) => d.start_hour * hour_height)
    .attr("width", day_width )
    .attr("height", (d) => (d.end_hour - d.start_hour + 1) * hour_height )
    .attr("stroke", "black")
    .attr("fill", (d,i) => colorize3(new_data.indexOf(d.thing))) // 改使用 indexOf 來作為 i值傳入

var hour_text = svg.selectAll('text.hour')
  .data(join_data)
  .enter().append("text")
    .attr("class", "hour")
    .attr("y", (d) => d.start_hour * hour_height + 5)
    .attr("x", 5)
    .attr("width", day_width )
    .attr("fill","black")
    .text(d => (d.thing))

CSS(Sass)

text
  alignment-baseline: hanging
  font-size: 12px
《D3視覺化日常作息方塊圖》完成一天的作息方塊圖
《D3視覺化日常作息方塊圖》完成一天的作息方塊圖

產生七天亂數資料

前面帶大家成功跑出一天的資料了,緊接著來準備七天的資料,讓神奇的 D3 槌子敲一下,產出一週的工作視覺化資料。

如何產出一天的亂數資料呢?先列出總共有哪些工作類別,並設定最後一件事為 null 值,在 generate_day_data 中,要亂數產生一組當天的工作項目。

首先, 利用 d3.range(0,24) 可以產出一個陣列內容涵蓋 0~23,利用 map 對這個陣列做加工,if 判斷式為,如果沒有上一件事情或是當下產生的 random 值大於 0.5,就從 catas 隨機中取得一個值做為 lastThing,這會讓下一件事情有一半的機會換值。利用 Math.random()parseInt() 可以產出新的事情,此時 raw_data 可以拿掉固定值,使用這個亂數產生器來產出一天的資料。

  • Math.random() 可以產出 0~1之間的亂數
  • parseInt() 可以將含有小數點的數值轉成整數
var catas = ["工作", "睡覺", "做專案", "吃東西", "散步"]
var lastThing = null
function generate_day_data () {
  return d3.range(0, 24).map((o)=>{
      if(!lastThing || Math.random() > 0.5) {
        lastThing = catas[parseInt(Math.random()*catas.length)]
      }
      return {
        time: o,
        thing: lastThing
      }
  })
}

能夠產生一天的資料後,接著應用前面學到的內容,就能產生一週的工作項目。使用 d3.range() 產出七個值,再利用 map 對每個值去操作,把前面處理 join_data 的內容改成 reduce_time() 傳入的參數為 arr ,之後就能將 generate_day_data 的值傳入,並將這組原始的 data,改裝成有分組的資料。

function reduce_time (arr) {
  return arr.reduce((accumulator, el, idx)=>{
    var last_el = accumulator.slice(-1)[0]
    var last_thing = last_el ? last_el.thing : null
    if (last_thing === el.thing) {
      last_el.end_hour = el.time
      return accumulator
    } else {
      return accumulator.concat([
        {
          thing: el.thing,
          start_hour: el.time,
          end_hour: el.time
        }
      ])
    }
  }, [])
}

var data_week = d3.range(0,7).map( () => reduce_time(generate_day_data()) )

繪製七天的行事曆

七天的資料準備好了,只要將這些資料繪製到畫面上就完成今天的任務。這邊我們增加一個變數 day_group,來做為七天的資料放置位置。並改寫後面的 hour_recthour_text,除了將 svg 換成 day_group 外,也將傳入的資料改成 (d) ⇒ d,讓七筆資料分別填入。D3 槌子一敲,繪製完成了。

var day_group = svg.selectAll("g")
  .data(data_week)
  .enter().append("g")
  .attr("transform",(d,i) => `translate(${ i * 100 })`)

var hour_rect = day_group.selectAll('rect.hour')
  .data((d) => d) // 改使用單日的資料傳入
  .enter().append("rect")
    .attr("class", "hour")
    .attr("y", (d) => d.start_hour * hour_height)
    .attr("width", day_width )
    .attr("height", (d) => (d.end_hour - d.start_hour + 1) * hour_height )
    .attr("stroke", "black")
    .attr("fill", (d,i) => colorize3(new_data.indexOf(d.thing)))

var hour_text = day_group.selectAll('text.hour')
  .data((d) => d) // 改使用單日的資料傳入
  .enter().append("text")
    .attr("class", "hour")
    .attr("y", (d) => d.start_hour * hour_height + 5)
    .attr("x", 5)
    .attr("width", day_width )
    .attr("fill","black")
    .text(d => (d.thing))
《D3視覺化日常作息方塊圖》繪製完成七天的作息方塊圖
《D3視覺化日常作息方塊圖》繪製完成七天的作息方塊圖

老闆來結語

回顧

再附上這次範例的成果,讓大家在實作時參考,讓我們來快速回顧一次製作流程:

  1. 了解 D3 繪製方法 filter, map, reduce 的用法
  2. 處理重複標籤,作為後續亂數產生一周行事曆使用
  3. 處理單日工作事項,相同工作的片段時間組合在一起
  4. 繪製一天的行程、不同工作項目對應不同顏色、加上文字
  5. 產生七天亂數資料
  6. 繪製七天的行事曆

萬事起頭難,一個作品不可能一步到位,將最終目標拆分成不同階段任務,從熟悉工具,繪製單日的行程到最後的七天行程,最後將這些工具組裝在一起,才有我們最後的成品。這次帶大家利用 D3 來將資料視覺化,大家也可以想想,自己手邊有什麼資料可以來讓 D3 這隻槌子敲一敲,變成一目了然的視覺圖,期待大家能用這次的教學做出有趣的應用。

如果你喜歡老闆的教學,歡迎加入老闆開的課程中一起學習,順便支持一下老闆,課程會帶你看看不一樣的作品,並引導大家一步步完成作品,透過每次的賞析、實作到修正作品,讓大家覺得寫 code 不是這麼難的事情,將這個過程想像成,拿一隻比較難的畫筆在進行創作,如果有機會使用它,便能夠在網頁上做出與眾不同的創作。

動畫互動網頁程式入門(HTML/CSS/JS)以簡單例子帶你入門網站的基礎架構及開發,用素材刻出簡單有趣又美觀的網頁和動畫,享受做出獨一無二的網頁所帶來的成就感,在職場上與設計師和工程師合作無間。

打好基本的互動網頁基礎之後,可以進階動畫互動網頁特效入門(JS/CANVAS),紮實掌握JavaScript 程式與動畫基礎以及進階互動,整合應用掌控資料與顯示的Vue.js前端框架,完成具有設計感的互動網站。長達3085分鐘,超過60個精緻範例與400張的投影片以上,以及四個加碼單元vue-cli、GSAP、D3、Three.js的投影片,成為hahow上最長的課程。

此篇直播筆記由幫手 H 協助整理

墨雨設計banner

分享
PHP Code Snippets Powered By : XYZScripts.com