2020 口罩存貨實時地圖開發紀錄

這篇文章主要是想記錄這次「2020 口罩存貨實時地圖」的開發過程。從 2/6 衛服部健保署釋出 API ,趕緊串接資料釋出簡易的存貨熱點,到今天開發告一段落,這中間從摸索地圖資料、理解 JavaScript ES6 的 Promise 等等,著實學到蠻多東西,也順便複習原生的 JS 語法。

目前開發成果如下圖,作品請參考連結,開源程式碼請詳見 GitHub ,更多資訊可查詢口罩供需資訊平台

2020 口罩存貨實時地圖

本文主要紀錄以下幾個技術面向:

  1. 使用 leaflet.jsOpenStreetMap 搭建地圖資料
  2. 透過 JavaScript ES6 Promise 語法串接 API
  3. 使用 Leaflet.markercluster 群集化資料
  4. 縣市/鄉鎮市區/關鍵字篩選功能實作
  5. 透過 Geolocation 取得使用者的位置資訊
  6. 收藏功能實作

好,我們開始吧!


使用 leaflet.js 與 OpenStreetMap 搭建地圖資料

leaflet.js 是一款開源的 JS 函式庫,可以快速搭建支援響應式的地圖介面,其官網中的 Leaflet Quick Start Guide 概略地介紹如何使用 leaflet.js 。

首先,透過 CDN 在 HTML 檔案的 <head> 區塊載入資源包:

1
2
3
4
<head>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"></script>
</head>

當然,也可以透過 NPM 來載入:npm install leaflet

在 HTML 中透過 id 來指向地圖所要搭建的區塊,CSS 中也要先指定高度:

1
<div id="map"></div>

1
#map { height: 100vh; }

接著,我們就能在 JS 中初始化地圖,並加入圖磚圖層(Tile Layer),所謂圖磚圖層,指的是地圖本身的圖層,而為何特地以「圖磚」形容,那是因為地圖資料都是由一張張正方型圖片所拼貼起來的,就像磚塊一樣,如下面這張圖,仔細觀察有縱橫交錯的線,那就是圖磚之間的間隙。

地圖資料是由一張張圖磚所拼貼而成。

回到正題。由於 leaflet.js 本身沒有地圖資料,所以必須透過第三方圖資來渲染圖層。官方建議的圖資有 OpenStreetMap(OSM)Mapbox ,其中,後者和 Google Map API 相同,需要串接個人註冊申請的 Access Token ,倘若流量超過,就必須綁定信用卡加以計費(台南好想工作室 Howard 的 60 萬帳單就是這麼來的)。因此,這裡選擇使用 OSM 的圖資 API 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 初始化
const map = L.map('map', {
center: [25.040065, 121.523235], // 台北市區的經緯度(地圖中心)
zoom: 10, // 地圖預設尺度
zoomControl: false // 是否顯示預設的縮放按鈕(左上角)
})

// 新增圖資圖層(OSM 圖資)
const osmUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
L.tileLayer(osmUrl, {
attribution: 'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> | 資料來源:<a href="https://data.nhi.gov.tw/Datasets/DatasetResource.aspx?rId=A21030000I-D50001-001&fbclid=IwAR11LdkhQPr1nyASKg0bUCx6LnIGY7KECOeVQ2EHwc67f2iKocIMuXRIpFE">衛生福利部中央健康保險署</a> | Created by <a href="https://www.facebook.com/luffychen0715?ref=bookmarks" target="_blank">Fei</a>',
minZoom: 8, // 最小縮法尺度
maxZoom: 18 // 最大縮放尺度
}).addTo(map);

如此一來,我們便完成地圖的初步搭建。當初用 OSM 搭建完地圖後,總覺得 OSM 的圖資塞滿太多圖例(Legend),很干擾主要資料的視覺化。所以我 Google 好一陣子有沒有客製化 OSM 圖例的方法,但解方似乎太過複雜而最後作罷。如果是以 Mapbox 作為圖資,其實是可以客製化圖例的,但我不想負擔 Mapbox API 的流量。

不過,幸運的是,有前輩看到我的初版作品,建議我可以透過 CSS 的 filter 來調整 OSM 的圖磚色調,降低背景干擾,也不失為一種解決方案。不愧是前輩,太神啦!

1
2
3
4
// OSM 圖資的對比與亮度
.leaflet-tile-pane {
filter: grayscale(0.7) contrast(0.5) brightness(0.7);
}

眼尖的朋友應該有發現,我取消了預設的地圖縮放鈕,因為我想客製化縮放鈕在地圖的右上角

1
2
3
4
// 自訂縮放按鈕位置
L.control.zoom({
position: 'topright'
}).addTo(map);

接著,我們可以試著在地圖上畫出一個座標:

1
2
3
L.marker([25.040065, 121.523235]).addTo(map)
.bindPopup('A pretty CSS3 popup.<br> Easily customizable.')
.openPopup();

.bindPopup() 的參數可以放入一段 HTML 字串, JS 會將它渲染成該座標的 Popup (冒泡視窗)元素。

如果想要換掉座標 Icon 的顏色,可以參考 Thomas Pointhuber 所製作的模板,或是自己準備圖檔( PNG 等等),客製化自己的 Icon 。我參考 Thomas 的寫法, Icon 的初始化封裝進一個建構函式,在分別創造不同的 Icon :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 建議使用 Thomas 設定的參數,沒有遇到 Icon 變形的問題。
function createIcon(name) {
return new L.Icon({
iconUrl: `./Assets/${name}.png`,
iconSize: [41, 41], // 根據 Icon 的大小自行調整
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
}

const greenIcon = createIcon('flag-green');
const orangeIcon = createIcon('flag-orange');
const redIcon = createIcon('flag-red');
const userPosIcon = createIcon('user');

至此,我客製化了理想中的地圖,接著處理資料的部分。


透過 JavaScript ES6 Promise 語法串接 API

這次要獲取的資料總共有兩份:

  1. 口罩即時存量資料,我用的是 Kiang 整理的 GeoJSON 檔案
  2. 全台縣市鄉鎮資料,我用的是 Donma 整理的台灣縣市/鄉鎮/地址中英文 JSON

這裡我使用 JS 原生的 AJAX(Asynchronous JavaScript and XML) XMLHttpsRequest 物件串接 API 來獲得資料。至於為什麼要使用 Promise 呢?這是為了確保 JS 執行完從遠端抓資料這個異步行為後,再繼續執行後面的事情。因為 JavaScript 本身的程式設計是單執行緒且同步執行的(JS 一次只能做一件事,一件一件執行)。

從開發的邏輯來思考,一定是先有資料,才能進行資料視覺化。為了避免 JS 進行資料視覺化時找不到資料的窘境(JS 尚未獲得資料,因為獲取資料需要時間),所以我們需要 Promise「保證」獲得資料這個異步行為執行完畢。

簡單來說, Promise 物件有三種狀態,分別是等待(Pending)實現(Fulfilled)以及拒絕(Rejected),串起來就是 Promise 的生命週期。

Promise 中,我們可以自定義異步行為。當 Promise 初始化(實體化)後,首先處於 Pending 狀態,並根據執行結果,將所獲取的資料傳入解決(resolve)拒絕(reject)(Callback Function),改變 Promise 物件的狀態,作為判斷異步行為的執行狀況。接著,再以 Promise.then() 定義後續的行動。

promise 語法與其生命週期

由於我們要取得兩份資料,代表有兩個 AJAX 異步事件需要執行。因此,我們可以使用 Promise.all() 來保證兩則異步事件執行完畢後, JS 再繼續往下執行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 將 Promise 初始化封裝成一個函式
function getXML(path) {
return new Promise((resolve, reject) => {
const xhrReq = new XMLHttpRequest();

xhrReq.onload = function() {
if(xhrReq.status == 200) {
const data = JSON.parse(xhrReq.response);
resolve(data); // 獲取資料後,將資料傳入 resolve() 。
} else {
console.log('抱歉,現在無法取的即時資訊!');
}
};
xhrReq.open('GET', path);
xhrReq.send();
})
}

const getCityDatas = getXML('./CityCountyData.json');
const getStoreDatas = getXML('https://raw.githubusercontent.com/kiang/pharmacies/master/json/points.json?fbclid=IwAR0RC0E5_D-1vZVHJX_wvm7VUvdHYYcGw2Q0sSk4ppxu1zvqh7hAWN0oHdU');

Promise.all([getCityDatas, getStoreDatas]).then(resultDatas => {
const cityDatas = resultDatas[0];
const storeDatas = resultDatas[1].features;
// ...
})

關於 Promise 語法的詳細解說,可以參考從Promise開始的JavaScript異步生活(點擊 Download PDF 即可下載),我覺得是一份很仔細的說明文件。


使用 Leaflet.markercluster 群集化資料

檢視從遠端取得的資料,可以知道這是由 6864 個物件所組成的陣列,一個物件儲存一家口罩販售據點的資訊,包括藥局名稱、電話、地址、口罩即時存量等等。

JSON 資料格式

試想,如果我們一次渲染 6864 個座標(包含 Popup 和 Icon )在地圖上,想必網頁載入時間會非常久(我用 Chrome 實測至少超過 30 秒)。所以,我們可以使用 Leaflet.markercluster 這個插件將資料群集化,在小比例尺時顯示較少資料,等到我們放大地圖時才顯示詳細的資料,避免一次渲染過多資料而拖慢網頁載入時間,也提升使用者體驗。

使用之前要先載入資源包:

1
2
3
4
5
6
7
8
<head>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.4.1/MarkerCluster.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.4.1/MarkerCluster.Default.css">
</head>
<body>
// ...
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.4.1/leaflet.markercluster.js"></script>
</body>

1
2
3
4
5
6
7
8
9
10
11
// 初始化一個 MarkerClusterGroup
const storeCluster = new L.MarkerClusterGroup({
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,

// 自訂群集樣式
iconCreateFunction: function(cluster) {
return L.divIcon({ html: `<div class="store-cluster">${cluster.getChildCount()}</div>` });
}
}).addTo(map);

群集的 Icon 可以透過 CSS 自訂樣式:

1
2
3
4
5
6
7
8
9
10
.store-cluster {
@include size(50px);
border-radius: 50%;
line-height: 50px;
font-size: 14px;
text-align: center;
font-weight: bold;
border: 1.5px solid rgba(#000, 0.7);
background-color: rgba(#fff, 0.5);
}


縣市/鄉鎮市區/關鍵字篩選功能實作

因為整個口罩地圖都是用原生 JS 開發的,所以縣市/鄉鎮市區的選項也是透過 JS 印製出來。

replace() 和 Regex 整理字串

台灣縣市/鄉鎮/地址中英文 JSON 檔案裡使用的是繁寫「臺」,我用 Regex 清理置換成 「台」字(其實直接改檔案就好了…):

1
const cityArray = cityDatas.map(item => item.CityName.replace(/臺/g,'台'));

刪除陣列中特定值的方法

資料裡面也包含「海南島」和「釣魚台」兩個地方,因為用不到,所以必須篩除。「刪除陣列中特定值的方法」其實有很多,這裡只記錄一個,我把它模組化成一個函式,得以重複使用:

1
2
3
4
5
6
7
8
9
10
function removeByValue(array, value) {
return array.forEach((item, index) => {
if(item === value) {
array.splice(index, 1);
}
})
}

removeByValue(cityArray, '南海島');
removeByValue(cityArray, '釣魚台');

刪除陣列中重複值的方法

有時候清理資料時,也會篩除陣列中重複的數值,這邊順道記錄一種方法:

1
2
3
const noRepeatCityArray = cityArray.filter((item, index, arr) => {
return arr.indexOf(item) === index;
})

用 include() 匹配選項和關鍵字

篩選功能中 DOM 元素的監聽事件是透過 change 來綁定回呼函式,就不多加贅述。值得一提的是「篩選邏輯」的部分,無論是選項或關鍵字,只要使用 filter()include() 就能實作出來:

1
2
3
4
5
6
7
8
9
10
11
12
// 透過縣市搜尋
matchedStore = stores.filter(store => store.properties.address.includes(citySelected));

// 透過縣市和鄉鎮市區搜尋
matchedStore = stores.filter(store => store.properties.address.includes(citySelected + areaSelected));

// 透過關鍵字搜尋
storeDatas.filter(store => {
if(store.properties.address.includes(searchValue) || store.properties.name.includes(searchValue)) {
// ...
}
})

將資訊樣板封裝成函式以便彈性呼叫

因為無論哪一種搜尋,都需要印出搜尋結果,既然重複好幾次,那就模組化成一個函式吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function createHTML(store, html, count, loved = false) {
let [bgAdultColor, bgChildColor] = ['#bfffbf', '#bfffbf']; // 綠
// ...
html += `
<div class="store-info p-3" data-lat="${store.geometry.coordinates[1]}" data-lng="${store.geometry.coordinates[0]}">
<div class="d-flex justify-content-between">
<h5 class="font-weight-bold mb-2">${store.properties.name}</h5>
<div class="love ${loved ? 'hide' : ''}"><i class="far fa-heart"></i></div>
<div class="loved ${loved ? 'show' : ''}"><i class="fas fa-heart"></i></div>
</div>
<p class="mb-1"><i class="fas fa-map-marker-alt"></i> <a href="https://www.google.com.tw/maps/place/${store.properties.address}" target="_blank">${store.properties.address}</a></p>
<p class="mb-2"><i class="fas fa-phone-alt"></i> ${store.properties.phone}</p>
<div class="masks-info">
<div class="mask-item" style="background-color: ${bgAdultColor}">成人口罩 <span>${store.properties.mask_adult}</span> 個</div>
<div class="mask-item" style="background-color: ${bgChildColor}">兒童口罩 <span>${store.properties.mask_child}</span> 個</div>
</div>
</div>
<hr class="m-0">
`;
count += 1;
return [html, count];
}

// 印出搜尋結果
[str, storeCount] = createHTML(store, str, storeCount);

// 印出收藏的藥局
[str, storeCount] = createHTML(store, str, storeCount, true);

其實這部分的重點在 include() 這個語法,它不僅是字串的方法,也是陣列的方法,太好用了!

另外一個重點也告訴我,還是趕快把框架學會吧(哭)。還有,陣列方法也要熟悉!(推薦閱讀:JavaScript Array 陣列操作方法大全 ( 含 ES6 )JavaScipt 陣列方法的 20 道陰影


透過 Geolocation 取得使用者的位置資訊

取得使用者地理位置資訊的方法也有很多種,這次嘗試的是地理位置定位 (Geolocation) 物件。透過 Geolocation API 取得使用者地理位置需要經過同意,當使用者進入網站時,瀏覽器就會跳出一個對話框詢問,如下圖。其他方法應該可以有客製化的 Modal ,未來有機會再研究。

Geolocation 取得地理位置須徵詢用戶同意

MDN 仔細介紹了 Geolocation 的用法,這裡就稍微紀錄一下程式碼,一樣,模組化成函式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function getUserPosition() {
// 檢查瀏覽器是否支援 Geolocation API
if(navigator.geolocation) {

// 定義成功的回呼函式
function showPosition(position) {
L.marker([position.coords.latitude, position.coords.longitude], {icon: userPosIcon}).addTo(map);
map.setView([position.coords.latitude, position.coords.longitude], 16);
}

// 定義失敗的回呼函式
function showError() {
console.log('抱歉,現在無法取的您的地理位置。')
}

navigator.geolocation.getCurrentPosition(showPosition, showError);

} else {
console.log('抱歉,您的裝置不支援定位功能。');
}
}


收藏功能實作

Local Storage 暫存收藏藥局

資料部分,就是很單純將用戶所收藏的藥局的電話暫時存進瀏覽器的 Local Storage 中,等到需要時再抓回來用(後來我實作用戶到訪網站時直接渲染收藏藥局清單,方便使用者,也提升載入速度)。必須注意的是,資料存進 Local Storage 前後,都必須格式化處理:

1
2
3
4
5
// 從 Local Storage 載入資料
const lovedStores = JSON.parse(localStorage.getItem('lovedStores')) || [];

// 將資料存進 Local Storage
localStorage.setItem('lovedStores', JSON.stringify(lovedStores)) ;

原生 JS 新增/移除 HTML 中的 class

之前慣用 jQuery 處理 DOM ,這次嘗試用原生 JS 新增或移除標籤,其實兩者大同小異,都有 addremovetoggle 方法,只不過原生 JS 是以 classList 來儲存 DOM 的標籤。

除此之外,原生 JS 的 DOM 選取器也可以熟悉一下。(還是趕快把 React 和 Vue 學起來…)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function loveBtnActive() {
const love = document.querySelectorAll('.store-info .love');
const loved = document.querySelectorAll('.store-info .loved');

love.forEach(el => {
const lovedStoreTel = el.parentNode.parentNode.children[2].textContent;
el.addEventListener('click', function() {
lovedStores.push(lovedStoreTel);
el.classList.toggle('hide');
el.parentNode.children[2].classList.toggle('show');
localStorage.setItem('lovedStores', JSON.stringify(lovedStores)) ;
})
})

loved.forEach(el => {
const lovedStoreTel = el.parentNode.parentNode.children[2].textContent;
el.addEventListener('click', function() {
removeByValue(lovedStores, lovedStoreTel);
el.classList.toggle('show');
el.parentNode.children[1].classList.toggle('hide');
localStorage.setItem('lovedStores', JSON.stringify(lovedStores)) ;
})
})
}


後記

約莫二月初的時候,我在前端社群看到有人使用中國丁香園所蒐集的資料來視覺化 COVID-19(武漢肺炎) 的疫情狀況,就覺得很有趣,也想要試看看。沒想到幾天後政府提出實名制購買口罩的政策,數位政委唐鳳旋即宣布將在 2 月 6 日釋出 API 供技術社群開發應用。所以,前一天晚上我便將地圖和網頁架構寫好, 6 日一早串上 API ,將熱點視覺化後,旋即分享給身旁的朋友,再慢慢優化功能與介面。等未來框架學得差不多,也會試著以框架重新搭建一次。

其實過程中,除了技術的學習以外,最感動的莫過於技術社群和政府部門協力參與開發的能量,無論是 Web 、 APP 還是聊天機器人,各式各樣的應用服務讓我深刻感受 Hackers 在短時間內嘗試透過技術解決問題的熱忱。這也讓我更加相信學習程式設計的目的,是以人為本地解決問題,進而對社會產生貢獻。

期望我能保持這樣的心,繼續精進,成為理想中的 Contributor 。


參考資料

  1. 口罩供需資訊平台
  2. 從Promise開始的JavaScript異步生活
  3. 六角學院-Leaflet + OpenStreetMap 地圖應用開發
把網頁當畫布揮灑的 Canvas 01:基礎圖形繪製 JS 新手地下城試煉攻略:B1-九九乘法表

評論

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×