D3學習之地圖

(2017.03.09-03.11)

地圖的意義

在可視化領域中,將數據點投影和關聯到地理區域上,是一個非常關鍵的內容(體現了可視化中利用讀者自身知識常識從而加速吸收信息的原則)。

GeoJSON and TopoJSON

GeoJSON是用於描述地圖空間信息的數據格式。GeoJSON不是一種新的格式,其語法規範是符合JSON格式的,只不過對其名稱進行了規範,專門用於表示地理信息。GeoJSON裏的對象也是由名稱/值對的集合構成,名稱總是字符串,值可以是字符串、數字、布爾值、對象、數組、null。內部結構的話,每一個GeoJSON都必有一個type屬性,表示對象的類型,如Point(點)、LineString(線)、Feature(特徵)等。

上圖展示的就是一個標準GeoJSON文件的內容,可以看到type值為FeatureCollection(特徵集合),則該對象必須要一個名稱為features的成員(就在type下一行),features是一個數組,數組每一項都是一個特徵對象,如果是中國地圖的話,則每一項描述一個省的地理信息。

TopoJSON是GeoJSON按拓補學編碼後的擴展形式,TopoJSON中的每一個幾何體都是通過將共享邊整合後組成的,從而消除了部分冗餘數據,同時地理座標使用整數,因此文件大小較GeoJSON縮小了80%左右。同時,TopoJSON的語法規範也是符合JSON格式的。

獲取地圖數據

首先介紹一個非常好的地理數據網站Natural Earth,其中提供了大量免費的地理數據下載內容,包括中國地圖,那麼在網站上下載了相應的zip包後,進行解壓:

其中的shp文件,我們需要從中提取出需要的地理信息,並保存為中文形式,標準的工具室ogr2ogr,但是工具需要使用命令行操作,而且需要VS進行編譯,較為麻煩,因此我們使用一個基於ogr2ogr開發的圖形化軟件:ogr2gui,下載地址為ogr2gui,下載完成後打開工具,進行下圖操作:(切記!!導入的文件和導出的文件路徑中一定不能出現漢字,不然會導致導出失敗並且沒有任何錯誤提示!

完成後生成china.geojson文件,那麼我們通過在線工具來看看我們獲取的數據繪製成地圖後長什麼樣,瀏覽http://mapshaper.org,導入我們的geojson文件:


地圖繪製得不錯,然而不能接受的是地圖裏沒有台灣省。。。。所以最後我在網上下載了一個更合適的地圖:

mapshaper這個網站還有一個很重要的功能就是簡化地圖的邊界,原始地圖數據通常非常大,因為其中包含了大量地圖的細微邊界變化數據,而其中一些我們並不需要,因此可以進行簡化,下圖就是簡化後的效果:

可以發現適當的簡化並不影響地圖的整體效果(並且有時候,捨棄基本的地理信息可以讓我們展示更真實的數據,見我的博客《數據可視化之美閲讀》)。

使用D3繪製地圖

那麼我們最終的目的當然是使用D3來繪製地圖,GeoJSON和TopoJSON格式都可以繪製地圖,然而TopoJSON具有文件大小更小的優勢,所以儘可能都是用TopoJSON,但TopoJSON的缺點在於它標準是由D3作者制定,目前還不是世界範圍內承認的標準。下面的內容,我們來分別使用兩種方式來繪製地圖。

GeoJSON

首選無論使用GeoJSON還是TopoJSON,都需要先定義地圖的投影和地理路徑生成器,具體代碼和註釋如下:

var projection = d3.geo.mercator()
    .center([107, 31])   //設置地圖中心位置,前經度後緯度
    .scale(850)             //設置縮放量
    .translate([width/2, height/2]);    //設置平移量


//定義地理路徑生成器
var path = d3.geo.path()      //應用上面生成的投影,每一個座標都會先調用此投影函數,然後才產生路徑值
    .projection(projection);

然後,通過d3.json請求文件china.geojson,並添加足夠數量的path(svg的path,svg是d3的基礎),每一個path用於繪製一個省的路徑。

//顏色比例尺
var color = d3.scale.category20();
//請求china.geojson,把文件的json內容傳遞給root對象
d3.json("../geojson/china.geojson", function(error, root) {

    if (error)
        return console.error(error);
    console.log(root.features);

    svg.selectAll("path")
        .data( root.features )
        .enter()
        .append("path")
        .attr("stroke","#aaa")   //svg邊線屬性定義,這裏是顏色
        .attr("stroke-width",1)  //這裏是寬度
        .attr("stroke-dasharray",10,10)   //svg stroke虛線
        .attr("fill", function(d,i){    //每一塊的顏色填充
            return color(i);
        })
        .attr("d", path )
        .on("mouseover",function(d,i){   //兩個交互,鼠標放置和鼠標移開
            d3.select(this)
                .attr("fill","yellow");
        })
        .on("mouseout",function(d,i){
            d3.select(this)
                .attr("fill",color(i));
        });
});

至此,地圖繪製成功,步驟非常簡潔,並且帶有部分交互效果,我們來看看效果:

如上圖所示,鼠標箭頭停留處對應的省份顏色會變成黃色,實現了一定程度的交互效果(虛擬機截圖關係,看不到鼠標箭頭。。)。

查看網頁源代碼,看看地圖的HTML格式:

每一個path對應一個省份,並且都在svg元素內。

TopoJSON

使用上文提到的mapshaper網站,可以將GeoJSON文件轉為TopoJSON文件後導出。首先需要明確的一點是,我們使用D3雖然導入的是topojson文件,但D3通過將TopoJSON對象轉換為GeoJSON再繪製地圖,所以實質還是使用GeoJSON對象繪製地圖,和上面的操作並不多少不同。我們主要來看看對象轉換過程:

d3.json("../geojson/china.topojson",function(error,toporoot){
   if(error)
       return console.error(error);

   //輸出china.topojson的對象
    console.log(toporoot);

    //將topoJSON對象轉換為GeoJSON,保存在georoot中
    //然而需要注意的是,實際上在繪製地圖時,還是使用了GeoJSON對象。

    //feature方法返回GeoJSON的特徵(Feature)或特徵集合(FeatureColleciton)
    var georoot = topojson.feature(toporoot,toporoot.objects.china);

    console.log(georoot);

後面繪製過程和使用GeoJSON並不差別,所以不貼代碼了。

那麼為什麼我們要使用TopoJSON呢,除了它文件相比GeoJSON會小很多外,它還能實現一些有趣的功能。
①合併地區

舉個例子,要將東南各省合併在一起用一個顏色表示,那麼就可以使用topojson.merge( )方法來返回一個合併後的幾何體對象,並且其中只保存着我們所需要的幾個省的幾何信息。代碼如下:

var southeast = d3.set([
        "廣東", "海南", "福建", "浙江", "江西",
        "江蘇", "台灣", "上海", "香港", "澳門"
    ]);
    //所有省份
    var georoot = topojson.feature(toporoot,toporoot.objects.china);
    //合併東南各省
    var mergePolygon = topojson.merge(toporoot, toporoot.objects.china.geometries
        .filter(function (d) {
            return southeast.has(d.properties.name); //只有集合中名字相稱的省份才會留下,其他會被filter過濾
        }
    ));

在繪製的時候我們分兩步來繪製,一、繪製除東南各省外的其他省份;二、繪製東南各省,代碼如下:

//先不繪製選中的那幾塊
    svg.selectAll("path")
        .data(georoot.features.filter(function(d){
            return !southeast.has(d.properties.name);  //篩選掉東南各省,不繪製
        }))
        .enter()
        .append("path")
        .attr("class","province")
        .style("fill","#ccc")
        .attr("d",path);

    //繪製東南各省
    svg.append("path")
        .datum(mergePolygon)
        .attr("class","province")
        .style("fill","blue")  //用藍色標註
        .attr("d",path);

繪製後,看看結果:

效果不錯~

②繪製邊界線
假設我們現在需要用藍色標註新疆的西藏的邊界,使其更加顯眼,那該怎麼做呢,下面代碼展示瞭如何使用topojson做到邊界線的繪製:

d3.json("../geojson/china.topojson",function(error,toporoot){
    if(error)
        return console.log(error);

    //獲取西藏和新疆的邊界線
    var boundary = topojson.mesh(toporoot,toporoot.objects.china,function (a,b) {
        //經嘗試發現a和b的取值存在順序關係,參數相反的話無法識別,所以正反條件都加上了
        return (a.properties.name ==="西藏" && b.properties.name ==="新疆")
            or (b.properties.name ==="西藏" && a.properties.name ==="新疆");
    });

    console.log(boundary);

    var georoot = topojson.feature(toporoot,toporoot.objects.china);
	//繪製整體地圖
    svg.selectAll("path")
        .data(georoot.features)
        .enter()
        .append("path")
        .attr("class","province")
        .style("fill","#ccc")
        .attr("d",path);
	
	//繪製特殊邊界線
    svg.append("path")
        .datum(boundary)   //boundary為topojson.mesh方法生成的幾何對象
        .attr("class","boundary")
        .style("fill","none")       //path如果不設置fill為none的話會自帶黑色填充,導致無法呈現為一條線
        .style("stroke","blue")
        .style("stroke-width",3)
        .attr("d",path);

});

效果如圖:

③查找相鄰區域
TopoJSON除了可獲取兩省份的邊界線之外,還可以計算與一個省份相鄰的省份,需要用到topojson.neighbors( )方法,代碼如下:

d3.json("../geojson/china.topojson",function(error,toporoot){

    //通過topojson.neighbors計算所有省份的相鄰省份,保存在數組neighbors裏
    //數組neighbors保存有各省份的鄰省序號
    var neighbors = topojson.neighbors(toporoot.objects.china.geometries);
    var georoot = topojson.feature(toporoot,toporoot.objects.china);

    paths = svg.selectAll("path")
        .data(georoot.features)
        .enter()
        .append("path")
        .style("fill","#ccc")
        .attr("class","province")
        .attr("d",path);

    console.log(paths);
    svg.selectAll("path").each(function (d,i) {
        //為每一個元素添加相鄰省份的選擇集
        d.neighbors = d3.selectAll(
            neighbors[i].map(function(j){  //使用map方法通過序號返回鄰省的path對象
                return paths[0][j];
            })
        );
    }).on("mouseover",function (d,i) {
        //鼠標移入後,變色
        d3.select(this).style("fill","red");
        d.neighbors.style("fill","steelblue");
    }).on("mouseout",function(d,i){
        //鼠標移出後,恢復原來的顏色
        d3.select(this).style("fill","#ccc");
        d.neighbors.style("fill","#ccc");
    });
});

效果如圖:

網格生成器

作為地圖,有時候需要我們添加經緯線,我們可以使用網格生成器來繪製:

var width = 1000, height = 1000;
var svg = d3.select("body")
    .append("svg")
    .attr("width", width)
    .attr("height", height)
    .append("g")
    .attr("transform", "translate(0,0)");

var eps = 1e-4;   //防止網格沒有邊界線,不過因為我們是對中國區域畫網格,並不影響

//創建一個經緯度網格生成器,設置經度和緯度範圍以及步長
var graticule = d3.geo.graticule()
    .extent([[71,16],[137+eps,54]])
    .step([3,3]);

//生成網格數據
var grid  = graticule();
var projection = d3.geo.mercator()
    .center([107,31])
    .scale(800)
    .translate([width/2,height/2]);
var path = d3.geo.path().projection(projection);

d3.json("../geojson/china.topojson",function(error,toporoot){
    if(error)
        return console.error(error);
    var georoot = topojson.feature(toporoot,toporoot.objects.china);
    svg.append("path")
        .datum(grid)
        .attr("class","graticule")
        .style("stroke","steelblue")
        .style("stroke-width","2")
        .attr("d",path);
    svg.selectAll("path.province")
        .data(georoot.features)
        .enter()
        .append("path")
        .attr("class","province")
        .attr("fill", "#ccc")
        .style("stroke","steelblue")
        .attr("d", path );
});


總結

地圖會成為我畢設後續代碼編寫的一塊主要內容,通過這幾天的學習初步掌握了地圖繪製的方式,在以後的代碼編寫中再來鞏固。