LoginSignup
9
7

オープンソースWeb地図ライブラリの活用:Leaflet

Last updated at Posted at 2023-08-01

はじめに

インタラクティブなWeb地図を作る機会があったのでまとめてみました。
地図ライブラリを使うと結構簡単にいろいろなことができました。
初心者が、QGIS を用いて、地理情報の表記に geojson 形式のファイルを表示、編集できるか試してみた手順を、あくまで「とっかかり」用のメモとして、記録しています。
QGIS が 3.12 にアップデートされていたのでこれに合わせて追記修正します。

Leaflet

LeafletとはオープンソースのWeb地図を作成するためのJavascriptライブラリです。
簡単に導入でき、機能も十分なのでLeafletでインタラクティブな地図を表示する方法をStepに別けて解説します。
公式サイト
fig001.png
fig002.png

Leafletの特徴

  • モバイル フレンドリーでインタラクティブなマップを作成するためのオープンソースなJavaScriptライブラリ
  • 外部依存がなく、わずか約42KBの重さ
  • ほとんどの開発者が必要とするすべてのマッピング機能を備えている
  • 多くのプラグインで拡張が可能
  • Leafletを利用している旨の著作権表示をしていれば、商用でも非商用でも無料で利用することができる。
  • Leaflet自身はWebGISとして地図を見られるようにする機能があるだけ、地図やGISデータは別途用意する必要がある

レイヤー構造

Leafletで表示するWebGISデータはレイヤー構造を持つので、地図情報の上にGEOJSON形式の地形やエリアのポリゴンを配置したり、ラスタ情報として建物内部など個別の地図を配置したり、場所を示すマーカを配置できます。
さらに、レイヤ毎に表示・非表示を切替えることもできます。

GISデータの利用

近年、地理院地図をはじめとした無料で利用できる地図配信サービスや、
国土数値情報をはじめとした無料で利用できるGISダウンロードサービスが存在するため、
Leafletの使い方を覚えれば費用を払わずにある程度のWebGISを作成することが可能です


Step1:地図に都道府県情報を追加して表示する

ひな形となるファイルを作成します。ここではOpenStreemMapをタイルレイヤとして表示し、その上にGeojsonファイルで日本の都道府県データを重ねています。
地図の基本機能として拡大縮小、移動ができるようになっています。
また、Geojsonファイルのスタイルで表示する色や線の太さを変更しています。

Step1.html
<!DOCTYPE html>
<html lang="jp">

<head>
	<meta charset="utf-8">
	<title>Leaflet Demo 00</title>
	<link rel="stylesheet" href="./css/index.css" type="text/css">
	<link rel="stylesheet" href="./css/leaflet.css" />
	<script src="./js/leaflet.js"></script>
</head>

<body>
	<image id="logo" src="./images/layers-2x.png">
		<div id='map'></div>

		<script type="text/javascript">
			// 要素選択
			var dataNow;
			const url = "./data/japan.json";	// 読み込むJSONファイル

			function leafletJS(json) {
				const map = L.map('map');

				map.setView([35.8, 136.5], 5);
				L.control.scale({ imperial: false, position: 'bottomright' }).addTo(map); // 目盛表示(長さ)

				//OpenStreetMap:タイルを作成:Mapに追加
				const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
					maxZoom: 25,
					attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
				}).addTo(map);

				//スタイル設定する
				function style(feature) {
					return {
						weight: 2,
						fillOpacity: 0.9,
						fillColor: "#FFCC80"
					};
				}

				var geojson1 = L.geoJson(json, {
					style,
					onEachFeature: stats_PPD_onEachFeature
				});//.addTo(map);//追加してあればはじめに呼ばれる;

				//レイヤーのグループ化:GeoJson>ポリゴン,Layer>マップ
				var L_1 = L.layerGroup([geojson1]).addTo(map);
				var L_2 = L.layerGroup([]);

				//ベース:Map追加
				const baseLayers = {
					//'Map': osm
					'Data 有': L_1,
					'Data なし': L_2,
				};

				//オーバレイ:レイヤー追加,
				const overlays = {
				};
				//コントロールとオーバレイの追加
				const layerControl = L.control.layers(baseLayers, overlays).addTo(map);
				var icon = L.icon({
					iconUrl: './images/marker-icon.png',
					iconSize: [32, 52], // アイコンのサイズ
					popupAnchor: [0, -10] // ポップアップを開く基準
				});

				//ハイライト
				function highlightFeature(e) {
					var layer = e.target;
					layer.setStyle({
						weight: 3,//太線
					});
					layer.bringToFront();
					layer.bindTooltip('<p class="tooltip">名称:' + layer.feature.properties.name + '</br>地方:' + layer.feature.properties.region, { sticky: 'true', direction: 'top', opacity: 0.9 });
				}

				//リセットハイライト
				function resetHighlight(e) {
					var layer = e.target;
					layer.setStyle({
						weight: 1,
					});
				}

				//モバイルブラウザでのタップ時のハイライト設定
				function infoFeature_PPD(e) {
					const stats_layer = e.target;
					stats_layer.bringToFront();
					stats_layer.bindTooltip('<p class="tooltip">No:' + stats_layer.feature.properties.no + '</br>名称:' + stats_layer.feature.properties.name + '</br>地方:' + stats_layer.feature.properties.region, { sticky: 'true', direction: 'top', opacity: 0.9 });

				}

				//表示設定(ハイライトやツールチップ)のコントロール
				function stats_PPD_onEachFeature(feature, layer) {
					if (L.Browser.mobile) {
						layer.on({
							click: infoFeature_PPD
						});
					} else {
						layer.on({
							mouseover: highlightFeature,
							mouseout: resetHighlight,
						});
					};
				}
			}

			// 起動時の処理
			window.addEventListener("load", () => {
				// Mapデータの読み込み
				fetch(url)
					.then(response => response.json())
					.then(data => leafletJS(data));
			});
		</script>
</body>

</html>

解説

ファイル構成

Top-stepxx.html
  -js:ライブラリ
   -data:GISデータ
   -css:スタイルファイル

Leaflet.jsを使うため最低限必要な準備はLeafletのCSSファイルを読込み、LeafletのJSライブラリをインクルードすること。具体的には下記をHeaderセクションに含めます。

 <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.3/dist/leaflet.css" crossorigin=""/>
 <!-- Make sure you put this AFTER Leaflet's CSS -->
 <script src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js" crossorigin=""></script>

Body要素にはマップを配置したいdiv特定の場所に要素を配置します。

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

もう一つCSSでマップコンテナーの高さを定義する必要があります。
ここではindex01.ccsとして読込みます。

マップのオブジェクトはL.map()で準備します

表示する緯度経度、倍率はmap.setView()メソッドで行います

const map = L.map('map');
map.setView([35.6, 139.7], 6); //Mapを表示する緯度経度、倍率

タイルマップの読込はL.tileyer()で行い
.addTo()メソッドで地図上に紐づけます

//OpenStreetMap:タイルを作成
const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
	maxZoom: 25,
	attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);

GEOデータL.geoJson()を使ってJSON形式のGeojsonで読込みます
引数にスタイルを設定できるので、データ読込時にGEOデータの色や線種を再設定できます

//スタイル定義
function style(feature) {
	return {
		weight: 2,
		fillOpacity: 0.9,
		fillColor: "#FFCC80"
	};
}

//GEOファイルの読み込みとスタイルの適用
const geojson = L.geoJson(statesData, {
	style,
});

レイヤー単位で各要素を生成したあと、ルートとなるMapに紐づけますが
複数のレイヤーをグループ化することもできます
グループ化されたレイヤーは、表示非表示を同時に切替えることができます

//[]内に複数のレイヤーを記載すると複数のレイヤーを1つのグループとして扱える
var L1 = L.layerGroup([geojson]).addTo(map);

Step2:

Step1のファイルを拡張して地図にインタラクティブな機能を追加します

具体的には各都道府県にマウスを合わせたると、マウスの場所で色を変更し・ポップアップを表示します。

*先ほど示したプログラムではここまで実装済みです

map12.png

解説:

geojsonファイルを読込んだときに個々のデータに対して、インタラクティブな機能を追加していきます。

const geojson = L.geoJson(statesData, {
	style,
	onEachFeature: stats_PPD_onEachFeature
});

stats_PPD_onEachFeature()関数でモバイル端末・PC端末での動作の違いで呼出す関数を定義し、
highlightFeature()、resetHighlight() 、infoFeature_PPD()の関数でハイライトやリセットなど動作を定義します。

//ハイライト
function highlightFeature(e) {
}

//リセットハイライト
function resetHighlight(e) {
}

//モバイルブラウザでのタップ時のハイライト設定
function infoFeature_PPD(e) {
}

//表示設定(ハイライトやツールチップ)のコントロール
function stats_PPD_onEachFeature(feature, layer) {
	if (L.Browser.mobile) {
		layer.on({
			click: infoFeature_PPD
		});
		} else {
			layer.on({
			mouseover: highlightFeature,
			mouseout: resetHighlight,
			});
		};
}

Step3:

Step2のファイルを拡張して
GEOJSOで定義された都道府県データレイヤーグループと、
PNGで作られたラスタ地図レイヤーグループ
の表示を切り替える機能を追加します。
例では単なる緑色の矩形を表示していますが、実際の使い方としては地図上の建物内部レイアウトをラスタレイヤとして表示して、建物内部を地図上に表示することができます。

map13.png

レイヤーをグループ化し、それぞれベースレイヤ、オーバーレイレイヤとして定義します。 
 
また、ベースレイヤーとオーバーレイレイヤをコントロールするUIを右上に追加します。

ベースレイヤーとオーバーレイレイヤの違い

  • ベースレイヤ:ベースとなる地図、1つずつしか選択できない(ラジオボタン)
  • オーバーレイレイヤ:複数同時に選択・重ねられるレイヤ(チェックボックス)
//レイヤーのグループ化:GeoJson>ポリゴン,Layer>マップ
var L1 = L.layerGroup([geojson]).addTo(map);
var L2 = L.layerGroup([layer]);

//ベース:Map追加
const baseLayers = {
	'GEOJSON': L1,
	'ラスタ地図': L2,
};
//オーバレイ:レイヤー追加,
const overlays = {
};

//コントロールとオーバレイの追加
const layerControl = L.control.layers(baseLayers, overlays).addTo(map);

Step4:

別ファイルからのデータを使ってMap上の区画色や線種に反映する(コロプレスマップ・階級区分図)と呼ばれる機能を追加します。

  • 画面上部にボタンを配置し、各ボタンに対応したCSVファイルを読込む機能を追加します。
  • 読込んだCSVによってGEOJSONファイルの一部に色を付ける機能を追加します。
  • 読込んだCSVファイルの内容は画面左枠にリストとして表示します。

map14.png

ボタン配置とCSVファイルを読込み

メニューは下記で定義しています。Classはあとで読込むCSVファイルのファイル名として使います。

<div id="menu">
	<ul>
		<li class="000"><a href="#">Info00</a></li>
		<li class="001"><a href="#">Info01</a></li>
		<li class="002"><a href="#">Info02</a></li>
	</ul>
</div>

CSVファイルはD3のCSV関数を使って読込みます
CSVで読込んだデータはdata配列に入ります。
今回、CSVファイルは頻繁(秒単位)に更新される仕様なため、
?ver=読込んだ時刻のプロパティを着けてCSVファイルを読み込んでいます。単にファイル名だけで読込むとブラウザのキャッシュが効いて、ファイル更新されても古いキャッシュがひょうじされてしまいます

d3.csv("./data/" + str + ".csv?ver=" + Date.parse(new Date), function (data) {
	//省略
})

CSVファイルリスト表示

同じくCSVで読込んだデータはdata配列を使って名前の降順にソート、
ソートした中身をD3の機能を使ってHTMLのリスト文を作成しています。

d3.csv("./data/" + str + ".csv?ver=" + Date.parse(new Date), function (data) {
	//ソート
	data.sort((a, b) => a.name - b.name);
	d3.select("#chart")
		.selectAll("div")
		.remove()
		//表を作る
	d3.select("#chart")
		.selectAll("div")
		.data(data)
		.enter()
		.append("div")
		.text(function (d, i) { return (i + 1) + "," + d["name"] + ", " + d["pn"]; })
		.style("color", "red")

	//省略
})

GEOJSONファイルの一部に色を付ける

上記のdata配列を使ってGEOJSONの全ての要素と名前で比較しています。
同じ名前の場合はGEOJSONの要素を更新し、setStype()関数で表示色を変更します。
同時にcorrentlayer[id].feature.properties.xxxとしてパラメータも更新しています

if (jsonName == data[i].name) {
	correntlayer[id].feature.properties.value = dataValue;
	correntlayer[id].feature.properties.pn = dataPN;
	correntlayer[id].feature.properties.no = dataNo;
	correntlayer[id].feature.properties.color = "red";
	correntlayer[id].setStyle({ fillColor: "#FF0000" });
}

Step5:

プラグインの例として2つの機能追加をします。

  1. フルスクリーンでの表示
  2. Markerに番号を付ける

map15.png

フルスクリーンプラグイン

こちらのプラグインを利用します。

使い方はCSSとJSライブラリをインクルードし

 <link rel="stylesheet" href="Control.FullScreen.css" />
 <script src="Control.FullScreen.js"></script>

フルスクリーン関数をベースとなるmapに紐づけます。

L.control.fullscreen({
	position: 'topleft', // change the position of the button can be topleft, topright, bottomright or bottomleft, default topleft
	title: 'Show me the fullscreen !', // change the title of the button, default Full Screen
	titleCancel: 'Exit fullscreen mode', // change the title of the button when fullscreen is on, default Exit Full Screen
	forceSeparateButton: true, // force separate button to detach from zoom buttons, default false
	forcePseudoFullscreen: true, // force use of pseudo full screen even if full screen API is available, default false
	fullscreenElement: false // Dom element to render in full screen, false by default, fallback to map._container
}).addTo(map);

Markerに番号を付ける

カスタムアイコンのプラグインとプラグインの拡張

この記事を参考に色を設定できるカスタムアイコンを利用しました。
また、このプラグインを拡張しアイコンの上にリストと同じ番号が表示されるようにしました。

Github

このサンプルだと、Markerまでは上手く動作していませんが。
最終的なソースを示します。

<!DOCTYPE html>
<html lang="jp">

<head>
	<meta charset="utf-8">
	<title>Leaflet Demo 00</title>
	<link rel="stylesheet" href="./css/index.css" type="text/css">
	<link rel="stylesheet" href="./css/leaflet.css" />
	<link rel="stylesheet" href="./css/leaflet.awesome-markers.css">
	<link rel="stylesheet" href="./css/Control.FullScreen.css" />

	<script src="./js/d3.v3.min.js"></script>
	<script src="./js/leaflet.js"></script>
	<script src="./js/leaflet.awesome-markers.js"></script>
	<script src="./js/Control.FullScreen.js"></script>

</head>

<body>
	<image id="logo" src="./images/layers-2x.png">
		<div id="menu">
			<ul>
				<li class="000"><a href="#">Button0</a></li>
				<li class="001"><a href="#">Button1</a></li>
				<li class="002"><a href="#">Button2</a></li>
			</ul>
		</div>
		<div id="chart">No. , 名称 , 地方コード</div>
		<div id='map'></div>

		<script type="text/javascript">
			// 要素選択
			var $body = d3.select("body");
			var $tooltip = d3.select("#tooltip");
			var $item = d3.selectAll("#menu li");
			var dataNow;
			const url = "./data/japan.json";	// 読み込むJSONファイル

			function leafletJS(json) {
				const map = L.map('map');

				map.setView([35.8, 136.5], 5);
				L.control.scale({ imperial: false, position: 'bottomright' }).addTo(map); // 目盛表示(長さ)

				//ラスタレイヤーの追加:yx yx 下2桁-10で← -で↑
				bound1 = [[34, 136], [34, 135]]; // 地図画像の範囲
				bound2 = [[34, 136], [34, 135]]; // 地図画像の範囲

				layer1 = L.imageOverlay('images/1F_black.jpg', bound1, { opacity: '0.8' });//.addTo(map);
				layer2 = L.imageOverlay('images/2F_black.jpg', bound2, { opacity: '0.8' });

				//マーカ用レイヤ
				const marker1 = L.geoJson();
				const marker2 = L.geoJson();;
				var marker_list = [];

				//OpenStreetMap:タイルを作成:Mapに追加
				const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
					maxZoom: 25,
					attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
				}).addTo(map);


				//CSVに設定している場所なら色を付ける
				function draw(str) {
					//マーカーの指定があれば消す
					for (i in marker_list) {
						marker1.removeLayer(marker_list[i]);
						marker2.removeLayer(marker_list[i]);
					}

					//保管棚情報
					geojson1.setStyle({ fillColor: "#FFCC80" })
					d3.csv("./data/data_" + str + ".csv?ver=" + Date.parse(new Date), function (data) {
						//名称でソート
						data.sort((a, b) => a.name - b.name);

						//D3関数を使って処理
						d3.select("#chart")
							.selectAll("div")
							.remove()
						//表を作る
						d3.select("#chart")
							.selectAll("div")
							.data(data)
							.enter()
							.append("div")
							.text(function (d, i) { return (i + 1) + "," + d["name"] + ", " + d["region"]; })
							.style("color", function (d) { return markColor(d) })

						// 棚番地データ取得JSONデータ取得
						for (var i = 0; i < data.length; i++) {
							var dataName = data[i].name;
							var dataValue = data[i].value;
							var dataPN = data[i].region;
							var dataNo = i + 1;
							//console.log(geojson1);
							for (var id in geojson1._layers) {			
								var jsonName = geojson1._layers[id].feature.properties.name;

								if (jsonName == data[i].name) {
									geojson1._layers[id].feature.properties.value = dataValue;
									geojson1._layers[id].feature.properties.region = dataPN;
									geojson1._layers[id].feature.properties.no = dataNo;
									geojson1._layers[id].setStyle({ fillColor: "#FF0000" });

									//下記はGeo情報がそれぞれ1つを想定している
									//日本地図の場合は1件1Goeとは限らないので注意
									//例えば東京は島があるのでGoe情報は複数になる
									//var pos = geojson1._layers[id].feature.geometry.coordinates[0][0][0];
									//var posX = geojson1._layers[id].feature.geometry.coordinates[0][0];
									//var posX1 = posX.map(item => item[1]); posX0 = posX.map(item => item[0]);//緯度経度の配列を取得
									//var posMax0 = posX0.reduce(aryMax);//最大値を求める
									//var posMax1 = posX1.reduce(aryMax);
									//marker = L.marker([posMax1, posMax0], {
									//	icon:
									//		L.AwesomeMarkers.icon({
									//			icon: '',
									//			markerColor: geojson1._layers[id].feature.properties.color,//tColor,
									//			html: (i + 1)
									//		})
									//}).addTo(marker1);//どのレイヤに追加するか
									//marker.bindPopup('<p>名称:' + data[i].name + '</br>地方:' + dataPN + '</p>');
									//marker_list.push(marker);

								} else {
								};
							};
						}						

					});
				}

				//スタイル設定する
				function style(feature) {
					return {
						weight: 2,
						fillOpacity: 0.9,
						fillColor: "#FFCC80"
					};
				}

				var geojson1 = L.geoJson(json, {
					style,
					onEachFeature: stats_PPD_onEachFeature
				});//.addTo(map);//追加してあればはじめに呼ばれる;

				//var geojson1;

				//レイヤーのグループ化:GeoJson>ポリゴン,Layer>マップ
				var L_1 = L.layerGroup([geojson1, layer1, marker1]).addTo(map);
				var L_2 = L.layerGroup([layer2, marker2]);

				//ベース:Map追加
				const baseLayers = {
					//'Map': osm
					'Data 有': L_1,
					'Data なし': L_2,
				};

				//オーバレイ:レイヤー追加,
				const overlays = {
				};
				//コントロールとオーバレイの追加
				const layerControl = L.control.layers(baseLayers, overlays).addTo(map);
				var icon = L.icon({
					iconUrl: './images/marker-icon.png',
					iconSize: [32, 52], // アイコンのサイズ
					popupAnchor: [0, -10] // ポップアップを開く基準
				});

				//ハイライト
				function highlightFeature(e) {
					var layer = e.target;
					layer.setStyle({
						weight: 3,//太線
					});
					layer.bringToFront();
					layer.bindTooltip('<p class="tooltip">名称:' + layer.feature.properties.name + '</br>地方:' + layer.feature.properties.region, { sticky: 'true', direction: 'top', opacity: 0.9 });
				}

				//リセットハイライト
				function resetHighlight(e) {
					var layer = e.target;
					layer.setStyle({
						weight: 1,
					});
				}

				L.control.fullscreen({
					position: 'topleft', // change the position of the button can be topleft, topright, bottomright or bottomleft, default topleft
					title: 'Show me the fullscreen !', // change the title of the button, default Full Screen
					titleCancel: 'Exit fullscreen mode', // change the title of the button when fullscreen is on, default Exit Full Screen
					forceSeparateButton: true, // force separate button to detach from zoom buttons, default false
					forcePseudoFullscreen: true, // force use of pseudo full screen even if full screen API is available, default false
					fullscreenElement: false // Dom element to render in full screen, false by default, fallback to map._container
				}).addTo(map);

				//モバイルブラウザでのタップ時のハイライト設定
				function infoFeature_PPD(e) {
					const stats_layer = e.target;
					stats_layer.bringToFront();
					stats_layer.bindTooltip('<p class="tooltip">No:' + stats_layer.feature.properties.no + '</br>名称:' + stats_layer.feature.properties.name + '</br>地方:' + stats_layer.feature.properties.region, { sticky: 'true', direction: 'top', opacity: 0.9 });

				}

				//表示設定(ハイライトやツールチップ)のコントロール
				function stats_PPD_onEachFeature(feature, layer) {
					if (L.Browser.mobile) {
						layer.on({
							click: infoFeature_PPD
						});
					} else {
						layer.on({
							mouseover: highlightFeature,
							mouseout: resetHighlight,
						});
					};
				}

				// クリックイベント追加
				$item.each(function (d, i) {
					var $this = d3.select(this);
					$this.on("click", function () {
						$item.select("a").classed('on', false);
						$this.select("a").classed('on', true);
						draw($this.attr("class"));
					});
				});

				function onMapClick(e) {
					if (marker_list) {
						alert("マップ上の絶対位置 " + e.latlng);
					}
				}

				//クリックした地点を表示
				//map.on('click', onMapClick);

				const aryMax = function (a, b) { return Math.max(a, b); }	//最大値関数
				const aryMin = function (a, b) { return Math.min(a, b); }	//最小値関数

				//名称で色を変える
				function markColor(d) {
					//red purple green blue orange darkred darkblue cadetblue darkpurple darkgreen darkblue
					switch (d["region"]) {
						case "Hokaido":
							return "red";
							break;
						case "Tohoku":
							return "green";
							break;
						case "Kanto":
							return "blue";
							break;
						case "Chubu":
							return "orange";
							break;
						case "Kinki":
							return "darkred";
							break;
						case "Chugoku":
							return "darkblue";
							break;
						case "Shikoku":
							return "cadetblue";
							break;
						case "Kyushu":
							return "darkpurple";
							break;
						default:
							return "black";
					}
				}
			}

			// 起動時の処理
			window.addEventListener("load", () => {
				// Mapデータの読み込み
				fetch(url)
					.then(response => response.json())
					.then(data => leafletJS(data));
			});
		</script>
</body>

</html>

参考資料

9
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
7