一張新型肺炎地區分佈地圖是怎麼製作的?

Ginkgoch發表於2020-02-09

前言

2020年剛開始,各鐘不幸的訊息滿天飛。新型肺炎的蔓延,科比去世… 無時無刻讓我感到痛楚。為了不給國家添亂,新年幾天都在窩在家裡。時不時拿起手機,觀察一下現在病情蔓延情況。下面這張地圖就是一張典型的GIS應用。

infection-cover-status-demo

每當我看到這張靜態圖時,很想要知道幾個資訊無法獲知。

  1. 我們能通過顏色和圖例比較一個省的確診人數範圍,看不到一個省具體患病人數。
  2. 由於是一張靜態圖,我們沒法獲市級資料。如果地圖可以拖動,放大縮小就簡單多了。
  3. 每次看到紅色,心裡都很焦慮。能換成其他顏色,我自己更加能接受點。

基於這兩個小功能,我準備介紹一下怎麼去製作一張地圖。我準備分兩個階段來做介紹。

  1. 先用最簡潔的程式碼來生成一張靜態圖片。通過這個階段,讓我們認識一下一般地圖應用開發的流程。
  2. 當我們瞭解流程以後,我們就把這個程式改造成地圖服務,讓她和知名的地圖前端庫Leaflet合併開發一個可互動的地圖,整合點有趣的功能。

這篇文章,我準備先從製作一張靜態圖片開始。

讓我們從環境開始

以前開發地圖應用軟體,可能需要掌握很多程式語言技能,才能勝任一個完整的專案。比如一個典型的GIS B/S應用一般會使用Java, C#或其他後端程式語言來開發後端,然後用JavaScript + HTML來開發前端展現。

今天用我們熟悉的JavaScript;即使是前端開發人員也可以開發後端地圖應用了。追求極簡開發環境的話,我們只需要2個工具。Node.js (推薦8以上,或者直接安裝最新版本都是相容的)和 vscode.

這篇文章照顧新手,寫的比較多。老鳥請自行過濾。勿噴。

建立工程,新增引用

接著,我們建立一個工程目錄。用以下命令就可以了。(我個人比較喜歡使用命令列,由於平時都是用macOS做日常使用機器。所以以下命令列都是macOS執行驗證的)。

# 建立專案目錄
cd [your workspace]
md nCoV-map
cd nCoV-map

# 建立功能,新增引用
npm init -y
npm i --save ginkgoch-map canvas lodash

# 新建一個檔案,這個將是我們寫程式碼的地方
touch tutorial-01.js

這裡引用了canvas庫,是因為Node.js沒有提供繪圖API,我們只能引用一個第三方Node.js庫來替代使用。

到這裡,我們的工程已經建立好了。

GIS資料

GIS應用裡面資料是很重要的。我把她分為靜態資料和動態資料。靜態資料就是我們的幾何圖形以及她們特定的特徵資料。如地區的名字等。動態資料就是我們實時關注的疫情變化。

一般靜態資料比較容易找到。百度搜尋中國地圖資料csv, json, shapefile都可以找到。這個專案裡面,我準備使用shapefile作為我的靜態資料。這裡你可以找到以下資料,我們一會兒會使用到。把上面資料下載下來以後,放到工程的data目錄下面。

  • chn/
    • gadm36_CHN_1_3857.shp - 省級資料
    • gadm36_CHN_2_3857.shp - 市級資料
  • cntry02.shp - 世界國家資料

動態資料會麻煩點。我是寫了一個爬蟲,定時爬取。有興趣可以私聊。不過作為例子,我放上了幾份疫情資料在data/infected目錄裡面以便做示例。

剩下的工作就很簡單了

疊加世界資料

首先,我們定義一個函式來建立一個地圖的圖層,一個資料來源即一個資料圖層,多個資料圖層疊加起來就可以構成我們期望的樣式。使用ginkgoch-map,我們是這樣定義一個圖層的。

function createLayerWithDefaultStyle(filePath) {
    // create a source with the specified shapefile file path
    let source = new GK.ShapefileFeatureSource(path.resolve(__dirname, filePath));

    // wrap the source as a world layer
    let layer = new GK.FeatureLayer(source);

    // set a style on the layer
    layer.styles.push(new GK.FillStyle('#f0f0f0', '#636363', 1));

    return layer;
}

有了layer, 我們可以簡單檢視我們資料圖層的樣子。比如對於資料cntry02.shp:

let worldLayer = createLayerWithDefaultStyle('../data/cntry02.shp');
await worldLayer.open();
let worldImage = await worldLayer.thumbnail(512, 512);
fs.writeFileSync(path.resolve(__dirname, './images/tutorial-01-world.png'), worldImage.toBuffer());

我們通過命令列執行下面的語句。我們可以找到圖片:

node tutorial-01.js

tutorial-01-world

疊加中國資料

當然這個不是我們想要的樣子,我們還需要把省份的資料疊加在上面,才能看到我們中國詳細一點的資料。我們接著做。

const [imageWidth, imageHeight] = [512, 512];

// create a world layer with cntry02.shp
let worldLayer = createLayerWithDefaultStyle('../data/cntry02.shp');

// create a province layer with gadm36_CHN_1_3857.shp
let provinceLayer = createLayerWithDefaultStyle('../data/chn/gadm36_CHN_1_3857.shp');

let mapEngine = new GK.MapEngine(imageWidth, imageHeight);
mapEngine.srs = new GK.Srs('EPSG:900913');
mapEngine.pushLayer(worldLayer);
mapEngine.pushLayer(provinceLayer);

let image = await mapEngine.image();
fs.writeFileSync(path.resolve(__dirname, './images/tutorial-01-default.png'), image.toBuffer());

現在再開啟圖片tutorial-01-default.png, 注意檢視中國的資料已經疊加成功了。

tutorial-01-default

調整可視範圍

哦?太小了~ 好,我們調整下地圖的可視範圍。

await provinceLayer.open();
let chinaEnvelope = await provinceLayer.envelope();
chinaEnvelope = GK.ViewportUtils.adjustEnvelopeToMatchScreenSize(chinaEnvelope, imageWidth, imageHeight);

let image = await mapEngine.image(chinaEnvelope);
fs.writeFileSync(path.resolve(__dirname, './images/tutorial-01-china.png'), image.toBuffer());

tutorial-01-china

連線動態資料

我們對靜態資料進行了渲染,同時對中國省份級別的邊框進行繪製。接下來,我們將連線動態資料,把動態的感染人數和地圖對應起來。我們怎麼做呢?

首先,我們先看下靜態資料的特徵資料。Shapefile的特徵資料存放在*.dbf檔案裡面。你可以選擇使用你常用的工具開啟dbf檔案。我個人一般使用的是shapefile viewer來檢視。參考這裡獲取程式及使用說明

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-NUD1zDlD-1580548031579)(./tutorials/images/china-attributes.png)]

NL_NAME_1就是我們需要的省份名字,然後我們來看看動態資料的一個片段。

[
    {
        "provinceName": "湖北省",
        "provinceShortName": "湖北",
        "confirmedCount": 4586,
        "suspectedCount": 0,
        "curedCount": 90,
        "deadCount": 162,
        "comment": "待明確地區:治癒 30",
        "cities": [
            {
                "cityName": "武漢",
                "confirmedCount": 2261,
                "suspectedCount": 0,
                "curedCount": 54,
                "deadCount": 129
            },
            {
                "cityName": "黃岡",
                "confirmedCount": 496,
                "suspectedCount": 0,
                "curedCount": 5,
                "deadCount": 12
            },
            ...

有趣的是,動態資料也包含省份的名字provinceShortName;以及感染,疑似,治癒以及死亡的人數。現在,我們要做的就是通過靜態資料的NL_NAME_1和動態資料的provinceShortName相等的資料相關聯。在ginkgoch-map裡面是這樣做的。

首先,我們定義一個函式來幫助我們定義一個關係。

/**
 * field - the dynamic field value to return.
 * infectedData - the infected data in JSON format.
 */
function _getDynamicFieldForProvince(field, infectedData) {
    return {
        name: field, fieldsDependOn: ['NL_NAME_1'], mapper: feature => {
            const fullName = feature.properties.get('NL_NAME_1');
            const infectionInfo = _.find(infectedData, d => {
                return fullName.includes(d.provinceShortName);
            });

            if (infectionInfo === undefined) {
                return undefined;
            } else {
                return infectionInfo[field];
            }
        }
    };
}

然後建立4個列的對映函式。

function connectDynamicData(layer) {
    // load dynamic data
    let dynamicData = fs.readFileSync(path.resolve(__dirname, '../data/infected/1580376765333.json')).toString();
    dynamicData = JSON.parse(dynamicData);
    
    // connect 4 dynamic attribute fields to the source.
    const source = layer.source;
    source.dynamicFields.push(_getDynamicFieldForProvince('confirmedCount', dynamicData));
    source.dynamicFields.push(_getDynamicFieldForProvince('suspectedCount', dynamicData));
    source.dynamicFields.push(_getDynamicFieldForProvince('curedCount', dynamicData));
    source.dynamicFields.push(_getDynamicFieldForProvince('deadCount', dynamicData));
}

最後,我們呼叫這個函式進行對映。

//...省略前後重複的程式碼
let provinceLayer = createLayerWithDefaultStyle('../data/chn/gadm36_CHN_1_3857.shp');
connectDynamicData(provinceLayer);

至此,我們可以認為provinceLayer已經包含了動態資料。她將在需要的時候動態的去通過對映關係找到需要的資料來使用。

樣式化地圖

做到這裡,大家可以去休息一下。迎接最後一步:地圖樣式化。我們把感染人數分成幾個等級,根據等級渲染不同的顏色來表示嚴重程度。比較好的是,ginkgoch-map提供了對應的API來渲染。

我們先定義個函式來建立樣式化物件Style.

function _getClassBreakStyle(field) {
    const strokeColor = '#636363';
    const strokeWidth = 1;

    let countStops = [1, 10, 50, 100, 300, 500, 750, 1000, Number.MAX_SAFE_INTEGER];
    let activePallette = ['#fff5f0', '#fee0d2', '#fcbba1', '#fc9272', '#fb6a4a', '#ef3b2c', '#cb181d', '#67000d']
    let style = new GK.ClassBreakStyle(field);

    for (let i = 1; i < countStops.length; i++) {
        style.classBreaks.push({ minimum: countStops[i - 1], maximum: countStops[i], style: new GK.FillStyle(activePallette[i - 1], strokeColor, strokeWidth) });
    }

    return style;
}

再應用到layer上即可看到效果。

let confirmedCountStyle = _getClassBreakStyle('confirmedCount');
provinceLayer.styles.push(confirmedCountStyle);

// 順便我們把文字渲染上去,即可完成。
let provinceLabelStyle = new GK.TextStyle('[NL_NAME_1]', 'black', 'Arial 12px');
provinceLabelStyle.lineWidth = 2;
provinceLabelStyle.strokeStyle = 'white';
provinceLabelStyle.location = 'interior';
provinceLayer.styles.push(provinceLabelStyle);

chn-confirmed-map.png

是不是很有趣?我們現在可以隨意替換調色盤,讓她變成藍色系的。替換這一句即可。

// let activePallette = ['#fff5f0', '#fee0d2', '#fcbba1', '#fc9272', '#fb6a4a', '#ef3b2c', '#cb181d', '#67000d'];
let activePallette = ['#f7fbff', '#deebf7', '#c6dbef', '#9ecae1', '#6baed6', '#4292c6', '#2171b5', '#08519c'];

final-infection-map.png

寫在最後

最終的程式碼可以在這裡下載:https://github.com/ginkgoch/nCoV-map/tree/develop/tutorials

看起來很多,大多數程式碼都是業務程式碼,和對樣式的設定。瞭解起來還是挺簡單的。今天就到這裡,後面有時間,我再寫一篇搭建一個地圖服務,製作一個可以互動的地圖應用。有問題可以隨時聯絡我, ginkgoch@outlook.com

Happy Mapping!

相關文章