GAミント至上主義

Web Monomaniacal Developer.

地図から市区町村を選択する機能をLeafletとVue.jsと国土交通省のデータを使って作った

3日ぐらいで勢いで作れてしまったので忘れないうちにポイントと流れをメモ。

作ったもの

日本地図クリックでその市区町村のリストを取得できるやつ。

左バーがVue, 右の地図がLeaflet + 国交省データ
f:id:uyamazak:20200331182944p:plain

動画

既存の管理画面には埋め込まず、別のHTMLファイルとして作成しました。

抱えていた課題

シニアジョブでは求職者の方の通勤エリア指定を営業さんがチェックボックスで行っていました。
これが非常に面倒だし、難しいし、選択漏れがあっても気づきにくい。

「地図で選択できるようにしてほしい」という声が社内で以前からありましたが、技術的に難しかったので後回しになっていました。

HTMLで地図作るのは都道府県ならまだしも市区町村となると2000近いので実質不可能・・・。

例1:千葉県野田市とか

f:id:uyamazak:20200331160232p:plain
あなたは野田市に隣接する市区町村をすべてチェックできるだろうか

例2:土地勘のない地域

関東育ちの私の場合だと例として北海道の真ん中らへん

f:id:uyamazak:20200331160918p:plain
あなたは北海道上川郡新得町の周辺の市区町村をもれなくチェックできるだろうか

時間も短くなるし、ミスも減るし、ストレスも減っていいことづくめ。

ベースとなる地図を用意する(Leaflet + 地理院タイル)

最初Google Mapを考えたのですが、今回欲しい機能はないことと、保守にあたってバージョンアップの問題、APIキーなどが煩雑だったため、他の選択肢を探すことにしました。

無料で使える地図としては、国土交通省国土地理院が用意してくれているものが見つかりました。

地理院地図|地理院タイルを用いたサイト構築サンプル集
Google Mapと変わらぬ操作感で誰でも使えそうです。

地図ライブラリはこのページの一番上にあったLeafletを使うことにしました。
maps.gsi.go.jp


Leafletはドキュメントも充実しており、良さそう(他のライブラリは触ってない)。
leafletjs.com

市区町村の境界データを用意する (geojson形式)

ベースの地図ができたので、市区町村の境界データを用意します。
こちらも国土交通省が用意してくれています。

サイトの見た目は阿部寛HPの時代を感じますが、大事なのはコンテンツ。他にも鉄道関係とかいろいろあるのでこれからもお世話になりそうです。
http://nlftp.mlit.go.jp/ksj/index.html

行政区域のページを開き、ダウンロードします(URL変わりそう)。
http://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-N03-v2_3.html


今回は全国が必要なので、全国をチェックしてダウンロードしたところ、問題が。
600MB以上あるので読み込もうとすると失敗します。

そのため、面倒ですが都道府県ごとにダウンロードします。

ずらっと表示されますが、一番下の最新のものをダウンロードします。
この記事を書いている時点で、平成31年で下記のようなファイル名でした。

N03-190101_01_GML.zip
...
N03-190101_47_GML.zip

47ファイルをDLし終わったら、解凍して、geojsonファイルだけを集めます。
あとで使いますが、_01_の数字は都道府県コードです。

解凍後、MacZshを使っている場合は下記のような感じで「*」を使ってコピーできるので便利です。
(cpの前にlsで動作確認を推奨)

cp ~/Downloads/N03-190101_*_GML/*.geojson /path/to/dir

行政区域のgeojsonの中身について

こんな感じになってます(北海道の最初の方だけ)。

{
"type": "FeatureCollection",
"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:EPSG::6668" } },
"features": [
{ "type": "Feature", "properties": { "N03_001": "北海道", "N03_002": "オホーツク総合振興局", "N03_003": null, "N03_004": "北見市", "N03_007": "01208" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 144.08143547296879, 44.125060386827442 ], ...  ] ] } },
{ "type": "Feature", "properties": { "N03_001": "北海道", "N03_002": "オホーツク総合振興局", "N03_003": null, "N03_004": "北見市", "N03_007": "01208" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 143.78333333266721, 44.184525108247215 ], [ 143.78280805394854, 44.18336861337...

今回使うのは、coordinatesの他、features.propertiesの下記項目です。

  • 都道府県名(N03_001)
  • 郡・政令都市名(N03_003)
  • 市区町村名(N03_004)
  • 行政区域コード(N03_007)

HTMLファイルの作成

今回の機能用にHTMLファイルを1つ用意し、公開フォルダに設置します。
ヘッダーでCDN経由でLeaflet、Vue, Axiosを読み込んでます。jQueryは使いません!
バージョンは作った時点の最新版。

<html lang="ja">
<meta charset="utf-8">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<title>地図から市区町村入力</title>

Leafletの初期化

HTMLにマップ用のdivを用意します。

<div id="map"></div>

JSは下記のような感じ。
Leaflet本体は大文字のLとして宣言されています。

<script>
// 選択時に間違ってダブルクリック判定されて拡大するとうざいので無効化、Canvasの方が早いらしいので変更。
const map = L.map('map',  {
    doubleClickZoom: false,
    preferCanvas: true
});

/*
地図タイルには下記の国土地理院のものを使用します。
必要なattributionは使用する地図によって異なるので国土地理院のWEBサイトを参照してください。
https://maps.gsi.go.jp/development/ichiran.html#pale2

標準地図 https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png
淡色地図 https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png
白地図 https://cyberjapandata.gsi.go.jp/xyz/blank/{z}/{x}/{y}.png
*/
L.tileLayer('https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png', {
    attribution: `<a href='https://maps.gsi.go.jp/development/ichiran.html' target='_blank'>地理院タイル</a> Shoreline data is derived from: United States. National Imagery and Mapping Agency. "Vector Map Level 0 (VMAP0)." Bethesda, MD: Denver, CO: The Agency; USGS Information Services, 1997.`
}).addTo(map);

// とりあえず東京を中心に表示
const centerLatLang = [35.7010141, 139.7042647];
map.setView(centerLatLang, 9);

// 読み込んだ都道府県ごとのレイヤーをキャッシュする用の変数
const layers = {};
</script>

地理院タイルはいろいろ試して、淡色地図を使いました。

データの操作にはVueを使いますが、geojsonが1都道府県で数十MBあるので、それをdataに乗せるとまともに動きませんし、特にVueでリアクティブる必要もありません。
そのため、Leaflet関係の変数はグローバルに宣言しておきます。
今回は1つのHTML で独立したアプリにしたので他に影響もありません

Vue + axiosでgeojsonを読み込んでいろんなアクションつける

geojsonはL.geoJson()で読み込むだけで使えます。

geojsonファイルはAWSのS3にアップして、できるだけキャッシュするようにヘッダーつけたり、CloudFront経由にしてgzip圧縮するようにしてます。

一部抜粋。

      methods:
        // ダウンロードしたファイル名は変更したくないのでこんな感じでハードコーディング。
        geoJsonFilePath: function (code) {
            return GEO_JSON_HOSTNAME + "N03-19_" + code + "_190101.geojson";
        },
        // グローバルに宣言したlayers変数を使って多重リクエストを防止してます
        addLayer: async function (code) {
            if (!code) {
                return;
            }
            const filePath = this.geoJsonFilePath(code);
            if (layers[filePath]) {
                console.log('already loaded');
            } else {
                console.log('loading');
                await this.fetchLayer(code);
            }
            map.addLayer(layers[filePath]);
        },
        fetchLayer: async function (code) {
            const filePath = this.geoJsonFilePath(code);
            const app = this;
            this.addLayerLoading(code);
            return await axios.get(filePath)
                .then(function (response) {
                    const data = response.data;
                    layers[filePath] = L.geoJson(data, {
                        style: app.defaultStyle(code),
                        onEachFeature: function(feature, layer) {
                            layer.on({
                                mouseover: app.mouseoverFeature,
                                mouseout: app.mouseoutFeature,
                                click: app.selectFeature
                            });
                        }
                    }).bindTooltip(
                        function (layer) {
                            return app.municipalityNameFormatForTooltip(layer.feature.properties);
                        }
                    );
                })
                .then(function () {
                    app.removeLayerLoading(code);
                });
        },

onEachFeatureでマウスオーバーとマウスアウト、クリック時のアクションを指定してます。
あとbindTooltipでツールチップも行ってます。

地図の都道府県クリックで境界データを読み込む

上述したとおり、すべての都道府県の境界データは個別に読み込むようにしました。
一応左のチェックボックスでも操作できるようにしましたが、できれば地図上のクリックで済ませたいところ。

そのためには市区町村ではなく都道府県の境界データが必要となります。
検索したところGitHubにあげてくれているのが見つかりました。

github.com

こちらは全国で12MB。読み込んでおいてもまあ大丈夫そうです。
注意点としては行政区域のファイル名では都道府県のコードが0埋めされた2文字だったのですが、こちらのファイルでは数字形式で入ってます。
そのためString(value).padStart(2, '0')で0埋め処理をしています。

        loadJapanLayer: async function() {
            const app = this;
            return await axios.get(GEO_JSON_HOSTNAME + 'japan.geojson')
                .then(function (response) {
                    const data = response.data;
                    const layer = L.geoJson(data, {
                        style: app.allStyle,
                        onEachFeature: function(feature, layer) {
                            layer.on({
                                click: app.selectPrefecture,
                            });
                        }
                    });
                    map.addLayer(layer);
                });
        },
        selectPrefecture: function(event) {
            const layer = event.target;
            // 一桁のやつの0埋め
            const code = String(layer.feature.properties.id).padStart(2, '0');
            if (!this.selectedLayerCodes.includes(code)) {
                this.selectedLayerCodes.push(code);
            }
        },

window.open()でやり取りする。

疎結合にして、他でも使いまわしやすいように、既存のシステムには埋め込まない形にしました。

既存管理画面側

ボタンと市区町村のIDの配列を元にチェックする関数を作っておきます。バリバリのjQueryで雑に書きます。
一応チェックした個数が合ってるかぐらいは確認できるようにしました。

    <a class="btn btn-primary" id="open-map-selector">
        地図から市区町村を選択
    </a>
    <script>
        // 地図側から呼び出す関数。市区町村IDの配列を受け取る
        function loadMunicipalitiesByMap(municipalityIds) {
            let count = 0;
            municipalityIds.forEach(function(id) {
                // 送信数とチェック数の確認のためにチェック数を返す
                let checked = $('input[data-city-id=' + id + ']').prop('checked', true).size();
                if (checked) {
                    count++;
                }
            });
            return count;
        }
        $('#open-map-selector').on('click', function() {
            const address = $('.residence_r-area-span').text();
            window.open('/leaflet/index.html#' + address, 'mapSelector');
        });
    </script>

地図アプリ側

親ウインドウ閉じたりいろいろエラーが考えられるので簡単にエラー処理もしておく。
エラーがない場合はwindow.close()で閉じて親画面に戻ってもらいます。

        sendMunicipalities: function() {
            this.sendMunicipalitiesErrorMessage = '';
            if (!window.opener) {
                this.sendMunicipalitiesErrorMessage = '呼び出し元のウィンドウがありません';
                return;
            }
            if (typeof window.opener.loadMunicipalitiesByMap !== 'function') {
                this.sendMunicipalitiesErrorMessage = 'loadMunicipalitiesByMap()が定義されていません';
                return;
            }
            const sendCount = Object.keys(this.sortedSelectedMunicipalities).length;
            if (!sendCount) {
                this.sendMunicipalitiesErrorMessage = '市区町村が選択されていません';
                return;
            }
            // 市区町村IDだけを取って、数値に変換
            const ids = this.sortedSelectedMunicipalities.map(v => Number(v.split(',')[3]));

            const checkedCount = window.opener.loadMunicipalitiesByMap(ids);
            if (checkedCount !== sendCount) {
                this.sendMunicipalitiesErrorMessage = `送信した個数とチェックされた個数が異なります。送信:${sendCount} チェック:${checkedCount}`;
                return;
            }
            window.close();
        },

あとは都道府県ごとに色変えたり、居住地のデータがあったら初期値にセットしたりなどなど細かいのはいろいろあるけどこんなところ。

まとめ

国土交通省なめてた。データ用意してくれて感謝。
経路とか高度なのはGoogle Map必須だけど、今回みたいなやつはLeafletでも十分。
こういう時もscriptで読み込むだけでさくっと使えるVue便利。
言い訳としてコードサンプルはリファクタリング前のものなので、これからきれいにします。

Leaflet.js Essentials (English Edition)

Leaflet.js Essentials (English Edition)