2016年6月6日

如何用 D3.js 繪製地圖

D3.js 是個強大熱門的網頁 svg 圖表工具,我們試著用 D3.js 在網頁上製作地圖,並在網頁上將座標點標記在上面,最後測試了如何更新標記的座標點。

利用 topojson 轉換行政區域圖 SHP 資料

台灣官方釋出了三種詳實等級不同的行政區域圖資:

  1. 縣(市)行政區界線
  2. 鄉(鎮、市、區)行政區域界線
  3. 全國村里界圖

釋出的格式均為 SHP Shapefile 檔格式,D3.js 的作者 Mike,在 Github 上提供topojson,讓我們可以讀取 SHP 檔並輸出成 GeoJSON 格式,讓我們可以直接由 SHP 檔產生 TopoJSON 檔。

要使用 topojson 必須先安裝 NodeJS 以及 npm,在 mac 可用 port 或是 homebrew 安裝。

sudo port install nodejs
sudo port install npm

接下來就用 npm 安裝 topojson

sudo npm install -g topojson

我們用了 縣(市)行政區界線 的資料,下載下來是一個壓縮檔: 直轄市、縣(市)界線檔1041215.zip,解壓縮後會有以下這些檔案:

County_MOI_1041215.dbf
County_MOI_1041215.prj
County_MOI_1041215.shp
County_MOI_1041215.shx

我們要用的是 CountyMOI1041215.shp。下一步是用 topojson 轉檔,由於原始檔的文字編碼是 big5,要指定 --shapfile-encoding ,taiwan.json 就是我們要用的 topojson 檔案。

topojson -s 0.0000001 -o taiwan.json -p --shapefile-encoding big5 County_MOI_1041215.shp

利用 D3 geo 繪製台灣地圖

先準備一個 web server,隨便一種 server 都可以,製作 index.html,include 需要的 js 檔案: d3.v3.min.js 以及 topojson.v1.min.js,還有 jquery,把剛剛的 taiwan.json 以及 index.html 放在 webserver 的同一個目錄中。。

<script src="https://d3js.org/d3.v3.min.js" charset="utf-8"></script>
<script src="https://d3js.org/topojson.v1.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.0/jquery.min.js"></script>

增加兩個 css style,用在頁面底色以及縣市邊界的線段類型。

<style>
body {
    background-color: #cceeff;
}

.subunit-boundary {
    fill: none;
    stroke: #fff;
    stroke-dasharray: 5, 0;
    stroke-linejoin: round;
}
</style>

接下來是 javascipt 的部分,首先要定義 svg 的圖形尺寸

var width = 800,
    height = 600;

var svg = d3.select("body").append("svg")
    // .attr("class", "svgback")
    .attr("width", width)
    .attr("height", height);

初始化 d3.geo,projection 是 d3.geo 最重要的概念,他能將座標點投影至頁面的圖形區塊上。

var projection = d3.geo.mercator()
    .center([121,24])
    .scale(6000);

var path = d3.geo.path()
    .projection(projection);

再來是載入 taiwan.json 的路徑資料,並將他畫到 svg 上面,同時我們將縣市行政區界線畫出來。d3.json 是從 server 將 taiwan.json 載入到頁面,然後用 topology.objects["CountyMOI1041215"] 裡面描述的路徑畫到 svg 上,會寫成 "CountyMOI1041215" 的原因是因為產生出來的 taiwan.json 是由 CountyMOI1041215.shp 產生出來的。

d3.json("taiwan.json", function(error, topology) {
    var g = svg.append("g");
    
    // 縣市/行政區界線
    d3.select("svg").append("path").datum(
            topojson.mesh(topology,
                    topology.objects["County_MOI_1041215"], function(a,
                            b) {
                        return a !== b;
                    })).attr("d", path).attr("class","subunit-boundary");
    
    d3.select("g").selectAll("path")
          .data(topojson.feature(topology, topology.objects.County_MOI_1041215).features)
          .enter()
          .append("path")
          .attr("d", path)
          .attr({
                d : path,
                name : function(d) {
                    return d.properties["C_Name"];
                },
                fill : '#55AA00'
        });
});

taiwan.json 裡面的內容:

# taiwan.json
{"type":"Topology","objects":{"County_MOI_1041215":{"type":"GeometryCollection","bbox":[118.14367476000007,21.895599675000085,124.56114950000006,26.385278130000074],"geometries":[{"type":"MultiPolygon","properties":{"OBJECTID":1,"County_ID":"09007","Shape_Leng":1.55703852,"Shape_Area":0.00268483471071,"C_Name":"連江縣","
.....

目前為止,我們可以看到這樣的畫面:

在地圖上標記城市的點

準備一個 cities.csv 檔案,內容如下,放在同一個 web server 的目錄中,其實我們只有用到經緯度的資料,其他的欄位沒有用到。

code,city,country,lat,lon
KHH,KAOHSIUNG,TAIWAN,22.630865,120.310047
HUN,HUALIEN,TAIWAN,23.979337,121.595788
KNH,KINMEN,TAIWAN,24.449047,118.375968
MZG,MAKUNG,TAIWAN,23.567376,119.584375

在載入 taiwan.json 台灣地圖後,再利用 d3.csv 載入 cities.csv,將城市地點以一個紅點標記出來。

    var g = svg.append("g");
    
    // load and display the cities
    d3.csv("cities.csv", function(error, data) {
        g.selectAll("circle")
           .data(data)
           .enter()
           .append("circle")
           .attr("cx", function(d) {
                   return projection([d.lon, d.lat])[0];
           })
           .attr("cy", function(d) {
                   return projection([d.lon, d.lat])[1];
           })
           .attr("r", 5)
           .style("fill", "red");
    });

到目前為止,我們可以看到的畫面如下

因為未來我們希望能夠持續動態更換產生的圖點,我們先準備另一個檔案 cities2.csv,內容如下

code,city,country,lat,lon
TPE,TAIPEI,TAIWAN,25.034608,121.564801
TXG,TAICHUNG,TAIWAN,24.165392,120.662028

以 setInterval 定時每兩秒鐘更新一次城市的圖點,未來可以將 "cities2.csv" 換成會持續更換資料的 server 動態頁面,就可以看到地圖點持續地更新。

setInterval(function() {
    d3.csv("cities2.csv", function(error, data) {
        svg.select("g").selectAll("circle").remove();
        
        // new data joins old elements 'circle'
        var update = svg.append("g").selectAll("circle")
            .data(data)
            .enter()
            .append("circle")
            .attr("cx", function(d) {
                return projection([d.lon, d.lat])[0];
            })
            .attr("cy", function(d) {
                    return projection([d.lon, d.lat])[1];
            })
            .attr("r", 5)
            .style("fill", "red");
    });
}, 2000);

打開頁面,兩秒鐘後,就會看到畫面變成這樣

顯示縣市名稱

在 body 裡面先產生一個 panel 的 div

<div id='panel' style="display: none">
    <span id='title'></span><br />
</div>

用以下的程式,顯示 panel

    // 顯示滑鼠所指向的縣市/行政區
    d3.select("svg").selectAll("path").on("mouseenter", function() {
        // console.log(e);
        fill = $(this).attr("fill");
        $(this).attr("fill", '#00DD77');
    
        $('#title').html($(this).attr("name"));
        $('#panel').css({
            "height" : "20px",
            "width" : "60px"
        });
    }).on("mouseout", function() {
        $(this).attr("fill", fill);
    });
    
    // panel 隨滑鼠移動
    $("path").mouseover(function(e) {
        
        if($('#panel').is(':visible')){
            $('#panel').css({
                'top' : e.pageY,
                'left' : e.pageX
            });
        } else {
            $('#panel').fadeIn('slow').fadeOut();
        }
    });

完整的範例程式

以下是整個完整的範例程式

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="description" content="" />
<meta name="keywords" content="" />
<meta name="author" content="" />
<meta name="viewport"
    content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<title>test</title>

<script src="https://d3js.org/d3.v3.min.js" charset="utf-8"></script>
<script src="https://d3js.org/topojson.v1.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.0/jquery.min.js"></script>

<style>
body {
    background-color: #cceeff;
}

.subunit-boundary {
    fill: none;
    stroke: #fff;
    stroke-dasharray: 5, 0;
    stroke-linejoin: round;
}

#panel {
    position: absolute;
    z-index: 99;
    height: 20px;
    width: 60px;
    background-color: #fff;
    -webkit-transition: all .1s;
    border-radius: 5px;
    background-color: #000;
    background-color: rgba(0, 0, 0, 0.3);
    color: #fff;
    padding: 10px;
}

.svgback {
    background-color: #ffffff;
}
</style>

</head>
<body>

<div id='panel' style="display: none">
    <span id='title'></span><br />
</div>

<script>

var width = 800,
    height = 600;
var svg = d3.select("body").append("svg")
    // .attr("class", "svgback")
    .attr("width", width)
    .attr("height", height);

var projection = d3.geo.mercator()
    .center([121,24])
    .scale(6000);

var path = d3.geo.path()
    .projection(projection);

// load and display the World
d3.json("taiwan.json", function(error, topology) {
    var g = svg.append("g");
    
    // load and display the cities
    d3.csv("cities.csv", function(error, data) {
        g.selectAll("circle")
           .data(data)
           .enter()
           .append("circle")
           .attr("cx", function(d) {
                   return projection([d.lon, d.lat])[0];
           })
           .attr("cy", function(d) {
                   return projection([d.lon, d.lat])[1];
           })
           .attr("r", 5)
           .style("fill", "red");
    });
    
    // 縣市/行政區界線
    d3.select("svg").append("path").datum(
            topojson.mesh(topology,
                    topology.objects["County_MOI_1041215"], function(a,
                            b) {
                        return a !== b;
                    })).attr("d", path).attr("class","subunit-boundary");
    
    d3.select("g").selectAll("path")
          .data(topojson.feature(topology, topology.objects.County_MOI_1041215).features)
          .enter()
          .append("path")
          .attr("d", path)
          .attr({
                d : path,
                name : function(d) {
                    return d.properties["C_Name"];
                },
                fill : '#55AA00'
        });
    
    // 顯示滑鼠所指向的縣市/行政區
    d3.select("svg").selectAll("path").on("mouseenter", function() {
        // console.log(e);
        fill = $(this).attr("fill");
        $(this).attr("fill", '#00DD77');
    
        $('#title').html($(this).attr("name"));
        $('#panel').css({
            "height" : "20px",
            "width" : "60px"
        });
    }).on("mouseout", function() {
        $(this).attr("fill", fill);
    });
    
    // panel 隨滑鼠移動
    $("path").mouseover(function(e) {
        if($('#panel').is(':visible')){
            $('#panel').css({
                'top' : e.pageY,
                'left' : e.pageX
            });
        } else {
            $('#panel').fadeIn('slow').fadeOut();
        }
    });

});

setInterval(function() {
    d3.csv("cities2.csv", function(error, data) {
        svg.select("g").selectAll("circle").remove();
        
        // new data joins old elements 'circle'
        var update = svg.append("g").selectAll("circle")
            .data(data)
            .enter()
            .append("circle")
            .attr("cx", function(d) {
                return projection([d.lon, d.lat])[0];
            })
            .attr("cy", function(d) {
                    return projection([d.lon, d.lat])[1];
            })
            .attr("r", 5)
            .style("fill", "red");
    });
}, 2000);
</script>

</body>
</html>

換成世界地圖

topojson/examples 下載 world-110m.json

projection 的地方要改成以 [0,5] 為中心,至於旋轉的原因,是為了搭配原始資料的關係,這樣才能正確地用經緯度標記城市的點。

var projection = d3.geo.mercator()
    .center([0,5])
    .rotate([-180,0]);

把 taiwan.json 改成 world-110m.json。

// load and display the World
d3.json("world-110m.json", function(error, topology) {
    var g = svg.append("g");

因為 world-110m.json 裡面的 objects 的名稱為 countries,所以把 CountyMOI1041215 改成 countries。

// 縣市/行政區界線
    d3.select("svg").append("path").datum(
            topojson.mesh(topology,
                    topology.objects["countries"], function(a,
                            b) {
                        return a !== b;
                    })).attr("d", path).attr("class","subunit-boundary");
    
    d3.select("g").selectAll("path")
          .data(topojson.feature(topology, topology.objects.countries).features)
          .enter()
          .append("path")
          .attr("d", path)
          .attr({
                d : path,
                name : function(d) {
                    return d.properties["name"];
                },
                fill : '#55AA00'
        });

調整一小部分就可以換成世界地圖

Reference

視覺化實戰 - D3.js 地理區塊視覺化

Taiwan Disaster in Real Time Display

抄程式學 d3.js

Let’s Make a Map

【 D3.js 入門系列 — 10 】 地圖的製作

【 地圖系列 】 世界地圖和主要國家的 JSON 文件

Try D3 Now

Spherical Mercator

Center a map in d3 given a geoJSON object

DavaViz for Everyone: Responsive Maps With D3