はじめに
この記事では、特定の小学校区ポリゴン内に含まれる物件データを抽出し表示する地図アプリの例をご紹介します。この記事を通して、Turf.jsの空間演算を用いてポリゴン内に含まれる地物データを抽出する方法が理解いただけると思います。
具体例として、不動産関連のユースケースを想定した学校区に含まれる自社の物件データの抽出を取り上げていますが、不動産関連や学校区に限りません。町丁目内の顧客データを抽出する場合など、他のケースにも置き換えることができます。
完成イメージ
以下がアプリの完成イメージです。地図をクリックした地点の小学校区とその小学校区に含まれる物件データを表示しています。
※ 使用している物件データは、架空のものになります。
処理詳細
- 地図を表示し、CSV形式の物件データも読み込みます
- 地図上をクリックし、サーバーへのリクエストを行います
- サーバーは TerraMap API からの小学校区データをレスポンスします
- 小学校区ポリゴン内の物件データを抽出します
- 学区内の物件データは、右側テーブルに表示させます
- 小学校区ポリゴン、小学校位置、学区内の物件位置を地図に表示させます
- 地図上の物件位置にカーソルを合わせることで、名称と住所をポップアップ表示させます
使用技術一覧
| カテゴリ | 採用技術 / データソース |
|---|---|
| 地図ライブラリ | MapLibre GL JS |
| 背景地図 | OpenStreetMap(ベクトルタイル) |
| 空間演算 | Turf.js の booleanPointInPolygon |
| 小学校区データ | TarraMap API の「小学校区・中学校区データ」 |
| 物件データ | CSV形式(lat, lng, name, address)1 |
物件データと空間演算について
この記事では物件のデータベース(RDMS)を扱っていません。大量のデータはデータベースに保持し使用することが多いと思いますが、今回は予めデータベースから絞られて出力された小規模なデータを想定しております。データベースから取得する部分は疑似的にCSVファイルを読み込む形にしています。
Turf.jsによる空間演算もフロントエンドで実行させております2。これはデータベース等のバックエンドで空間演算を行わない場合や行うことが難しい場合を想定しております。
サーバーサイドの実装
TerraMap APIから小学校区データを取得する
TerraMap API の APIキーを利用して小学校区データを取得する仲介サーバーが必要になりますが、1つ前の記事で扱ったものと同一になります。
サーバーサイドの実装に関しましては、以下をご参照下さい。
フロントエンドの実装
root/
├── index.html
├── map.js
├── style.css
└── bukken_sample.csv
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>小学校区ポリゴン内にある物件データを抽出</title>
<script src="https://cdn.jsdelivr.net/npm/papaparse@5.5.3/papaparse.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@7.2.0/turf.min.js"></script>
<script src="https://unpkg.com/maplibre-gl@5.8.0/dist/maplibre-gl.js"></script>
<link
href="https://unpkg.com/maplibre-gl@5.8.0/dist/maplibre-gl.css"
rel="stylesheet"
/>
<script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
<link
href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css"
rel="stylesheet"
>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="container">
<div id="map"></div>
<div id="info" style="width: 33.33%; height: 100%; overflow: auto; padding: 10px;">
<h2>小学校区内・物件データ</h2>
<div id="base-data">loading ...</div>
<p>地図上をクリックすると、その地点の小学校区内にある物件データが表示されます</p>
<div id="data-count"></div><br />
<div id="data-table"></div>
</div>
</div>
<script src="map.js"></script>
</body>
</html>
map.js
// 背景地図の表示
const map = new maplibregl.Map({
container: "map",
style: "https://tile.openstreetmap.jp/styles/osm-bright/style.json",
center: [139.69152, 35.66444],
zoom: 14,
});
const bukkenFile = "./bukken_sample.csv";
const bukkenPopup = new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
});
let bukkenData = [];
let bukkenGrid = null;
let bukkenCurrentCoordinates = undefined;
let schoolMarker = null;
// サンプルCSVの文字コードがShift_JISなので、Shift_JISを指定して読み込む
fetch(bukkenFile)
.then((res) => res.arrayBuffer()) // バイナリで取得
.then((buffer) => {
const decoder = new TextDecoder("shift_jis"); // 文字コード指定
const text = decoder.decode(buffer);
const parsed = Papa.parse(text, { header: true });
bukkenData = parsed.data;
// 読み込み結果を表示
const fileName = bukkenFile.split("/").pop().split("\\").pop();
document.getElementById(
"base-data"
).textContent = `${fileName} --- 全 ${bukkenData.length.toLocaleString()} 件`;
});
// レイヤー、ソース、マーカーを削除
function initializeLayerAndSource() {
if (map.getLayer("polygon")) {
map.removeLayer("polygon");
}
if (map.getLayer("outline")) {
map.removeLayer("outline");
}
if (map.getSource("gakku_geojson")) {
map.removeSource("gakku_geojson");
}
if (map.getLayer("points")) {
map.removeLayer("points");
}
if (map.getSource("bukken_geojson")) {
map.removeSource("bukken_geojson");
}
if (schoolMarker) {
schoolMarker.remove();
}
}
map.on("load", () => {
// クリックイベント
map.on("click", (e) => {
const lat = e.lngLat.lat;
const lng = e.lngLat.lng;
// クリックした位置の小学校区データを取得
fetch(`http://127.0.0.1:3000/get-gakku-polygon`, { // サーバーURLは適宜変更してください
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ lat: lat, lng: lng }),
})
.then((response) => response.json())
.then((data) => {
// 表示物の初期化
initializeLayerAndSource();
if (!data || !data.features || data.features.length === 0) {
console.log("No features found in the response.");
document.getElementById("data-table").innerHTML = "";
document.getElementById("data-count").textContent =
"該当物件数 --- 0 件";
return;
}
// GeoJSONのソースを追加
map.addSource(`gakku_geojson`, {
type: "geojson",
data: data,
});
// 小学校区ポリゴンの背景色を設定します
map.addLayer({
id: `polygon`,
type: "fill",
source: `gakku_geojson`,
layout: {},
paint: {
"fill-color": "#1c1c1c",
"fill-opacity": 0.5,
},
});
// 小学校区ポリゴンの枠線を設定します
map.addLayer({
id: `outline`,
type: "line",
source: `gakku_geojson`,
layout: {},
paint: {
"line-color": "#1c1c1c",
"line-width": 2,
},
});
// 小学校の位置を取得 TerraMap APIデータ特有のデータ構造に対応
const schoolPosition = {
lat: data.features[0].properties.point_coordinates[1],
lng: data.features[0].properties.point_coordinates[0],
};
// 小学校のマーカーを追加
schoolMarker = new maplibregl.Marker()
.setLngLat([schoolPosition.lng, schoolPosition.lat])
.setPopup(
new maplibregl.Popup({
offset: -35,
closeButton: false,
closeOnClick: false,
}).setHTML(
"<p class='popup-text'>" +
data.features[0].properties.points[0][0] + // 学校名:TerraMap APIデータ特有のデータ構造に対応
"</p>"
)
)
.addTo(map);
// マーカーのポップアップを表示
schoolMarker.togglePopup();
// 物件データの抽出
const polygon = data.features[0];
const filteredPoints = bukkenData.filter((point) => {
const pointCoords = turf.point([point.lng, point.lat]);
return turf.booleanPointInPolygon(pointCoords, polygon);
});
// 物件の円レイヤーを追加
const pointFeatures = filteredPoints.map((point) => ({
type: "Feature",
geometry: { type: "Point", coordinates: [point.lng, point.lat] },
properties: { name: point.name, address: point.address },
}));
const pointGeoJSON = {
type: "FeatureCollection",
features: pointFeatures,
};
map.addSource("bukken_geojson", {
type: "geojson",
data: pointGeoJSON,
});
map.addLayer({
id: "points",
type: "circle",
source: "bukken_geojson",
paint: {
"circle-radius": 6,
"circle-color": "#22ff98ff",
"circle-stroke-width": 2,
"circle-stroke-color": "#FFFFFF",
},
});
// 物件テーブル表示
if (filteredPoints.length > 0) {
document.getElementById("data-table").innerHTML = "";
const tableData = filteredPoints.map((point) => [
point.name,
point.address,
]);
if (!bukkenGrid) {
bukkenGrid = new gridjs.Grid({
columns: [
{ id: "name", name: "名称" },
{ id: "address", name: "住所" },
],
data: tableData,
}).render(document.getElementById("data-table"));
} else {
bukkenGrid
.updateConfig({
data: tableData,
})
.forceRender();
}
document.getElementById("data-count").textContent = `${
data.features[0].properties.points[0][0] // 学校名:TerraMap APIデータ特有のデータ構造に対応
}区内 --- ${filteredPoints.length.toLocaleString()} 件`;
} else {
document.getElementById("data-table").innerHTML = "";
document.getElementById("data-count").textContent =
"該当物件数 --- 0 件";
}
})
.catch((error) => {
console.log(error);
});
});
// 物件の MouseMoveイベント・ポップアップ表示
map.on("mousemove", "points", (e) => {
map.getCanvas().style.cursor = "pointer";
const featureCoordinates = e.features[0].geometry.coordinates.toString();
if (bukkenCurrentCoordinates !== featureCoordinates) {
bukkenCurrentCoordinates = featureCoordinates;
const coordinates = e.features[0].geometry.coordinates.slice();
const name = e.features[0].properties.name;
const address = e.features[0].properties.address;
bukkenPopup
.setLngLat(coordinates)
.setHTML(`<p class='popup-text'>名称: ${name}<br/>住所: ${address}</p>`)
.addTo(map);
}
});
// 物件の MouseLeaveイベント・ポップアップ削除
map.on("mouseleave", "points", () => {
map.getCanvas().style.cursor = "";
bukkenCurrentCoordinates = undefined;
bukkenPopup.remove();
});
});
style.css
.container {
display: flex;
width: 100%;
height: 95vh;
}
#map {
width: 66.67%;
height: 100%;
}
.maplibregl-popup-tip {
display: none;
}
.maplibregl-popup-content {
background-color: rgba(255, 255, 255, 0.5);
box-shadow: none;
padding: 0;
}
.popup-text {
color: black;
font-size: 10px;
margin: 5px;
}
bukken_sample.csv (一部抜粋)
lat,lng,name,address
35.65937,139.70639,マンション渋谷,東京都渋谷区渋谷
35.65543,139.70083,桜丘町ハウス,東京都渋谷区桜丘町
35.65394,139.69626,コーポ南平台町,東京都渋谷区南平台町
35.65822,139.698,道玄坂ヒルズ,東京都渋谷区道玄坂
35.65746,139.69461,マンション円山町,東京都渋谷区円山町
35.65672,139.69229,神泉町アパート,東京都渋谷区神泉町
35.66064,139.69182,レジデンス松濤,東京都渋谷区松濤
...
背景地図の表示
まず基本となる MapLibre GL JS の取り込みと、背景地図の表示は以下のようになります。
<script src="https://unpkg.com/maplibre-gl@5.8.0/dist/maplibre-gl.js"></script>
<link
href="https://unpkg.com/maplibre-gl@5.8.0/dist/maplibre-gl.css"
rel="stylesheet"
/>
// 背景地図の表示
const map = new maplibregl.Map({
container: "map",
style: "https://tile.openstreetmap.jp/styles/osm-bright/style.json",
center: [139.69152, 35.66444],
zoom: 14,
});
学区ポリゴンとマーカーの表示
小学校区データを表示させる部分を抜粋します。
地図のクリックイベントで、サーバーから小学校区データを取得し、ポリゴンとマーカーを表示させています。
let schoolMarker = null;
// 中略 ////////////////////////////////////
map.on("load", () => {
// クリックイベント
map.on("click", (e) => {
const lat = e.lngLat.lat;
const lng = e.lngLat.lng;
// クリックした位置の小学校区データを取得
fetch(`http://127.0.0.1:3000/get-gakku-polygon`, { // サーバーURLは適宜変更してください
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ lat: lat, lng: lng }),
})
.then((response) => response.json())
.then((data) => {
// 中略 ////////////////////////////////////
// GeoJSONのソースを追加
map.addSource(`gakku_geojson`, {
type: "geojson",
data: data,
});
// 小学校区ポリゴンの背景色を設定します
map.addLayer({
id: `polygon`,
type: "fill",
source: `gakku_geojson`,
layout: {},
paint: {
"fill-color": "#1c1c1c",
"fill-opacity": 0.5,
},
});
// 小学校区ポリゴンの枠線を設定します
map.addLayer({
id: `outline`,
type: "line",
source: `gakku_geojson`,
layout: {},
paint: {
"line-color": "#1c1c1c",
"line-width": 2,
},
});
// 小学校の位置を取得 TerraMap APIデータ特有のデータ構造に対応
const schoolPosition = {
lat: data.features[0].properties.point_coordinates[1],
lng: data.features[0].properties.point_coordinates[0],
};
// 小学校のマーカーを追加
schoolMarker = new maplibregl.Marker()
.setLngLat([schoolPosition.lng, schoolPosition.lat])
.setPopup(
new maplibregl.Popup({
offset: -35,
closeButton: false,
closeOnClick: false,
}).setHTML(
"<p class='popup-text'>" +
data.features[0].properties.points[0][0] + // 学校名:TerraMap APIデータ特有のデータ構造に対応
"</p>"
)
)
.addTo(map);
// マーカーのポップアップを表示
schoolMarker.togglePopup();
// 中略 ////////////////////////////////////
})
.catch((error) => {
console.log(error);
});
});
// 中略 ////////////////////////////////////
});
物件データの読み込みと抽出
物件データの読み込みと抽出部分を抜粋します。
ライブラリの取り込み
<script src="https://cdn.jsdelivr.net/npm/papaparse@5.5.3/papaparse.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@7.2.0/turf.min.js"></script>
CSVファイルの読み込み
PapaParseを活用し、比較的短いコードで済ませています。
const bukkenFile = "./bukken_sample.csv";
let bukkenData = [];
// 中略 ////////////////////////////////////
// サンプルCSVの文字コードがShift_JISなので、Shift_JISを指定して読み込む
fetch(bukkenFile)
.then((res) => res.arrayBuffer()) // バイナリで取得
.then((buffer) => {
const decoder = new TextDecoder("shift_jis"); // 文字コード指定
const text = decoder.decode(buffer);
const parsed = Papa.parse(text, { header: true });
bukkenData = parsed.data;
});
学区ポリゴン内の物件データを抽出
クリックイベント内で抽出を行っており、Turf.js の point と booleanPointInPolygon を利用しています。
//// クリックイベント内の一部分 ////
// polygonが小学校区ポリゴンで、型は Feature<Polygon | MultiPolygon> です
// 物件データの抽出
const polygon = data.features[0];
const filteredPoints = bukkenData.filter((point) => {
const pointCoords = turf.point([point.lng, point.lat]);
return turf.booleanPointInPolygon(pointCoords, polygon);
});
抽出された物件データの表示
抽出された小学校区内の物件データを、地図内とテーブルに表示する部分を抜粋します。いずれもクリックイベント内に記述しています。
物件の円レイヤーを追加
複数も考えられる物件データは、GeoJSON形式に変換し、マーカーではなく円のレイヤーとして追加しました。
//// クリックイベント内の一部分 ////
// 物件の円レイヤーを追加
const pointFeatures = filteredPoints.map((point) => ({
type: "Feature",
geometry: { type: "Point", coordinates: [point.lng, point.lat] },
properties: { name: point.name, address: point.address },
}));
const pointGeoJSON = {
type: "FeatureCollection",
features: pointFeatures,
};
map.addSource("bukken_geojson", {
type: "geojson",
data: pointGeoJSON,
});
map.addLayer({
id: "points",
type: "circle",
source: "bukken_geojson",
paint: {
"circle-radius": 6,
"circle-color": "#22ff98ff",
"circle-stroke-width": 2,
"circle-stroke-color": "#FFFFFF",
},
});
物件データのテーブル表示
Grid.js の Grid を活用して、比較的短いコードで済ませています。
let bukkenGrid = null;
// 中略 ////////////////////////////////////
//// クリックイベント内の一部分 ////
// 物件テーブル表示
if (filteredPoints.length > 0) {
document.getElementById("data-table").innerHTML = "";
const tableData = filteredPoints.map((point) => [
point.name,
point.address,
]);
if (!bukkenGrid) {
bukkenGrid = new gridjs.Grid({
columns: [
{ id: "name", name: "名称" },
{ id: "address", name: "住所" },
],
data: tableData,
}).render(document.getElementById("data-table"));
} else {
bukkenGrid
.updateConfig({
data: tableData,
})
.forceRender();
}
参考情報
