[菜鳥工程師] d3 結合vue繪製機場地圖
Tags: D3 Geo, Map, Vue, D3, Frontend

在codepen 上看到一個利用d3 做的不錯作品，試著模仿做泰國的機場地圖。今天要介紹使用d3(v5)來繪製泰國的機場地圖並計算距離，先看看完成圖。(github連結)

一、資料處理

首先，我們要取的地圖資料，在這一次的練習，我們需要使用泰國機場和泰國國土的geo資料，在d3裡面，地圖利用geojson檔或是json檔匯入。

泰國機場資料

humdata : 在這裡找到機場資料，檔案類型為.csv檔，透過qgis工具，直接匯出.geojson或是.json檔。

2. 泰國國土邊界資料

humdata: 在相同的網站找到泰國邊界的資料，資料很完整，有分成0級到三級的分類，府的分類為第一級，下載之後為.shp的檔，不過因為府的界線劃分非常細緻，所以檔案會特別大，這個時候可以使用mapshaper ，上傳shp相關的檔案，調整上方壓縮比例，(我調整至0.6%)，之後匯出json檔，這樣檔案的大小就可以縮減很多。

二、套件使用

我在這裡使用vue來實作，利用cdnjs匯入所需要的檔案。

d3 : 使用版本5，部分語法和版本3不同。

d3-geo : 繪製地圖使用。

d3-selection-multi : 因為d3版本5的關係，把屬性使用{}多屬性的功能放到這個套件裡面，因此需要匯入這個。(參考資料: d3-selection-multi github)

vue : 使用vue框架練習。

三、 實作部分

先在html裡面建立一個#app>svg標籤，是接下來繪製的區域。

接下來在js檔案裡面建立起vm ，在mounted載入的時候，將畫面數據初始化，分成三大函式 initSvg() 、 loadMap() 、 loadAirports() ，這幾個函式會得到data的資料，用來繪製到svg上的元素，包含地圖邊界path、機場位置、marker標記。

index.js let vm = new Vue({

el: '#app', data(){ svg: null,

path: null,

projection: null, markers: [ { airport: null, x: 100, y: 100 }, { airport: null, x: 50, y: 100 } ] },

methods: {

init(){

this.initSvg()

this.loadMap()

this.loadAirports()

}

},

mounted: {

this.init()

}

})

initSvg() 先設定完svg的大小，接著要把經緯度投影到svg這個範圍裏面，要先設定好投影的相關設定。

d3.geoMercator() 表示用橫麥卡托的方式投影。

translate([this.size / 2, this.size / 2]) 接著把svg的直角坐標移動到svg的中心當作新的(0,0)起點。

.center([lng,lat]) 把中心點設定在經緯度的位置。

.scale(放大比例) ，這樣子完成 projection 的設定。

接著要用地裡路徑產生器 d3.geoPath() ，給予一組 geometry 資料，就可以產生path的路徑，並且把剛剛設定好的投影資料連結起來，表示根據投影條件來產生path。

initSvg() { this.svg = d3.select('#svg').attrs({ 'width': this.size, 'height': this.size, 'viewBox': `0 0 ${this.size} ${this.size}` }) this.projection = d3.geoMercator() .translate([this.size / 2, this.size / 2]) .center([100.60700225799999, 13.312599563600001]) .scale(2000) this.path = d3.geoPath().projection(this.projection) }

接下來是載入邊界地圖資料。

v5版本利用json .then來載入url的資料，將資料的features存入data的countries裡面，但這時候還沒有更新到畫面上的數據，畫面中的counties還是[]，因此不會渲染過去，所以使用 $nextTick(()=>{}) ，表示等待資料渲染過去才執行，而因為版本5用 attrs 來使用多屬性，d的意思為根據 geometry 來產生路徑位置。

loadMap() { let url = './data/trimCounty.json' d3.json(url).then(data => { this.counties = data.features this.$nextTick(() => { this.svg.select('.country').selectAll('path').data(this.counties).attrs({ d: this.path, fill: '#EE7738' }) }) }) },

而在html的部分如下:

<g class="country"> <path v-for="county in counties" class="county"></path> </g>

接下來是載入機場位置資料，和前面載入邊界資料的方式相同，利用json .then來載入url的資料，並且要把資料與畫面做連結，獲取機場資料後再繪製marker標記的部分。

loadAirports() { let url = './data/airports.geojson' d3.json(url).then(geometry => { this.airports = geometry.features this.$nextTick(() => { this.svg.selectAll('.airport').data(this.airports) // load markers this.loadMarker() }) }) }

再來是獲得標記markers，把機場的資料隨機選取兩個當作marker的機場資料，並且更新marker的x,y值(在地圖上呈現的位置)，並且計算距離 calcDistance() 。

loadMarker() { this.markers.forEach((marker, index) => { marker.airport = this.airports[index * 5] let coor = this.projection(marker.airport.geometry.coordinates) marker.x = coor[0] marker.y = coor[1] }) // clac distance this.calcDistance() },

在html的機場如下:

g元素包含兩個circle點，一個是機場位置，一個是動態的大圓圈。g根據位置來調整偏移的大小，第一個circle 有click事件， clickAndSetMarker 是設定標記點(後面會提到)；第二個circle會根據nowMark來決定是否顯示，想要有漸層的效果，在外面加上transition 並給予name作為css控制的名稱，而裡面的元素應該要用 v-show(display:none) 來表示，而不是 v-if (不然沒有效果)。

<g class="airport" v-for="airport in airports" :transform="getTransform(airport)"> <circle @click="clickAndSetMarker(airport)" class="point" r=2></circle> <transition name="mark-fade" mode="out-in"> <circle v-show="nowMark===airport" class="range" r=5></circle> </transition> </g>

transition在css的設定中， -active 的部分用來設定transition-during，

-enter,-leave-to 為初始的狀況。(參考資料: vue 中文官網)

.mark-fade-enter,.mark-fade-leave-to opacity: 0.5 .mark-fade-enter-active,.mark-fade-leave-active transition: all 0.5s linear

在html的部分，markers標記如下:

希望滑鼠點下移動後更新位置，點下去的時候marker會縮小一下。

在g.marker底下有兩個元素， path 和 text ，由於marker的動畫有translate以及scale，希望scale有過渡時間，而translate在移動的時候沒有過渡時間，然而這兩個同屬於屬性transform，因此需要利用兩層分別控制，外層g控制整體移動translate，而內層元素控制放大比例scale。 text 的部分在有點到機場的時候才會顯示，利用 v-if 來控制。

<g class="marker" v-for="marker in markers" :transform="`translate(${marker.x},${marker.y})`" @mousemove="moveMarker($event,marker)" @mousedown="downMarker($event,marker)" @mouseup="upMarker($event,marker)"> <path :transform="getScale(marker)" d="M0 0l28.592-28.592c15.78-15.78 15.908-41.24.128-57.02a40.424 40.424 0 0 0-57.124 57.2z" :fill="getTypeStyle(marker,1)"> </path> <text v-if="marker.airport">{{marker.airport.properties.iata_code || marker.airport.properties.gps_code}}</text> </g>

而marker之間的距離該如何計算呢?

假設地球是一個光滑的球面，然後計算任兩點的距離，這樣算出來的距離稱為大圓距離( The Great Circle Distance)。(參考: 根據經緯度計算地球上兩點之間的距離)

上述提及的 calcDistance() 來處理距離的計算，因為目前我們只有經過投影後svg上面的位置，不知道實際經緯度，因此我們要利用d3提供的方法 invert 將投影的東西反轉回去實際經緯度 this.projection.invert([marker.x, marker.y])) ，最後傳入 getGreatCircleDistance() 做計算。

calcDistance() { // invert 把投影點轉換成實際經緯度 let latlngArr = this.markers.map(marker => this.projection.invert([marker.x, marker.y])) this.distance = this.getGreatCircleDistance(latlngArr) }

getGreatCircleDistance() 計算出距離。

getGreatCircleDistance(distanceArr) { let radArr = distanceArr.map(point => point.map(d => this.getRad(d))) let disArr = radArr.reduce((total, rad) => [rad[0] - total[0], rad[1] - total[1]], [0, 0]) var EARTH_RADIUS = 6378.137; //單位kM var s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(disArr[0] / 2), 2) + Math.cos(radArr[0][1]) * Math.cos(radArr[1][1]) * Math.pow(Math.sin(disArr[1] / 2), 2))); s = s * EARTH_RADIUS; s = Math.round(s * 10000) / 10000.0; return parseInt(s) } getRad(d) { return d * Math.PI / 180.0; }

上述就是主要的邏輯部分，結合vue來顯示，其他點下移動的功能，則列在下面供大家參考。