自治体オープンデータの防災避難所情報を加工してLeafletJSで表示した際の気付き

# はじめに

自治体の公開情報を使って防災避難所マップを作成しました。
その過程で気付いたこと、特にデータ加工とウェブ地図化に関わることを記します。

作成したマップ：[リンク（外部サイト）](https://kashiwa.co-place.com/shelter/)
サンプルコードについては、**記事の最後**に簡略化したものを載せます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122865/ec9e08b7-6d9f-f5e1-eb5e-25d3ff466aca.png)


# ウェブ地図アプリの構成

ウェブ地図アプリの構成は下記のようにしました。地図描画用のフロントエンドライブラリにはLeafletJS（[外部リンク](https://leafletjs.com/reference.html)）を採用しています。
Leafletの採用理由としては、**ウェブ地図がとてもシンプルな構成で実装できる**ためです。

```tree.txt
防災避難所マップ
├── index.html
├── app
    ├── js
    │   ├── plugin //LeafletJSプラグイン
    │   └── main.js
    ├── css
    │   ├── plugin //LeafletJSプラグイン
    │   └── main.css
    └── data //避難所データ, 市域境界データ
```

コードはこちらの[リンク先（GitHub）](https://github.com/sanskruthiya/kashiwa-shelter/tree/main)においています。LeafletJSプラグインはかなり古いものも使っている関係で詳細は割愛しますが、代わりにプラグイン等を省いた簡略コードを記事後半に載せました。

プラグインについては下記をご参照ください。
リンク：[LeafletJSプラグイン一覧](https://leafletjs.com/plugins.html)

# 加工データの構成
避難所のデータは地元の自治体（千葉県柏市）が公開しているオープンデータ等の情報を整理しました。

## 加工元のデータについて

- 自治体のオープンデータ専用ページ：[柏市オープンデータの公開](https://www.city.kashiwa.lg.jp/databunseki/shiseijoho/jouhoukoukai/opendate/kokainitsuite.html)
- 自治体の防災情報ページ：[柏市防災情報](https://www.city.kashiwa.lg.jp/anshinanzen/disaster/index.html)

## データ加工における留意点

この作業過程で理解したことですが、**一言で「避難所」といっても行政の定義上はいくつか種類が分かれている**ので注意が必要です。具体的には、今回のケースでは下記の種類が該当しました。

| 種類 | 概要 |
|:-|:-|
|  指定避難所 |  宿泊滞在可能な体育館などで、大きな地震や浸水、火事で家に住めなくなった場合に利用する。 |
| 緊急避難場所  | 公園や学校の校庭など、一時避難用のオープンスペース。地震や火事から一旦避難する時に使えるけど、屋外なので豪雨や水害には向かない。  |
|  広域避難場所 | 緊急避難場所の広域版。大きな地震があったときに広域から多くの人が避難できる、防災公園などの広いスペース。  |

上記の他に、自治体によっては「福祉避難所」という区分もあるようです。

また、管轄が「市」か「県」か、というのも注意が必要でした。なぜなら、**市のHPの一部には県管轄の防災避難所情報が載っていないこともあった**からです。

こういった区分名称は自治体における定義としては重要なのですが、ユーザーとなる一般市民にとっては少しわかりづらく、どう扱うべきか悩ましかったです。結論として、とりあえず網羅的にデータを整理しました。

## 加工後データの構成

加工後の避難所データの属性項目は下記の通りです。

| 属性名 | 入力例 | 説明 |
|:-|:-|:-|
| id | 1 | 連番ID |
| name  | 市立田中北小学校  | 場所名 |
|  name_alias | 田中北小学校  | 場所名（略称等） |
|  type | 避難所  | 避難所の種別名 |
|  address | 船戸X-X-X  | 住所 |
|  tel | 04-XXXX-XXXX  | 電話番号 |
|  community_name | 田中・柏の葉  | 地域名 |
|  shelter_area_m2 | 1074  | 屋内避難所として使える面積(m2) |
|  shelter_capacity_per4m2 | 187  | 屋内避難可能人数（通常時） |
|  shelter_capacity_per2m2 | 375  | 屋内避難可能人数（詰めた時） |
|  site_area_m2 | 7695  | 屋外避難に使える敷地面積(m2) |
|  site_capacity | 3847  | 屋外避難可能人数 |
|  note | 防災備蓄倉庫, 耐震性井戸付貯水装置, 防災用簡易井戸  | 備考（付帯設備等） |


ここで、**住民にとって特に重要な情報は「場所」と「キャパシティ」** だと考えて、その情報をウェブサイトや追加調査等を実施した上で載せています。

キャパシティについても、**屋内で簡易宿泊などを行う際の避難可能人数と、屋外で一時避難に使う際の避難可能人数が異なります**。小学校で言うと、校庭に一時避難するか、体育館を避難所として使うかでそれぞれ収容可能人数が異なるので、同一場所でも両方の情報が必要となります。

## 位置座標について

前述のデータ元情報において、住所情報はあるものの位置座標の値が付与されていないため、[CSV アドレス マッチング サービス](https://geocode.csis.u-tokyo.ac.jp/home/csv-admatch/) などを用いて位置座標の付与を行いました。
「など」と表現しましたが、実際には大まかな座標値をアドレスマッチングサービスにて付与した上で、地理情報システムQGISを用いてOpenStreetMapを背景地図に重ねた上で、**地元の現地調査も含めて手動補正**しています。
これは手間のかかる作業ですが、**自治体の公式オープンデータであっても住所などが移転に伴う古い情報のままだったりする**ため、実は非常に重要でした。

手順を整理すると下記の流れになります。
1. 住所情報からアドレスマッチングサービスを用いて座標値を付与
2. QGISでOpenStreetMapを背景地図として重ねて位置を手動補正
3. 必要に応じて現地調査やウェブ調査での裏取りを行ってデータを確認・修正


その結果、下記のようにデータが整理できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122865/075424fa-8af2-40cb-ff33-a0f54ebc5ce0.png)

# 運用における留意点

整理した市内の防災避難所は全部で147ヶ所ありましたが、実際に作ってみると、**ある時の災害に対して全ての避難所が開設されるとは限らない**こともわかりました。

考えてみれば当然なのですが、例えば豪雨災害が予想されていれば、自治体内の全域ではなく危険なエリアに絞って避難所を開設したり、屋外ではなく屋内避難所のみを開設するのが現実的です。
実例として、ある豪雨予報の日に下記のような防災メールが来ており、その際は147箇所中20ヶ所が開設されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122865/75c6a2b5-a265-44c9-3afb-d693da52252e.png)

とはいえ、あらかじめ自治体内の全域の避難所情報がきちんと整理されていれば開設箇所のみをフラグ立てして表示を切り替えれば良いので、実際そのように運用してみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122865/26f74e47-3ef3-64d7-f3d8-790dd9a2a78b.png)

防災マップというものは、普段から気にしていればベストですが現実問題として何か非常時にならないと気にも留めないと思います。
ただ、いざ非常時になったとき、防災情報が古かったり不正確だったりするとせっかくの自治体オープンデータも使い物にならないので、**平常時からこうした情報整理をしておくことが大事**だと思いました。

今回のケースでも、**データを整理する中で気がついた点や修正要望は自治体に対してフィードバック**をしています。

# サンプルコード

最後に、LealefJSをベースにした地図アプリケーションのコードについて、今回の事例をもとに紹介しておきます。初学者の方向けとなりますが、データを差し替えたりプラグインで機能を追加したりしてアレンジしてみてください。
なお、このサンプルコードは簡易化のためHTMLファイル内にCSSやJavaScript、GeoJSON形式のデータを全て記述していますが、実際にはそれらは別々のファイルに分割して読み込んでいます。

```index.html
<!doctype html>
<html lang="ja">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="initial-scale=1, user-scalable=no,maximum-scale=1,width=device-width">
        <meta name="mobile-web-app-capable" content="yes">
        <meta name="apple-mobile-web-app-capable" content="yes">
        <meta name="description" content="sample">
        <!--LeafletJSのCSS読み込み-->
        <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
        <!--アプリケーションのCSS設定-->
        <style type="text/css">
            html, body {
                margin: 0; 
                height: 100%; 
                width: 100%; 
                -webkit-text-size-adjust : 100%;
            }
            #map {
                height: 100%;
            }
            table.tablestyle {
                table-layout: auto; 
                width: 100%;
                color:#111; 
                font-size: 13px; font-weight: 500; font-family: Helvetica, "游ゴシック", sans-serif;
                border-collapse: collapse; border-spacing:0; border:1px solid #777;
            }
            table.tablestyle th, table.tablestyle td{
                padding: 4px; 
                text-align: left;
                min-width: 80px;
                max-width: 200px;
            }
            table.tablestyle tr:nth-child(odd){
                background-color: #eee
            }
            .map-overlay {
                font-size: 12px; font-weight: 500; font-family: Helvetica, "游ゴシック", sans-serif;
                position: relative;
                width: 180px;
                padding: 5px;
                background: rgba(255,255,255,0.9);
                box-shadow: 0 0 15px rgba(0,0,0,0.2);
                border-radius: 2px;
                line-height: 20px;
            }
            .map-overlay i.style01 {
                width: 18px;
                height: 18px;
                float: left;
                margin-right: 8px;
                opacity: 0.9;
            }
            .info {
                padding: 0.5em 1em;
                color: #232323;
                border:1px solid #999;
                border-radius: 5px;
                background: rgb(255,255,255,0.9);
                border-left: solid 10px #1bc205;
            }
            .info p.info-title {
                font-size: 14px; font-weight: 400; font-family: Helvetica, "游ゴシック", sans-serif;
                color:#333;
                margin: 0; 
                padding: 0;
            }
            .leaflet-tooltip.class-tooltip {
                background: transparent;
                border: 1px solid transparent;
                box-shadow: 1px 1px 1px transparent;
            }
            .leaflet-tooltip-left.class-tooltip::before {
                border-left-color: #333;
            }
            .leaflet-tooltip-right.class-tooltip::before {
                border-right-color: #333;
            }
            .leaflet-tooltip.class-tooltip p.tooltipstyle {
                color:#fff;
                font-size: 12px; font-weight: 400; font-family: Helvetica, "游ゴシック", sans-serif;
                background: rgba(10,10,10,0.6);
            }
        </style>
        <title>Sample</title>
    </head>
    <body>
        <div id="map"></div>
        <!--LeafletJSの読み込み-->
        <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
        <!--以下、アプリケーションの描画設定スクリプト-->
        <script>
            //GeoJSON形式のサンプルデータ
            const json_poi = {
                "type": "FeatureCollection",
                "name": "shelter",
                "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },
                "features": [
                    { "type": "Feature", "properties": { "fid": 1, "name": "田中北小学校", "address": "船戸1-7-1", "tel": "04-XXXX-XXXX", "community_name": "田中・柏の葉", "shelter_area_m2": "1074", "shelter_capacity_per4m2": "187", "shelter_capacity_per2m2": "375", "site_area_m2": "7695", "site_capacity": "3847", "type": "避難所", "alias": "田中北小学校", "note": "防災備蓄倉庫, 耐震性井戸付貯水装置, 防災用簡易井戸" }, "geometry": { "type": "Point", "coordinates": [ 139.951626910429553, 35.913850500126053 ] } },
                    { "type": "Feature", "properties": { "fid": 137, "name": "海上自衛隊下総航空基地", "address": "藤ヶ谷１６１４番地１", "tel": "04-XXXX-XXXX", "community_name": "風早南部", "shelter_area_m2": "－", "shelter_capacity_per4m2": "－", "shelter_capacity_per2m2": "－", "site_area_m2": "13541", "site_capacity": "6770", "type": "緊急避難場所", "alias": "海上自衛隊下総航空基地", "note": "－" }, "geometry": { "type": "Point", "coordinates": [ 140.021649837703137, 35.804891848564594 ] } },
                    { "type": "Feature", "properties": { "fid": 146, "name": "大堀川防災レクリエーション公園", "address": "篠籠田119-3", "tel": "－", "community_name": "－", "shelter_area_m2": "－", "shelter_capacity_per4m2": "－", "shelter_capacity_per2m2": "－", "site_area_m2": "25520", "site_capacity": "12760", "type": "広域避難場所", "alias": "大堀川防災レクリエーション公園", "note": "防災備蓄倉庫（教室）、防災用簡易井戸" }, "geometry": { "type": "Point", "coordinates": [ 139.974064322370651, 35.872689408957775 ] } },
                ]
            }
            const label_data = {
                "type": "FeatureCollection",
                "name": "label",
                "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },
                "features": [
                    { "type": "Feature", "properties": { "fid": 2, "name_tag": "柏の葉キャンパス駅", "layer": "鉄道駅" }, "geometry": { "type": "MultiPoint", "coordinates": [ [ 139.952525, 35.893315 ] ] } },
                    { "type": "Feature", "properties": { "fid": 4, "name_tag": "柏駅", "layer": "鉄道駅" }, "geometry": { "type": "MultiPoint", "coordinates": [ [ 139.970953968765883, 35.862100836620563 ] ] } },
                    { "type": "Feature", "properties": { "fid": 9, "name_tag": "高柳駅", "layer": "鉄道駅" }, "geometry": { "type": "MultiPoint", "coordinates": [ [ 139.99898, 35.808305 ] ] } }
                ]
            }

            //マップの基本設定
            const map = L.map('map', {
                zoomControl: false,
                zoomSnap: 0.5,
                minZoom: 12,
                maxZoom: 18,
                condensedAttributionControl: false
            }).setView([35.8622,139.9709],12.5);
            
            const bounds = [[36.5000,139.5000], [35.0000,141.0000]];
            map.setMaxBounds(bounds);

            //ベースマップのURL指定
            const basemap_osm = L.tileLayer('https://tile.openstreetmap.jp/styles/osm-bright-ja/{z}/{x}/{y}.png', {attribution: '&copy; <a href="https://openstreetmap.org">OpenStreetMap</a> contributors',maxZoom: 28});
            const basemap_gsi = L.tileLayer('https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg', {attribution: '&copy; <a href="https://maps.gsi.go.jp/development/ichiran.html">地理院タイル</a>',maxZoom: 18});
            const flood_gsi = L.tileLayer('https://disaportaldata.gsi.go.jp/raster/01_flood_l2_shinsuishin_data/{z}/{x}/{y}.png', {attribution: '&copy; <a href="https://disaportal.gsi.go.jp/index.html">重ねるハザードマップ</a>',maxZoom: 17});
            
            //ラベルの設定（LeafletJSのツールチップ機能を応用）
            const label_marker = {radius: 0, fillColor: "transparent", color: "transparent", weight: 0, opacity: 0, fillOpacity: 1.0};
            const TooltipClass = {'className': 'class-tooltip'}

            function onEachFeature_label(feature, layer){
                const label = feature.properties.name_tag;
                const tooltipContent = '<p class="tooltipstyle">'+label+'</p>';
                layer.bindTooltip(tooltipContent, {permanent: true, direction: 'center', opacity:0.9, ...TooltipClass});
            }
            const label_layer = new L.geoJson(label_data, {
                onEachFeature: onEachFeature_label,
                pointToLayer: function(feature, latlng){
                    return L.circleMarker(latlng, label_marker);
                }
            });

            //指定避難所のポップアップ用表示情報
            function onEachFeature_shelter(feature, layer){
                let popupContent;
                popupContent =
                    '<table class="tablestyle">'+
                    '<tr><td>名称</td><td>'+feature.properties.name+'</td></tr>'+
                    '<tr><td>エリア</td><td>'+feature.properties.community_name+'</td></tr>'+
                    '<tr><td>連絡先</td><td>'+feature.properties.tel+'</td></tr>'+
                    '<tr><td>一時避難<br>可能人数</td><td>'+(feature.properties.site_capacity === "－" ? "－" : parseInt(feature.properties.site_capacity).toLocaleString() +"人") +'</td></tr>'+
                    '<tr><td>滞在避難<br>可能人数</td><td>'+(feature.properties.shelter_capacity_per2m2 === "－" ? "－" : parseInt(feature.properties.shelter_capacity_per2m2).toLocaleString() +"人") +'</td></tr>'+
                    '<tr><td>併設設備等</td><td>'+(feature.properties.note)+'</td></tr>'+
                    '</table>';
                const popupStyle = L.popup({autoPan:true}).setContent(popupContent);
                layer.bindPopup(popupStyle);
            }

            //指定避難場所のポップアップ用表示情報
            function onEachFeature_place(feature, layer){
                let popupContent;
                popupContent =
                    '<table class="tablestyle">'+
                    '<tr><td>名称</td><td>'+feature.properties.name+'</td></tr>'+
                    '<tr><td>エリア</td><td>'+feature.properties.community_name+'</td></tr>'+
                    '<tr><td>連絡先</td><td>'+feature.properties.tel+'</td></tr>'+
                    '<tr><td>一時避難<br>可能人数</td><td>'+(feature.properties.site_capacity === "－" ? "－" : parseInt(feature.properties.site_capacity).toLocaleString() +"人") +'</td></tr>'+
                    '<tr><td>併設設備等</td><td>'+feature.properties.note+'</td></tr>'+
                    '</table>';
                const popupStyle = L.popup({autoPan:true}).setContent(popupContent);
                layer.bindPopup(popupStyle);
            }

            //各避難所・避難場所のレイヤ設定
            const poi_place_layer = new L.geoJson(json_poi, {
                filter: function(feature, layer) {
                    return feature.properties.type.startsWith('緊急避難場所');
                },
                onEachFeature: onEachFeature_place,
                pointToLayer: function(feature, latlng){
                    return L.marker(latlng);
                }
            });

            const poi_shelter_layer = new L.geoJson(json_poi, {
                filter: function(feature, layer) {
                    return feature.properties.type.startsWith('避難所');
                },
                onEachFeature: onEachFeature_shelter,
                pointToLayer: function(feature, latlng){
                    return L.marker(latlng);
                }
            });

            const poi_area_layer = new L.geoJson(json_poi, {
                filter: function(feature, layer) {
                    return feature.properties.type.startsWith('広域避難場所');
                },
                onEachFeature: onEachFeature_place,
                pointToLayer: function(feature, latlng){
                    return L.marker(latlng);
                }
            });

            //初期表示用レイヤのマップへの追加
            basemap_osm.addTo(map);
            label_layer.addTo(map);
            poi_place_layer.addTo(map);
            poi_shelter_layer.addTo(map);
            poi_area_layer.addTo(map);
            
            //インフォボックスの設置
            const info = L.control({position:'topleft'});
            info.onAdd = function(map){
                this._div = L.DomUtil.create('div', 'info');
                this._div.innerHTML = '<p class="info-title">防災避難所マップのサンプル</p>';
                return this._div;
            }
            info.addTo(map);

            //凡例ボックスの設置（浸水想定地域レイヤ用）
            const legend_floodrisk = L.control({position:'topright'});
            legend_floodrisk.onAdd = function(map){
                this._div = L.DomUtil.create('div', 'map-overlay');
                this._div.innerHTML = '<i class="style01" style="background:#ffffe0"></i>浸水想定：0.5m未満<br><i class="style01" style="background:#f5deb3"></i>浸水想定：0.5m〜3m<br><i class="style01" style="background:#ffc0cb"></i>浸水想定：3m〜5m<br><i class="style01" style="background:#f08080"></i>浸水想定：5m〜10m';
                return this._div;
            }
            //凡例ボックスの表示切替（浸水想定地域レイヤを表示/非表示したときに凡例を表示/非表示する）
            map.on('overlayadd', function (eventLayer) {
                if (eventLayer.layer === flood_gsi) {
                    legend_floodrisk.addTo(map);
                }
            });
            map.on('overlayremove', function (eventLayer) {
                if (eventLayer.layer === flood_gsi) {
                    legend_floodrisk.remove(map);
                }
            });

            //重ね合わせ用レイヤチェックボックスへのレイヤ情報の登録
            const overlayMaps = {
                '一時避難用 緊急避難場所': poi_place_layer,
                '滞在用 避難所': poi_shelter_layer,
                '大規模災害用 広域避難場所': poi_area_layer,
                'ラベル': label_layer,
                '洪水浸水想定区域': flood_gsi,
            };
            //ベースマップ用レイヤ切替ボックスへのレイヤ情報の登録
            const baseMaps = {
                '地図（OpenStreetMap）': basemap_osm,
                '航空写真（国土地理院）': basemap_gsi,
            };

            //レイヤ操作ボックスの表示（モバイル画面の場合は折りたたみ可能にする）
            if (L.Browser.mobile) {
                L.control.layers(baseMaps, overlayMaps, {collapsed:true}).addTo(map).expand();
            } else {
                L.control.layers(baseMaps, overlayMaps, {collapsed:false}).addTo(map);
            }

            //スケールバーの設置
            L.control.scale({maxWidth:120, metric:true, imperial:false, position: 'bottomleft'}).addTo(map);
        </script>
    </body>
</html>
```

上記のコードをテキストエディタに貼り付けてindex.htmlというファイル名で保存し、そのファイルをダブルクリックするだけで下図の画面が立ち上がります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122865/830bf11d-fea3-aa4e-7f0a-079b50f86f6e.png)

外部に公開して運用していくには公開用サーバや構成などを考える必要がありますが、その入口として参考になれば幸いです。

以上、ご覧いただきありがとうございました。