對於一個或一組具有時間相關的資料來說,如果我們對其沒有足夠的了解,與其盲目地直接對其做分析,我們也能選擇先對其做可視化的動作,而競賽圖則是一個相當普遍的呈現方式。
前言
競賽圖一般稱做Bar Chart Race,是一組有著不同類別或屬性的資料,隨著時間的變化,給出一個某一時刻,資料之間排名的呈現。
如下圖所示:(此動態圖是參考@johnburnmurdoch在Observable網站上分享的程式筆記,進行修改並執行後的結果,待會還會提到)
競賽圖顧名思義,由於圖表上的條狀結構會隨著時間進行變化,以及更迭,搭配適當的變化速度,能夠給予人一種競爭的感覺,就好像裡面的元素正在進行一場比賽一樣。其效果除了讓人印象深刻之外,我們也能夠一目瞭然,簡單方便地快速總覽不同事物在不同的時間下,有著哪些變化,並且還能輕鬆對不同元素進行比較。
時至今日,如果我們要做出這樣的競賽圖其實不難,雖然需要一些特殊的技巧與工具,我們在Windows系統常用的Excel也能做到。又或者是視覺化分析軟體Tableau,乃至一些數據視覺化網頁平台Flourish,都能夠輕易做到。在開發者的部分,也有許多人分享其作法與模板供人使用,像是在Observable這類型的筆記分享網站(主要用於資料視覺化)上,剛剛有提到一位作者@johnburnmurdoch提供了使用D3.js進行繪製的程式筆記。其中,D3.js又可稱為D3或者Data-Driven Documents,從名稱就能知道它是以數據驅動的程式碼,並且是一個JavaScript的函式庫,主要用於資料的視覺化與進行動態圖的繪製。好了,我掰不下去了,畢竟我本身並沒特別學過JavaScript,一般都是要實現什麼特定功能直接剪剪貼貼,稍微修修改改罷了。怕繼續說下去多錯,先打住了。
此外,Python也有許多繪圖的套件,一般大眾常用的便是matplotlib,根據不同的目的以及客製化的程度,我們有太多太多的工具能夠去選擇,並且實現競賽圖的繪製。
本系列主要是介紹我在學習繪製競賽圖上的經驗與解決方案,而本篇會先介紹如何使用Observable網站上分享的程式碼,而當然,我們開頭一直提到由@johnburnmurdoch分享的程式筆記,是我們這次的範例程式。由於D3.js歷經了幾次更新,當時bar-chart-race-the-most-populous-cities-in-the-world這篇程式筆記在新版已經無法使用,所以我想這個例子非常適合作為本篇的介紹。
開發環境
程式語言: python 3.6.8 64 bits
bar-chart-race-the-most-populous-cities-in-the-world
首先,我們可以先拜訪以下網址:
我們首先會發現一件事,出錯了!一般正常運作的話,其應該是能夠直接在該網站上跑起來的。而上面也有人提到版本的更新導致這份程式碼無法運作,語法已經不同。只是到目前為止,這份程式碼尚未有更新版本。
如下圖所示:
因此,我們就要想辦法讓它跑起來,做為本系列的起手式,練練手。
順帶一提,開頭也說過,我本身對JavaScript不熟,更別說其底下的函式庫了,感覺許多的封裝其實推出以及更新都蠻快的,一下子就又會有新的框架或是針對特定功能強化的函式庫被發表出來。
所以,在製作與籌劃這篇內容時,我也花了老半天在Google上搜尋不同的關鍵字,看看有沒有蛛絲馬跡能夠去突破,本篇也會多多少少分享一下我的過程與發現。
正式開始
我們接著直接透過網站上的功能,下載完整程式碼與說明文檔,步驟如下圖所示:
下載完沒意外會是一個.tgz的壓縮檔,我們把它解壓縮。
我們來看看它的結構:
其包含一個html檔,是首頁的部分,並且有一個observablehq/runtime的js檔、一個作者分享的主要程式碼a061f2083b7310ef@1205.js檔等等。
我們打開README.md,這邊順帶介紹一個好用的軟體,一般我們想要查看markdown的文本,除了使用像是pycharm內建的功能或是github等等程式碼、筆記分享網站,Typora也是一個很好用的工具。
打開文本之後,基本上會發現其主要是介紹如何在本地端運行這段程式,如下圖所示:
其中,我們主要會用到python而已,我們並沒想要做網站的開發,或者嵌入一些函式庫,單純想要run一下競賽圖,這就足夠了。
開啟網頁伺服器
上面提供的語法主要是python 2的,在python 3中,SimpleHTTPServer已經被整合進http.server,所以我們得要修改一下語法,自己變通一下。我們在剛剛這份程式碼的根目錄底下執行以下指令(以CMD的方式,記得切換目錄):
# using default setting.
python -m http.server
# specify the port.
python -m http.server 8080
執行結果如下:
註:我們使用瀏覽器進行測試的時候,因為瀏覽器一般都會對網頁進行緩存,所以可能我們即便是重啟伺服器,實際上內容並不會真的被重新刷新,所以建議使用無痕視窗打開,這樣無痕視窗關閉,並重啟後能夠確保環境夠乾淨。比較方便。
我們這邊是指定8080 port,所以瀏覽器上網址列需特別註明。雖然我們拜訪了網址,但我們會發現網頁無內容,並且跳出了一些錯誤。(在chrome瀏覽器上會遇到這個問題,但在舊版IE並沒有出現這個錯誤,雖然仍然網頁空白,應該是D3.js不支援的關係)
錯誤提示為
Failed to load module script: The server responded with a non-JavaScript MIME type of “application/octet-stream”. Strict MIME type checking is enforced for module scripts per HTML spec.
大體上就是跟你說瀏覽器有MIME type的檢查,伺服器在啟動的時候,我們需要進行相關的設定,好讓伺服器可以正常與瀏覽器進行溝通。
MIME type又稱為網際網路媒體類型(Internet media type)、MIME類型以及內容類型(Content type),主要用於對網路傳輸的檔案與內容進行分類。
這邊要注意一下,網頁回應都是正常的,代表沒有找不到資源等等的問題。如下圖所示:
所以我們稍微切換一下啟動伺服器的方式,我們寫一個python script檔,內容如下,主要就是針對不同的副檔名,指定其MIME type:
import http.server
import socketserver
PORT = 8080
Handler = http.server.SimpleHTTPRequestHandler
Handler.extensions_map = {
'.manifest': 'text/cache-manifest',
'.html': 'text/html',
'.png': 'image/png',
'.jpg': 'image/jpg',
'.svg': 'image/svg+xml',
'.css': 'text/css',
'.js': 'text/javascript',
'.module.js': 'module',
'': 'application/octet-stream', # Default
}
httpd = socketserver.TCPServer(("", PORT), Handler)
print("serving at port", PORT)
httpd.serve_forever()
我們直接執行這個script看看結果如何。
沒出意外的話,應該是會跑出以下結果:
到這邊,我們已經成功在本地端運行了該程式碼。而就像剛剛所提及的,這份程式碼使用的語法版本與新版的不相容,因此,錯誤仍在。修正語法
錯誤提示為
chart = TypeError: svg.append(…).attrs is not a function
這個錯誤是在a061f2083b7310ef@1205.js檔案中,如下圖所示:
由於其使用的.attrs()這個函式,用了很多次,所以我們考慮寫一個python script來幫它轉換為我們要的語法,會使用到正規表達式。
我們首先觀察一下,.attrs()函式的使用,如果其使用到複合的元素,會以逗號(,)做為分隔,並且會有類似key、value的關係,使用冒號(:)做分隔。
我們繼續觀察,如下圖所示:
我們會發現value值也不限定是一個固定的數值或字串,可以是另外一段語法。此外,.attrs()函式的使用,在這份檔案中,不會只有在物件屬性呼叫的結尾(若在結尾,最後會有分號(;)),我們必須記住各個部位會使用到的"字元",好讓我們去正確設定正規表達式。
這邊我們也要來介紹一個好用的網站,regex101:
Regular expression tester with syntax highlighting, explanation, cheat sheet for PHP/PCRE, Python, GO, JavaScript…regex101.com
regex101可以幫助我們很快地使用正規表達式來測試不同的字串。不只有提示說明,還有計算找到字串的步驟數、單元測試、產生不同語言的對應程式碼等等強大功能。這邊我們主要會使用到其人性化的顏色區塊標註功能,幫助我們判別正規表達式抓出來的關鍵字串。
相關資訊如下圖所示:
這邊主要的想法是,我們得保留縮排,雖然說縮排不怎麼影響運行,但會影響閱讀阿!!!日後debug也要考慮進來,而且目前可能不只這個錯誤要去修正。
再來是,每一組屬性都有著key與value。我們得注意key與value各自大致包含哪些字元。
剛剛提到的結尾分號(;)我們可以不管它,原本它該在哪個位置我們不去干擾。
形式長這樣,記住它,加強印象:
.attrs({
r: 10,
r2: 20,
})
我們想要將其替換為以下程式碼的結構:
.attr('class', 'cityMarker')
.attr('cx', d => projection([d.lon, d.lat])[0])
.attr('cy', d => projection([d.lon, d.lat])[1])
.attr('r', 3)
而雖然我們是要做替換的動作,正規表達式的使用上可以直接用re.sub()函式,但因為.attrs()函式的寫法包含著複合元素,所以這邊傾向分兩層進行目標子字串的擷取,也就是分內、外層。
外層自然是要抓出整個.attrs()函式的語法,內層則是要抓出key(s)與value(s)。
程式碼如下,我們會簡單介紹一下思考邏輯,細節部分就不詳細講了,只是字串處理罷了:
import re
js_path = './a061f2083b7310ef@1205.js'
output_js_path = './a061f2083b7310ef@1205_new.js'
outer_attrs_regex = r"([ ]*)(.attrs\({[\n ]*(([\w'-]+:[\n ]*[ \w'`\$,\-=><\.\(\)*\+\/\\{}\[\]]+[\n, ]*)*)}\))"
inner_attrs_regex = r"([\w'-]+):[\n ]*([ \w'`\$,\-=><\.\(\)*\+\/\\{}\[\]]+)"
if __name__ == '__main__':
with open(js_path, mode='r', encoding='utf-8') as f:
rows = ''.join(f.readlines())
print(rows)
# for attrs.
gets = re.findall(outer_attrs_regex, rows)
space_prefix = ''
for get_ in gets:
origin_substr = ''.join(get_[:2])
if ' ' in get_[0]:
space_prefix = get_[0]
get_ = get_[1:]
else:
space_prefix = ''
terms = re.findall(inner_attrs_regex, get_[0])
whole_str = []
for term in terms:
temp_str = term[1].strip()
if temp_str[-1] == ',':
temp_str = temp_str.strip()[:-1]
if "'" == term[0][0]:
str_ = ', '.join([term[0], temp_str])
else:
str_ = "'{}', {}".format(term[0], temp_str)
whole_str.append(space_prefix + '.attr(' + str_ + ')\n')
rows = rows.replace(origin_substr, ''.join(whole_str)[:-1])
我們首先設定目標檔案路徑,以及輸出的檔案名稱(路徑),當我們覆寫完直接寫出一個新檔,盡量不要直接覆蓋舊檔。
接著我們設定外層以及內層的正規表達式,外層考慮.attrs()函式之前的縮排空格數,接著擷取.attrs()函式的結構,需要注意空格、換行以及使用到的符號等等。內層專注於把每一組的key以及value內容擷取出來,最後重新進行組合。然後透過字串取代的方式,將原子字串替換為我們新產生的字串。如此,變能夠在其他語法結構不變的情況下,只對.attrs()函式進行替換。
這個是第一部分,替換完,檢查沒問題之後,我們需要開始寫檔,追加程式碼如下:
import re
js_path = './a061f2083b7310ef@1205.js'
output_js_path = './a061f2083b7310ef@1205_new.js'
outer_attrs_regex = r"([ ]*)(.attrs\({[\n ]*(([\w'-]+:[\n ]*[ \w'`\$,\-=><\.\(\)*\+\/\\{}\[\]]+[\n, ]*)*)}\))"
inner_attrs_regex = r"([\w'-]+):[\n ]*([ \w'`\$,\-=><\.\(\)*\+\/\\{}\[\]]+)"
if __name__ == '__main__':
...
with open(output_js_path, mode='w', encoding='utf-8') as f:
f.write(rows)
接著,由於我們產生新檔,我們需要修改一下同目錄底下的index.js檔。如下圖所示:
我們再次執行看看。執行結果如下圖所示:
我的老天鵝啊,又出現了下一個錯誤。
錯誤提示為
chart = TypeError: text.select(…).styles is not a function
看來不只有.attrs()函式得修正,現在還有.styles()函式。
還好,基本上想法是一樣的,我們需要將.styles()函式替換為.style()函式。至於為何要這樣,就要去了解D3.js的發展理念了,至少我目前是不知道。
那我們就只能繼續往下追加這部分的功能了,雖然功能相似,但我就不特別把這項功能包成函式了,重點還是清楚為優先。程式碼如下:(這個就是這部分的完整程式碼了)
import re js_path = './a061f2083b7310ef@1205.js' output_js_path = './a061f2083b7310ef@1205_new.js' outer_attrs_regex = r"([ ]*)(.attrs\({[\n ]*(([\w'-]+:[\n ]*[ \w'`\$,\-=><\.\(\)*\+\/\\{}\[\]]+[\n, ]*)*)}\))" inner_attrs_regex = r"([\w'-]+):[\n ]*([ \w'`\$,\-=><\.\(\)*\+\/\\{}\[\]]+)" outer_styles_regex = r"([ ]*)(.styles\({[\n ]*([\/ ]*([\w'-]+:[\n ]*[ \w'`\$,\-=><\.\(\)*\+\/\\{}\[\]?#:]+[\n, ]*)*)*}\))" inner_styles_regex = r"([\/ ]*)([\w'-]+):[\n ]*([ \w'`\$,\-=><\.\(\)*\+\/\\{}\[\]?#:]+)" if __name__ == '__main__': with open(js_path, mode='r', encoding='utf-8') as f: rows = ''.join(f.readlines()) print(rows) # for attrs. gets = re.findall(outer_attrs_regex, rows) space_prefix = '' for get_ in gets: origin_substr = ''.join(get_[:2]) if ' ' in get_[0]: space_prefix = get_[0] get_ = get_[1:] else: space_prefix = '' terms = re.findall(inner_attrs_regex, get_[0]) whole_str = [] for term in terms: temp_str = term[1].strip() if temp_str[-1] == ',': temp_str = temp_str.strip()[:-1] if "'" == term[0][0]: str_ = ', '.join([term[0], temp_str]) else: str_ = "'{}', {}".format(term[0], temp_str) whole_str.append(space_prefix + '.attr(' + str_ + ')\n') rows = rows.replace(origin_substr, ''.join(whole_str)[:-1]) # for styles. gets = re.findall(outer_styles_regex, rows) space_prefix = '' for get_ in gets: origin_substr = ''.join(get_[:2]) if ' ' in get_[0]: space_prefix = get_[0] get_ = get_[1:] else: space_prefix = '' terms = re.findall(inner_styles_regex, get_[0]) whole_str = [] for term in terms: if '/' in term[0]: continue elif ' ' in term[0]: term = term[1:] temp_str = term[1].strip() if temp_str[-1] == ',': temp_str = temp_str.strip()[:-1] if "'" == term[0][0]: str_ = ', '.join([term[0], temp_str]) else: str_ = "'{}', {}".format(term[0], temp_str) whole_str.append(space_prefix + '.style(' + str_ + ')\n') rows = rows.replace(origin_substr, ''.join(whole_str)[:-1]) with open(output_js_path, mode='w', encoding='utf-8') as f: f.write(rows)
我們再次執行看看吧~~~。執行結果如下圖所示:
果然沒那麼簡單呢~,闖完一關又一關,關關難過我們還是得關關過。
錯誤提示為
chart = TypeError: textObject.transition is not a function
很明顯,到目前為止,出現的錯誤都是找不到其所提示相對應的屬性。這個transition()函式算是重點,其負責幫我們製作動畫的效果,從初始狀態,隨著時間,變化至最終狀態。說實在的,沒有屬性,除了語法錯誤之外,也可能是因為忘記或沒有賦予給它,導致textObject物件一誕生就缺東缺西,非常可憐。
所以這個步驟,我們不需要再使用到python script來大量修改語法了,我們只需要手動添加必要的函式庫。如下圖所示:
我們找到a061f2083b7310ef@1205_new.js檔案,這是我們新產生的檔案,之後就以這份檔案為主了。將游標移到最下方,也就是檔案的最後,我們添加'd3-transition'。添加完差不多就大功告成。
我們在執行看看吧。執行結果如下圖所示:
底下我們能夠看到、展開各物件的屬性與內容。
經驗分享
說實話,第一個錯誤,也就是.attrs()函式的部分,很快就能找到解法,而有了這個解法,第二個錯誤,.styles()函式也能很快被修正,因為很容易能聯想到是同樣邏輯。
而到了第三個錯誤的時候,transition()函式讓我搞了老半天,這類的錯誤別人的解法多半是因為其運行的環境是npm(Node Package Manager),所以要不是修改設定檔,就是重新安裝或是更新至最新版本,但這些做法對有的人有效,對有人的卻又沒效,實在頭疼。但看起來比較好用的解法是特別指定要import的函式庫,因為可能被誤認為所有的依賴已經全部引入了,可能是透過設定檔的指定,或是在嵌入JavaScript語法中進行import,並稍微設定一下變數等等。追尋著這個想法,我打算從原檔案下手,果然讓我找到了與import相關的語法。可喜可賀啊~。
結論
本篇是繪製競賽圖系列的第一篇,做為練練手的目的,我想這個範例相當適合,除了能夠結合python,換言之,可能是各位也擅長或較熟悉的程式語言,進行開發與執行。也能夠多學學其它不同語言的語法,以及磨練debug的感覺。一步一腳印慢慢進步。
沒有留言:
張貼留言