leafletで洪水ハザードマップを作成する【OpenStreetMap, 国土数値情報】

# 概要
　2019年は水害によって多大な被害がもたらされました．災害時には自治体のハザードマップにアクセスが集中しサイトが見づらい状況になることもありました．そこで，国土交通省が公開している国土数値情報のシェープファイルをleafletにプロットし，自分でハザードマップを作成しブラウザで確認できるようにしてみます．

　手頃にシェープファイルのデータをプロットする方法として，QGIS等のGISソフトを用いる方法がありますが，本記事ではGISが専門でない幅広い人がプロット結果を見れる方法としてwebアプリを活用します．

　本記事では例として，国土数値情報のうち[低位地帯データ（神奈川県）](http://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-G08-v1_0.html)をプロットし，leafletの洪水ハザードマップを作成します．

作成物イメージ![floodmap3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366359/7536767c-66de-501e-d1ec-1b27799c6b35.gif)



# 環境（主にサーバ側）
- Windows10 Pro 64bit
- Docker for Windows 19.03.2
- docker-conpose 1.24.1
- Node.js 12.13.0 ※expressの雛形形成のためにローカルで使います
- npm --version 6.12.0

#サーバ側の環境構築
### Dockerアプリ構築とMySQLへのシェープファイルのインポート
　サーバ側は，Dockerを使ってnode.js（Express）とMySQLの環境を構築します．MySQLにはGDALのogr2ogrを使ってシェープファイルをインポートします．サーバ側の環境構築方法は，[DockerのMySQLに，GDALを使ってシェープファイルをインポートしてみる](https://qiita.com/hiromasano/items/0b0a8af3887e6e0cee8f)の記事を参考にしてください．本記事はその続きの位置づけです．
　
　また，今回の作成物を[git](https://github.com/hiromasano/flood-map)に置いてありますので，適宜こちらも利用ください．


　構築したサーバのディレクトリ構造は下記のようになっています（重要なものを抜粋）．
./mayapp（gitではflood-map）/
　　┣conf.f/
　　┃　　┗nodejs.conf
　　┣db/
　　┃　　┗mysql-data/
　　┣geodata/
　　┃　　┗G08-15_14_GML/
　　┃   　　　┗G08-15_14.shpなど （国土数値情報からDLしてきたデータ）
　　┣public/
　　┃　　┣javascripts/
　　┃　　┗stylesheets/
　　┣routes/
　　┣views/
　　┣app.js
　　┗docker-compose.yml

#クライアント側の実装
### view を作成
下記スクリプトを`.myapp/views/flood-map.ejs`として保存します．今回のExpress環境では，ビューエンジンをejsとしております（htmlっぽく書けるがhtmlよりも便利）．

```html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>flood-map</title>
    <!--bootstrap-->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" 
      integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" 
      crossorigin="anonymous">
    <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js"
      integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" 
      crossorigin="anonymous">
    </script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"
      integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
      crossorigin="anonymous">
    </script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js"
      integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6"
      crossorigin="anonymous">
    </script>
    <!--leaflet-->
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.5.1/dist/leaflet.css"
      integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
      crossorigin=""/>
    <script src="https://unpkg.com/leaflet@1.5.1/dist/leaflet.js"
      integrity="sha512-GffPMF3RvMeYyc1LWMHtK8EbPv0iNZ8/oTtHPx9/cc2ILxQ+u905qIwdpULaqDkyBKgOaB57QTMg7ztg8Jm2Og=="
      crossorigin="">
    </script>
    <!--jQuery-->
    <script src="https://code.jquery.com/jquery-3.0.0.min.js"></script>
    <!--public-->
    <link rel="stylesheet" type="text/css" href="/stylesheets/osm.css">
    <script type="text/javascript" src="/javascripts/show-flood-map.js"></script>
    <script type="text/javascript" src="/javascripts/plot-floods.js"></script>
  </head>
  <body>
    <div class="dropdown" style="position: absolute; top: 10px; left: 10px; z-index: 1000;">
      <button class="btn btn-secondary dropdown-toggle" type="button" id="depthMenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
        <span id="dropdownLabel">最大浸水深</span>
      </button>
      <div class="dropdown-menu" aria-labelledby="depthMenu"> 
        <button class="dropdown-item" type="button" value=0.1>0.1m以上</button>
        <button class="dropdown-item" type="button" value=0.5>0.5m以上</button>
        <% for (var i = 1; i <= 10; i++) { %>
          <button class="dropdown-item" type="button" value="<%= i %>"><%= i %>m以上</button>
        <% } %>
        <button class="dropdown-item" type="button" value=15>15m以上</button>
        <button class="dropdown-item" type="button" value=20>20m以上</button>
      </div>
    </div>
    <div id="mymap" style="height: 100%; width: 100vw;"></div>
  </body>
</html>
```

#### ヘッダでインポートするもの
- bootstrap関連のjsとCSS
- leafletのjsとCSS
- jQuery
- 後ほど作成する，leafletを全画面で表示するためのCSS
- 後ほど作成する，`show-flood-map.js`と`plot-floods.js`

####bodyの概説
- **ドロップダウン**
　浸水する深さを切り替えて表示できるように，bootstrapのドロップダウンを配置します．leafletの地図に重ねて表示したいので styleで *position: absolute* を指定しています．
- **leafletの地図そのもの** 
　後に作成するCSSを当てることで画面のサイズに対して100%表示をします．

###CSSの作成
　[公式ドキュメント](https://leafletjs.com/examples/mobile/)を参考に，leafltetの地図を画面100%表示するためのCSSを作成しておき，`./myapp/public/stylesheets/`配下に置きます．

```./myapp/public/stylesheets/osm.css
body {
	padding: 0;
	margin: 0;
}
html, body, #mymap {
	height: 100%;
	width: 100vw;
}
```

### leaflet表示用のJS実装
　まず，leaflet表示用のJSを作成します．今回は，洪水地点を画面で表示している領域のものに限定してデータを取得しプロットする仕様にします．よって地図をスクロールする都度データの取得と再描画を行います（全件取得しない）．
　またプルダウンで表示する洪水の深さが変更された場合，同様に再描画を行います．プルダウンのイベントはjQueryで取得します．プルダウン選択後は，プルダウンのメニューにどの深さが選択されているか表示します．
　leafletの地図はズームアウトできますが，過度にズームアウトされた場合にはデータを取得せずにメッセージのみ表示します．これは，ズームアウトされた状態であまりに多くのデータを表示しないように制御するためです．

```./myapp/public/javascripts/show-flood-map.js
$(document).ready(function () {
  var mymap = L.map('mymap').setView([35.532169,139.695773], 15); //地図の初期表示位置を川崎駅付近に設定
 
  /*地図のタイル設定*/
  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
    maxZoom: 19,
    attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
  }).addTo(mymap);
  mymap.zoomControl.setPosition('bottomright');

  var layer = L.layerGroup().addTo(mymap); //洪水地点を描画するためのレイヤ
  var depth = 0.1; //洪水する深さの初期設定

  getFloodData(mymap, layer, depth); //洪水する地点を描画（後述のplot-floods.jsに定義）

  /*地図をスクロールして動かし終わったら洪水地点を再描画*/
  mymap.on('moveend', function (ev) {
    replot();
  });

  /*ドロップダウンで表示する洪水の深さが選択されたとき，値を設定し洪水地点を再描画*/
  $('.dropdown-menu .dropdown-item').on('click', function (e) {
    depth = parseFloat($(this).attr('value'));
    replot();
    $('#dropdownLabel').text($(this).attr('value') + 'm以上');
  });

  /*洪水する地点を再描画する処理*/
  function replot(){
    mymap.removeLayer(layer);
    layer = L.layerGroup().addTo(mymap);

    if (mymap.getZoom() < 12){
      /*地図がズームアウトされている場合はデータを取得しない*/
      var popup = L.popup()
        .setLatLng(mymap.getCenter())
        .setContent('<h3>情報を表示するには<br>ズームしてください．</h3>')
        .openOn(mymap);
      layer.addLayer(popup);
    }else{
      getFloodData(mymap, layer, depth); //洪水する地点を描画（後述のplot-floods.jsに定義）
    }
  }
});

```

### サーバからデータ取得・取得データの描画用のJS実装

　次に，APIを叩いてサーバ側からデータを取得し，洪水地点を地図に描画するJSを実装します．leaflet地図を画面で表示している緯度経度の範囲と，プルダウンで選択されている洪水の深さをパラメータとしてAPIを叩き，それらに対応する洪水地点のデータを取得します．APIは ajax を使って HTTP GET を実施します．

```./myapp/public/javascripts/ploot-floods.js
function getFloodData(mymap, layer, depth){
  var pram = setPram();

  /*APIを叩いてデータを取得する処理*/
  $.ajax({	
    url:"http://localhost:3000/data",
    type:"GET",
    dataType:"json",  
    timespan:10000,
    data: pram
	}).done(function(data) {
    plotFloods(data);
  });

  /*API用のパラメータ作成処理*/
  function setPram(){
    var bound = mymap.getBounds(); //現在ブラウザで表示しているleaflet地図の範囲の緯度経度
    var pram = {
      'northEastLat': bound['_northEast']['lat'],
      'northEastLng': bound['_northEast']['lng'],
      'southWestLat': bound['_southWest']['lat'],
      'southWestLng': bound['_southWest']['lng'],
      'depth': depth
    }
    return pram;
  }

  /*描画処理*/
  function plotFloods(data){
    for(let shape in data){
      var latlngs = [];
      for(let pos in data[shape]['ExteriorRing(shape)']){
        latlngs.push([data[shape]['ExteriorRing(shape)'][pos].y, data[shape]['ExteriorRing(shape)'][pos].x]);
      } 
      var polygon = L.polygon(latlngs, {color: 'red'});
      layer.addLayer(polygon);
    }
  }
}
```
【参考】APIの戻り値の例

```json
[
  {"ExteriorRing(shape)":[
    {"x":139.651210981754,"y":35.315688440883},
    {"x":139.651182463762,"y":35.3156591480579}, 
    {"x":139.651182419259,"y":35.31567903685},
    {"x":139.651178512664,"y":35.3156969889494}, 
    {"x":139.65117844455,"y":35.31572742931},
    {"x":139.651210981754,"y":35.315688440883}
  ]}
]
```

ここまででクライアント側の実装が終わりました．再びサーバ側の実装に戻ります．

# APIサーバの作成
 まず，必要なnpmモジュールをインストールします．ayapp直下で下記を実行します．

```
$ npm install --save mysql
$ npm install --save url
```

　次に，Expressのサーバ側にAPIサーバを構築します．`routes/`配下にapiフォルダを作成し，下記スクリプトを置きます．HTTP GETメソッドを受信したらパラメータをparseし，SQLを実行してDBからデータを取得します．hostはMYSQLのコンテナ名を指定します．
　SQLについて詳しくは[MySQL 空間分析関数](https://dev.mysql.com/doc/refman/5.6/ja/spatial-analysis-functions.html)を参照．

```api-db-data.js
var express = require('express');
var router = express.Router();

var sql = require('mysql');
var url = require('url');
var con = sql.createConnection({
  host: 'mysql',
  user: 'user1',
  password: 'user1',
  database: 'flood_map'
});

router.get('/', function(req, res, next) {
  var url_parse = url.parse(req.url, true);
  var bound =  url_parse['query'];
  con.query('SET @bound = GeomFromText(\'Polygon((? ?,? ?,? ?,? ?,? ?))\', 1);',
            [bound['northEastLng'], bound['northEastLat'],
             bound['southWestLng'], bound['northEastLat'],
             bound['southWestLng'], bound['southWestLat'],
             bound['northEastLng'], bound['southWestLat'],
             bound['northEastLng'], bound['northEastLat']].map(Number));
  con.query('SELECT ExteriorRing(shape) FROM g08_15_14 WHERE MBRIntersects(shape, @bound) = 1 AND g08_002 >=?;', parseFloat(bound['depth']),(e,r)=>{
    res.json(r);
    return;
  }); 
});

module.exports = router;
```

# ルーティングの設定実施
　viewで作成したejsファイルやAPIサーバに，`/floodmap`のURLからアクセスできるようにルーティングを実施します．`app.js`に下記のコメントアウトに示す4か所を追記します．

```./myapp/app.js
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');

var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');
var floodMap = require('./routes/flood-map'); //★追加
var apiDatabase = require('./routes/api/api-db-data'); //★追加

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', indexRouter);
app.use('/users', usersRouter);
app.use('/floodmap', floodMap); //★追加
app.use('/data', apiDatabase); //★追加

<中略>

module.exports = app;
```

　また，`http://localhost:3000/floodmap`で最初のページを読み込むGETリクエストを受信した際に，作成したejsファイルを返すように設定します．下記スクリプトをroutesの中に置きます．

```./myapp/routers/flood-map.js
var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res, next) {
  res.render('flood-map');
});

module.exports = router;
```

#サーバの起動とページへのアクセス
サーバ起動

```
$ docker-compose up -d
```

`http://localhost:3000/floodmap` にアクセス

サーバ終了

```
$ docker-compose down
```

# まとめ
　本記事では，以前に構築したDockerとExpress環境を利用して，leafletに描画する洪水ハザードマップを作成しました．少なくとも筆者のPCとlocalhost環境では，描画が極端に遅いこともなく，数十から数百程度の頂点を持つ領域を複数描画しても地図がちらつく等も起きませんでした．
　また，国土交通省のシェープファイルとleafletのOpenStreetMapのタイルで座標のずれも見られず，測地系の変換等は気にすることはなさそうです．

#参考文献
- MySQL 空間分析関数: https://dev.mysql.com/doc/refman/5.6/ja/spatial-analysis-functions.html (accessed 2019/12/8).