前言
這次的挑戰題目是因應最近的 COVID-19 病毒而生;因著對時事及醫療的一股熱情,看到這個題目的時候覺得非做不可。
這個學期兩次在短時間內飆出網站的 Prototype ,體會到不先考慮太多,直接開發也是一種方式;看到口罩地圖的資訊之後就直接開始著手進行,途中不考慮要怎麼寫文章,或是怎麼規劃得更好看之類的問題。
但是這樣的方式還是有其缺點。
一開始覺得這個網頁就只有簡單的一個畫面,所以沒有用 Webpack 專案,等接近完成的時候,才覺得如果使用 Vue CLI 建立專案的話,可以更方便擴充 jQuery 動畫,或者其他相關服務(例如疫情查詢等)。
除了覺得畫面簡單,也是因為我在 GitHub 上是將精神時光屋的專案放在同一個 Repository 裡,然後直接將 master 分支設定為 GitHub Page ;為了不想改變這個規律,所以沒有用 Webpack ;看來以後還是克服一下這種完美主義比較好 XD
之前幾次寫精神時光屋的文章都十分冗長,這次應該仍然會長度破表,請讀者海涵 <(_ _)>
作品
Lynn 的 F2E 2nd 作品列表 / 第十關:口罩地圖
資源
設計稿
這次挑戰我沒有採用 UI 組的設計稿,因為這次的主題比較特別,我希望能用自己的方式做出整個作品。
之前曾經試過使用 XD 來製作 Prototype ,但已深深覺得身為設計外行在設計上所耗費的時間還是拿來切版比較有效率……XD
這次想說使用 Photoshop 排一個大概好了(?),結果還是花了我一下午,最後決定直接動手 (/‵Д′)/~ ╧╧
(不過在製作途中還是用 Photoshop 畫了所需的 icon 。)
這是被我始亂終棄的設計稿: MaskMap.psd
資料
這次使用的口罩剩餘數量資料是 kiang 所提供的 藥局+衛生所即時庫存 geojson ;詳情可以參考口罩供需資訊平台。
地圖資料則是使用 Open Street Map 。
框架
除了網站本身使用 Vue.js 以外,地圖的部分使用 Leaflet 。
介面及功能
網站的介面很簡單,左邊是搜尋的列表,右邊是地圖。
使用者可以用定位或雙擊地圖來設定自己的位置,左方的列表就會依據距離顯示藥局的資訊;包含藥局名稱、地址、電話、營業時間及備註。
使用者也可以用文字搜尋藥局名稱和地址,也有成人或兒童口罩及距離的篩選機制。
從下圖中可以看到,地圖上的標誌會隨著口罩的有無而有所不同,共分為全藍、藍灰、灰藍及全灰四種;另外還有代表使用者位置的紅色標誌。
使用者也可以點擊標誌,在地圖上查看藥局資訊。
隨著縮放地圖的大小,距離較近的標誌會縮成白色圈圈;點擊後會放大地圖。
使用平板或手機操作時,左側的列表會預設為隱藏。
資料讀取中時有小精靈畫面。
另外依口罩供需資訊平台的建議有使用須知。
排版
整個網站使用 flexbox 排版,部分細節使用絕對定位;以下用擬人化的方式表達。
.wrap
.wrap 是和 Vue 實體綁定的元素,最大寬度為 1920px ,垂直置中。
1 2 3 4 5 6
| .wrap { max-width: 1920px; margin: 0 auto; display: flex; position: relative; }
|
.sidebar 是一個垂直位置比 .map 高的側欄,邊緣有一些陰影增加了它的神祕感(?)。
在電腦版時 .sidebar 是固定 500px 的寬度顯示在畫面中。
1 2 3 4 5 6
| .sidebar { background-color: #fff; width: 500px; box-shadow: 0px 0px 7px #333333; z-index: 1; }
|
使用寬度 768px 以下的裝置時, .sidebar 會往左邊飄到出界,並出現一個可以讓它回來的按鈕。
1 2 3
| <div class="sidebar" :class="{hide: menuHide === true}"> <button class="toggle-button" @click="menuHide = !menuHide"></button> </div>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @media(max-width: 768px) { .sidebar { position: absolute; transition: ease-out 0.5s; } .sidebar .toggle-button { display: block; transition: ease-out 0.5s; } .sidebar.hide { transform: translateX(-100%); box-shadow: none; } .sidebar.hide .toggle-button { transform: translateX(100%); box-shadow: #333 0 0 7px; } }
|
.sidebar 裡面有 .top 和 .list 兩個元素。
.top
.top 是個藍色區塊,包含標題、搜尋框格和篩選搜尋結果的選項。
當使用者點擊 .search-input 或 .search-label 時, .search-list 就會出現,讓使用者看到搜尋建議選項。
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 29 30 31
| <div class="top"> <h1 class="title">口罩存量查詢</h1> <div class="search" :class="{searching: searching === true}"> <input type="text" id="search-input" class="search-input" placeholder="搜尋藥局" v-model="searchInput" @click="searching = true;" autocomplete="off"> <label class="search-label" for="search-input"></label> <ul class="search-list" v-if="searching === true"> <li class="search-item" v-for="(item, index, key) of searchingList" :key="key" @click="changePosition(item.geometry.coordinates[1],item.geometry.coordinates[0],item)" v-if="index < 10"> <div class="name">{{item.properties.name}}</div> <div class="address">{{item.properties.address}}</div> </li> </ul> </div> <div class="filter"> <ul class="checkboxes"> <input type="checkbox" name="mask" id="adult" class="checkbox" :class="{active: checkboxes[0] === true}" v-model="checkboxes[0]"> <label for="adult">成人口罩</label> <input type="checkbox" name="mask" id="child" class="checkbox" :class="{active: checkboxes[1] === true}" v-model="checkboxes[1]"> <label for="child">兒童口罩</label> </ul> <select name="distance" class="distance" v-model="distance" v-if="userMarker !== null"> <option class="option" value="1000">1公里</option> <option class="option" value="5000">5公里</option> <option class="option" value="10000">10公里</option> </select> </div> </div>
|
.checkbox 因為長得不夠有特色,被 .checkbox::before 給取代了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| .sidebar .top .checkboxes .checkbox { appearance: none; -webkit-appearance: none; -moz-appearance: none; outline: none; }
.sidebar .top .checkboxes .checkbox::before { content: ''; display: block; width: 18px; height: 18px; background-image: url(../img/checkbox-false.png); background-size: 18px; margin: 0 3px 0 20px; cursor: pointer; }
|
.list
.list 做為一個 <ul> 很沒特色,它只是如往常地讓 <li> 們有個地方待。
但還好,它用卷軸表現了自己的美。
它說一般的卷軸都緊貼在邊緣,但它想到用陰影的方式畫出自己要的卷軸跟留白寬度。
兩側 的 padding 也因應卷軸的寬度有所不同,這樣底下的元素才不會因為卷軸的關係歪一邊。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| .sidebar .list { padding: 0 7px 0 25px; overflow-y: scroll; height: calc(100vh - 200px); }
.sidebar .list::-webkit-scrollbar { width: 18px; }
.sidebar .list::-webkit-scrollbar-thumb { border-radius: 10px; box-shadow: inset 0 0 10px 10px #7be0e5; border: solid 3px transparent; }
|
.list 家的 .item 就和許多 <li> 一樣,會發展出自己的樣貌。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <li class="item" v-for="(item,index,key) of sortedData" :key="key" v-if="(item.distance < distance || (userMarker === null && index < 10)) && isVisable(item.properties.mask_adult,item.properties.mask_child) " @dblclick="changePosition(item.geometry.coordinates[1],item.geometry.coordinates[0],item)" @click="focusStore(item)"> <h2 class="title">{{item.properties.name}}</h2> <div class="distance" v-if="userMarker !== null">{{item.distance | round}} 公尺</div> <div class="mask"> <div class="adult" :class="{stock: item.properties.mask_adult > 0}"> 成人口罩:<span>{{item.properties.mask_adult}}</span></div> <div class="child" :class="{stock: item.properties.mask_child > 0}"> 兒童口罩:<span>{{item.properties.mask_child}}</span></div> </div> <div class="data address">{{item.properties.address}} </div> <div class="data phone"><a :href="'tel:'+item.properties.phone">{{item.properties.phone}}</a> </div> <div class="schedule-container" v-html="item.properties.schedule"></div> <div class="data note" v-if="item.properties.note !== '-'">{{item.properties.note}}</div> </li>
|
它們有太多不同的包袱,要一一道來實在太過繁瑣,所以它們說,有興趣的話請自行閱讀,沒興趣記得要快轉。
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112
| .sidebar .list .item { border-bottom: 3px solid #7be0e5; padding: 5.6% 5.6% 6.7% 5.6%; position: relative; cursor: pointer; }
.sidebar .list .item:hover { background-color: #f8f8f8; }
.sidebar .list .item .title { font-size: 24px; width: 100%; padding-bottom: 5px; }
.sidebar .list .distance { position: absolute; right: 25px; top: 25px; }
.sidebar .list .data { width: auto; display: flex; margin-top: 10px; font-size: 18px; }
.sidebar .list .data::before { content: ''; display: block; width: 18px; height: 18px; background-repeat: no-repeat; background-position: center center; background-size: contain; margin: 0px 5px 0 15px; }
.sidebar .list .address::before { background-image: url(../img/address.png); }
.sidebar .list .phone::before { background-image: url(../img/phone.png); }
.sidebar .list .time::before { background-image: url(../img/time.png); }
.sidebar .list .item .schedule-container { width: 100%; padding: 0 15px; display: flex; }
.sidebar .list .item .schedule { border: #7be0e5 solid 2px; width: 80%; margin-top: 10px; border-collapse: separate; border-radius: 10px; }
.sidebar .list .item .schedule-container::before { content: ''; width: 18px; margin-top: 10px; margin-right: 5px; height: 18px; background-repeat: no-repeat; background-position: center left; background-image: url(../img/time.png); }
.sidebar .list .item .schedule .th, .sidebar .list .item .schedule .td { text-align: center; padding: 8px 0px; }
.sidebar .list .item .schedule .td { color: #7be0e5; font-weight: bold; }
.sidebar .list .mask { display: flex; width: 100%; justify-content: center; font-size: 20px; }
.sidebar .list .mask .adult, .sidebar .list .mask .child { width: 45%; padding: 10px 20px; margin: 10px; border-radius: 4px; background-color: #ccc; display: flex; justify-content: center; }
.sidebar .list .mask .stock { background-color: #7be0e5; }
.sidebar .list .item .note::before { background-image: url(../img/note.png); }
|
.map
.map 有它自己的神, 地圖之神 Leaflet 自會決定它的長相。
它也有叛逆的地方,它要自己決定地圖小框框的寬度;還有神也沒說框框裡要怎麼安排,這些可以自己想。
不過 .map 有個小秘密,這些安排方式,其實是跟 .list 家的 .item 抄來的。
1 2 3 4 5 6 7
| .map .leaflet-popup-content { width: 300px !important; display: flex; justify-content: space-around; flex-wrap: wrap; font-size: 16px; }
|
.loading
.loading 說,你以為介紹完 .sidebar 和 .map 就可以收工了嗎?
你知道你第一眼見到的通常是 .loading 嗎?
.loading 所站的高度比誰都高,所涵蓋的廣度也比誰都廣;讓你看不見 .map 也看不見 .sidebar ,除非 .loading 消失。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| .loading { position: absolute; z-index: 5; top: 0; left: 0; width: 100vw; height: 100vh; background-color: #5cc; display: flex; justify-content: center; align-items: center; color: #fff; font-size: 24px; }
|
它有個赤子之心小精靈,告訴你,你的資料還在來的路上。
對我該用正常的方式寫這一段。
.loading 中所使用的小精靈動畫使用 Loaders.css 所提供的樣式。
這是基於 jQuery 的動畫,所以必須先引入 jQuery;接著下載 css 和 js 檔並引入後,就可以使用了。
1 2 3 4 5 6 7 8 9 10 11 12
| <link rel="stylesheet" href="css/loaders.min.css">
<div class="loading" v-if="loading === true"> <div class="loader-inner pacman"></div> <div>資料讀取中</div> </div>
<script src="https://code.jquery.com/jquery-3.4.1.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script> <script src="js/loaders.css.js"></script>
|
.notice
.loading 消失後,你會看到站得第二高的 .notice 。
1 2 3 4 5 6 7 8 9 10 11 12
| .notice { position: fixed; height: 100vh; width: 100%; left: 0; top: 0; background-color: rgba(0, 0, 0, 0.5); z-index: 3; display: flex; justify-content: center; align-items: center; }
|
它有著半透明的黑底,跟一片有藍色叉叉的方塊,方塊上面就是告訴你一些該知道的事。
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 29
| .notice .box { width: 90%; max-width: 800px; padding: 30px; background-color: rgba(255, 255, 255, 0.9); border-radius: 10px; position: relative; font-size: 22px; text-align: justify; }
.notice .close-button{ border: none; background-color: transparent; color: #5cc; font-weight: bold; font-size: 36px; width: 40px; height: 40px; display: flex; justify-content: center; align-items: center; cursor: pointer; position: absolute; right: 0; top: 0; padding: 5px; outline: none; }
|
以下恢復一般的表達方式 XD
程式邏輯
網站使用 Vue.js 。
1 2 3
| let vue = new Vue({ el: '#vue', });
|
data
以下是網站的所有變數。
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| data: {
loading: true,
map: null,
userMarker: null,
storeMarkers: null,
userPosition: null,
zoom: null,
data: [],
searchInput: '',
checkboxes: [false, false],
distance: 1000,
searching: false,
geolocation: false,
menuHide: true,
noticeOpen: true, },
|
methods
網站有幾個重要的 function :
- openMap :開啟地圖、取得使用者位置
- getData :取得藥局資料、將藥局跟使用者的距離放入資料陣列中
- addMarkers :將藥局資料標記在地圖中
- changePosition :改變使用者位置及標誌
及輔助的 function :
- createScheduleString :依據資料產生營業時間表格字串
- createPopup :依據資料產生地圖小框框字串
- getIcon :回傳指定類型的地圖標記
- computeDistance :根據兩點的經緯度算出距離(公尺)
還有改善操作順暢度的 function :
- focusStore :點擊列表中的藥局時,對應資料的地圖小框框會開啟
- isVisable :依據 checkbox 點選狀況回傳該筆資料是否顯示在列表中
以下介紹比較重要的幾個 function 。
openMap()
在 openMap() 中,先宣告 Leaflet 的實體。
1 2 3 4 5 6 7 8 9 10
| openMap: function () { let that = this; this.map = L.map('map', { center: [23.6334772, 120.852944], zoom: 7, maxBounds: L.latLngBounds(L.latLng(28, 115), L.latLng(20, 127)), minZoom: 7, zoomControl: false });
|
放入 Open Street Map 的 tileLayer 。
1 2 3 4 5
| L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: 'Map data © <a target="_blank" href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a target="_blank" href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>' }).addTo(this.map); L.control.zoom({ position: 'topright' }).addTo(this.map);
|
要求使用者的位置資訊。
有定位的話以定位位置為地圖中心,無則以整個台灣為起始畫面。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( function (position) { that.geolocation = true; that.userPosition = [position.coords.latitude, position.coords.longitude]; that.zoom = 19; that.map.setView(new L.LatLng(that.userPosition[0], that.userPosition[1]), that.zoom); that.userMarker = L.marker([that.userPosition[0], that.userPosition[1]], { icon: that.getIcon('red') }); that.map.addLayer(that.userMarker); } ); }
|
綁定在地圖上點擊的事件。
1 2 3 4 5 6 7 8 9 10 11
| that.map.on('dblclick', (event) => { that.changePosition(event.latlng.lat, event.latlng.lng); }) that.map.on('click', (event) => { if (window.innerWidth <= 480) { that.changePosition(event.latlng.lat, event.latlng.lng); } }) },
|
getData()
getData() 會以 XMLHttpRequest 取得口罩地圖的資料,並依據資料產生營業時間表格字串放入陣列中。
接著呼叫 addMarkers() 將藥局標記在地圖中。
最後將 loading 賦值為 false ,使 .loading (資料讀取中小精靈)隱藏。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| getData: function () { let that = this; let xhr = new XMLHttpRequest(); xhr.open('get', 'https://raw.githubusercontent.com/kiang/pharmacies/master/json/points.json'); xhr.onload = function () { that.data = JSON.parse(xhr.responseText).features; for (let i = 0; i < that.data.length; i++) { that.data[i].properties.schedule = that.createScheduleString(that.data[i]); that.data[i].focus = false; } that.addMarkers(); that.loading = false; }; xhr.send(); },
|
addMarkers()
addMarkers 會以迴圈讀取每筆藥局資料。
依據資料選擇適當的標記圖示,產生標記並放入 storeMarkers 中。
最後再將 storeMarkers 加入地圖中。
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| addMarkers: function () { this.storeMarkers = new L.markerClusterGroup().addTo(this.map); for (let i = 0; i < this.data.length; i++) { let popupString = this.createPopup(this.data[i]); if (this.data[i].properties.mask_adult > 0) { if (this.data[i].properties.mask_child > 0) { this.data[i].marker = L.marker( [this.data[i].geometry.coordinates[1], this.data[i].geometry.coordinates[0]], { icon: this.getIcon('blue') }).bindPopup(popupString); } else { this.data[i].marker = L.marker( [this.data[i].geometry.coordinates[1], this.data[i].geometry.coordinates[0]], { icon: this.getIcon('blue-grey') }).bindPopup(popupString); } } else { if (this.data[i].properties.mask_child > 0) { this.data[i].marker = L.marker( [this.data[i].geometry.coordinates[1], this.data[i].geometry.coordinates[0]], { icon: this.getIcon('grey-blue') }).bindPopup(popupString); } else { this.data[i].marker = L.marker( [this.data[i].geometry.coordinates[1], this.data[i].geometry.coordinates[0]], { icon: this.getIcon('grey') }).bindPopup(popupString); } } let that = this; this.data[i].marker.on('dblclick', () => { if (window.innerWidth > 480) that.changePosition( that.data[i].geometry.coordinates[1], that.data[i].geometry.coordinates[0], that.data[i]); }); this.storeMarkers.addLayer(this.data[i].marker); } this.map.addLayer(this.storeMarkers); },
|
changePosition()
changePosition 會將使用者標記移到某個緯經度。
如果目的地是藥局的話,還會將小框框與新的使用者標記綁在一起。
這是因為新的使用者標記會蓋在原本的藥局標記上,使框框無法被點擊開啟;所以採用比較土法煉鋼的方式。
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| changePosition(lat, lng, item) { if (!item) { this.userPosition = [lat, lng]; this.zoom = 19; this.map.setView(new L.LatLng(this.userPosition[0], this.userPosition[1])); if (this.userMarker !== null) { this.map.removeLayer(this.userMarker); } this.userMarker = L.marker([this.userPosition[0], this.userPosition[1]], { icon: this.getIcon('red') }); this.map.addLayer(this.userMarker); this.searchInput = this.userPosition; } else { if (item.type === 'self') { if (navigator.geolocation) { let that = this; navigator.geolocation.getCurrentPosition( function (position) { that.userPosition = [position.coords.latitude, position.coords.longitude]; that.zoom = 19; that.map.setView(new L.LatLng(that.userPosition[0], that.userPosition[1]), that.zoom); that.searchInput = item.properties.name; if (that.userMarker !== null) { that.map.removeLayer(that.userMarker); that.userMarker = L.marker([that.userPosition[0], that.userPosition[1]], { icon: that.getIcon('red') }); that.map.addLayer(that.userMarker); } }, function (err) { if (err.code === 1) { alert('您未提供位置資訊的權限,請檢查您的瀏覽器設定。'); } } ); } } else { this.userPosition = [item.geometry.coordinates[1], item.geometry.coordinates[0]]; this.zoom = 19; this.map.setView(new L.LatLng(this.userPosition[0], this.userPosition[1]), this.zoom); this.searchInput = item.properties.name; if (this.userMarker !== null) { this.map.removeLayer(this.userMarker); } this.userMarker = L.marker([this.userPosition[0], this.userPosition[1]], { icon: this.getIcon('red') }).bindPopup(item.marker._popup._content); this.map.addLayer(this.userMarker); } } },
|
mounted()
使用者進入網站後,網站會在 mounted 階段開啟地圖、取得藥局資料。
也就能讓網站開始運作。
1 2 3 4 5 6 7 8 9 10 11
| mounted() { this.openMap(); this.getData(); let that = this; document.querySelector('body').addEventListener('click', function (event) { if (event.target.classList.contains('search-input') !== true) { that.searching = false; } }); },
|
完!