投稿日
React Leafletを使って地図アプリを作成してみよう
もくじ
1. はじめに
はじめまして。デザイン&エンジニアリング部所属の内田です。Java開発に参画することが多いです。
最近の趣味はウイスキーで、よくモルトバーに出没しています(おかげで金欠気味・・・)。
1.1. この記事の目的
私が最近携わったプロジェクトで、「オープンデータをREST APIで呼び出し、地図上に表示する」というアプリケーションを React + TypeScript で作成しました。本記事では、アプリケーションで利用した地図ライブラリ「React Leaflet」について、プロジェクトでの経験を踏まえ、実際に動くアプリを作りながら紹介します。また、React Leafletを使用する上で躓いた点、及びその解消方法についても紹介します。
1.2. Leafletについて
Leafletは、モバイルフレンドリーでインタラクティブな地図アプリケーションのために開発された、オープンソースのJavaScriptライブラリです。ほとんどの主要な地図機能を実装しており、これを利用してウェブアプリやモバイルアプリを作成することができます。
※公式Webサイトより
1.3. React Leafletについて
React Leafletは、Leafletの機能をReactコンポーネントとして扱えるようにしたライブラリです。Reactフレームワーク上で、直感的に地図アプリを構築する事ができます。
2. 今回作るもの紹介
以下が、本記事で作成した地図アプリケーションです。
地図アプリケーションをWebブラウザで開くと、OpenStreetMapが表示されます。上部のコントロールをクリックすると、データソースからGeoJSONデータを取得し、地図上に描画します。再度ボタンをクリックすると、描画されたデータを消します。ボタンクリックを繰り返すことで、データの表示・非表示を切り替えます。また、描画されたデータをクリックすると、情報が地図上のポップアップとして表示されます。
※OpenStreetMapは、自由に使用できるオープンデータ地図の共同作成プロジェクト、もしくはそこで作成されたオープンデータ地図です。
2.1. GeoJSONについて
GeoJSON(RFC7946)は、地理空間情報を表現するフォーマットの一つです。様々な地理空間データ構造をJSON形式でエンコードしています。地理空間データ構造は、「点( Point
)」「線( LineString
)」「ポリゴンで表現される面( Polygon
)」のような図形と、それらの図形+属性データ(プロパティ)で表される「地物( Feature
)」から構成されています。
※地物・・・天然物、人工物の区別なく、地上に存在するすべてのものの総称。
本記事では、「面で表現される地物」を、実際に描画しています。
また、他の代表的なデータフォーマットとして、Shapefile(ESRI Shapefile Technical Description)が存在します。プロジェクトでは、Shapefileや(緯度経度情報を含む)CSVファイル等をバックエンドでGeoJSON形式に変換し、地図アプリケーションからはGeoJSONデータをREST APIを使用して取得・描画しました。
3. バックエンド
プロジェクトではREST API経由でGeoJSONデータを取得していますが、React Leafletを使用する上ではデータの取得方法に制限はありません。そのため、本記事ではバックエンドアプリを用意せず、単に用意したGeoJSONデータ・ファイルを読み込むことにします。
本記事で使用するGeoJSONデータは以下のとおりです。
{
"features": [
{
"geometry": {
"coordinates": [ [ [ 139.785126, 35.644671 ], [ 139.815126, 35.644671 ], [ 139.815126, 35.664671 ], [ 139.785126, 35.664671 ], [ 139.785126, 35.644671 ] ] ],
"type": "Polygon"
},
"properties": {
"拠点": "豊洲オフィス",
"住所": "東京都江東区豊洲2-2-1"
},
"type": "Feature"
},
{
"geometry": {
"coordinates": [ [ [ 139.680664, 35.686144 ], [ 139.700664, 35.686144 ], [ 139.700664, 35.706144 ], [ 139.680664, 35.706144 ], [ 139.680664, 35.686144 ] ] ],
"type": "Polygon"
},
"properties": {
"拠点": "東京本社",
"住所": "東京都新宿区西新宿8-17-1"
},
"type": "Feature"
}
],
"name": "test",
"type": "FeatureCollection"
}
GeoJSONデータには、以下の地物を含めています。
- 弊社東京本社のビル周囲を囲んだポリゴン(東京都新宿区西新宿8-17-1)
- 弊社豊洲オフィスのビル周囲を囲んだポリゴン(東京都江東区豊洲2-2-1)
上記のデータに限らず、GeoJSONとしてエンコードされたデータであれば、どのようなデータでも表示することが可能です。
4. フロントエンド
地図アプリケーションは、npx create-react-app
でTypeScriptベースのプロジェクトを作成し、依存パッケージとしてaxios
、bootstrap
、react-bootstrap
、leaflet
、react-leaflet
を追加しました。
本記事における、地図アプリケーションの依存パッケージ全体は以下のとおりです。
"dependencies": {
"@testing-library/jest-dom": "^5.16.0",
"@testing-library/react": "^11.2.7",
"@testing-library/user-event": "^12.8.3",
"@types/jest": "^26.0.24",
"@types/node": "^12.20.37",
"@types/react": "^17.0.37",
"@types/react-dom": "^17.0.11",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "^5.0.0",
"typescript": "^4.5.2",
"web-vitals": "^1.1.2",
"axios": "^0.25.0",
"bootstrap": "^5.1.3",
"react-bootstrap": "^2.0.3",
"leaflet": "^1.7.1",
"react-leaflet": "^3.2.2"
}
以下に、地図アプリケーション全体を記載します(小さいアプリであるため、1ファイルにCSS以外のすべてのコンポーネントを記載しています)。実際に動かすときは、create-react-app
で作成したプロジェクト内のsrc/App.tsx
を、以下の内容で置換してください。
import React, { ReactElement, useEffect, useState } from 'react';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'leaflet/dist/leaflet.css';
import './App.css';
import L, { LatLng, Layer } from 'leaflet';
import { GeoJSON, MapContainer, ScaleControl, TileLayer, ZoomControl } from 'react-leaflet';
import { Col, Container, Row, Card, Spinner } from 'react-bootstrap';
import axios from 'axios';
import ReactDOMServer from 'react-dom/server';
import { Feature, GeoJsonObject, GeometryObject, GeoJsonProperties } from 'geojson';
const App: React.FC = () => {
const [isWaitng, setIsWaiting] = useState(true);
useEffect(() => {
setTimeout(() => {
setIsWaiting(false);
}, 400);
});
return (
<>
{isWaitng ? (
<div className="loading">
<Spinner animation="border" role="status" variant="light" className="spinner" />
</div>
) : (
<Top />
)}
</>
);
};
const Top: React.FC = () => {
const initialPosition = new LatLng(35.654671, 139.795126);
// featureを表示するかどうか制御する(feature毎に作成)
const [polygonVisible, setPolygonVisible] = useState(false);
// featureデータをロードする(feature毎に作成)
const [polygonData, isPolygonLoading] = useGeoJSONData("/test.geojson", polygonVisible);
return (
<>
<div className="top">
{isPolygonLoading && (
<div className="loading">
<Spinner animation="border" role="status" variant="light" className="spinner" />
</div>
)}
<div>
<Card>
<Card.Header>
<span>てすとまっぷ</span>
</Card.Header>
<ul className='category-list'>
<li onClick={() => { setPolygonVisible(!polygonVisible) }} >
<span style={{ color: '#dc143c' }}>■</span> てすと
</li>
</ul>
</Card>
</div>
{ /* tap={false}は既知のバグへの代替策
https://github.com/Leaflet/Leaflet/issues/7255 */}
<MapContainer
zoom={13}
zoomControl={false}
center={initialPosition}
tap={false}
preferCanvas={true}
renderer={L.canvas()}
>
<ScaleControl position="bottomright" imperial={false} />
<ZoomControl position="bottomright" />
<TileLayer
attribution='© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{polygonVisible && polygonData && <GeoJSON data={polygonData} onEachFeature={onEachPolygonFeature} style={polygonStyle} />}
</MapContainer>
</div>
</>
);
};
type OnEachFeature = (feature: Feature<GeometryObject, GeoJsonProperties>, layer: Layer) => void;
type Style = {
fillColor: string | undefined,
color: string | undefined,
weight: number | undefined
}
// polygonのfeature(地物)毎の処理を記載する
const onEachPolygonFeature: OnEachFeature = (feature, layer) => {
const fp = feature.properties;
const location_name = (fp && fp.拠点) ? decodeURIComponent(fp.拠点) : '-';
const address = (fp && fp.住所) ? decodeURIComponent(fp.住所) : '-';
const element: ReactElement = (
<Container className="container">
<Row className="row-style-narrow">
<Col className="col-title">拠点名</Col>
<Col xs={6}>{location_name}</Col>
</Row>
<Row className="row-style-narrow">
<Col className="col-title">住所</Col>
<Col xs={6}>{address}</Col>
</Row>
</Container>
);
layer.bindPopup(`${ReactDOMServer.renderToString(element)}`, {
maxHeight: 450,
});
};
const polygonStyle: Style = { fillColor: '#dc143c', color: '#dc143c', weight: 1.0 }
// 描画データのフェッチを制御するカスタムフック。
// 初回描画時にデータをフェッチし、以降は取得しない(更新頻度が小さいことと、取得データが大きいため)
type UseGeoJSONData = (url: string, showToggle: boolean) => [GeoJsonObject | undefined, boolean]
const useGeoJSONData: UseGeoJSONData = (url, showToggle) => {
// 描画データを保持する
const [area, setArea] = useState();
// Topがloading状態かどうかを保持する
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const handleError = (error: any) => {
setIsLoading(false);
alert('サーバとの通信に失敗しました。時間をおいて再度お試しください。');
throw new Error(error);
};
const getData = async () => {
setIsLoading(true);
await axios.get(url)
.then((result) => setArea(result.data))
.catch((error) => handleError(error));
setIsLoading(false);
};
if (showToggle && area == null) {
getData();
}
}, [showToggle]);
return [area, isLoading];
}
export default App;
CSSは以下を使用します。 src/App.css
を以下の内容で置換してください。
:root {
--main-bg-color: rgb(0, 147, 209);
--main-bg-color-opacity: rgb(0, 147, 209, 30%);
}
html {
font-size: 62.5%;
}
body {
font-size: 1.6rem;
color: var(--bs-dark);
}
.leaflet-container {
height: calc(100% - 8.4rem);
width: 100vw;
}
.leaflet-control-layers-base {
text-align: left;
}
.leaflet-popup-content {
width: auto !important;
}
.card-header {
font-weight: 700;
}
.loading {
background: rgb(0, 0, 0, 50%)
}
.top {
text-align: center;
height: 100vh;
width: 100vw;
}
.category-list {
margin: 1rem 0;
list-style: none;
font-size: 1.4rem;
}
.category-list :hover {
text-decoration: underline;
cursor: pointer;
}
.loading {
position: fixed;
top: 0;
left: 0;
z-index: 1200;
display: block;
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
outline: 0;
background: rgb(0, 0, 0, 50%)
}
.spinner {
position: absolute;
top: 50%;
left: 50%;
}
.container {
overflow: auto;
white-space: pre-line;
max-height: 450px;
}
.col-title {
font-weight: 700;
}
.row-style-narrow {
width: 400px;
}
以下で、本アプリにおける主要なコンポーネントを解説します。
4.1. Top
Top
はReactコンポーネントです。地図の描画やコントロール、及び地物の描画状態の制御を実施します。
地図の本体は MapContainer
コンポーネントです。 MapContainer
コンポーネントの子コンポーネントとして、地図上の部品を配置します。今回は、地図そのものである TileLayer
の他に、地図のスケール( ScaleControl
)、地図のズーム( ZoomControl
)を表示しています。
地物は、 GeoJSON
コンポーネントとして表現されます。別途取得したGeoJSONデータを渡すことで、GeoJSONデータ上の表現に応じた地物を表示します。また、GeoJSONデータ中には一般的に、複数の地物データが含まれます。地物それぞれに適用したい処理を渡すこともできます(後述)。
配置可能な子コンポーネントの一覧は、 https://react-leaflet.js.org/docs/api-components/ を参照してください。
地物の描画状態をコントロールするためのコンポーネントは、React Bootstrapを使用して作成しています。React Bootstrapは、CSSフレームワークであるBootstrapをReactで扱えるようにしたもので、コントロール部分の見た目を整えるために使用しました。「てすと」と書かれた部分をクリックする度に、地物の表示・非表示が切り替わります。
4.2. OnEachFeature
OnEachFeature
型の関数では、各地物に適用したい処理を記述します。典型的には、(クリック等の)イベントが発生した時の地図上の挙動を実装します。Layer
クラスで、マーカーを置く、ポップアップを表示する等、様々な挙動が定義されています。本アプリ内では、クリックしたときに、地物のプロパティ情報をポップアップとして表示するようにしています。
4.3. useGeoJSONData
useGeoJSONData
は、GeoJSONデータを取得するためのカスタムフックです。プロジェクトでは、一度に取得するデータが大きく(10MB程度)、また更新頻度も高くないことから、以下のような挙動をさせています。
- GeoJSONデータの最初の描画タイミングで、データの取得を行う
- データ取得中の状態では、
Top
コンポーネント上で取得中であることがわかるようにする(React BootstrapのSpinner
コンポーネントで表現しています) - 一度取得したら、画面のリロードを行わない限り再度データの取得は行わない
useGeoJSONData
からは、取得したデータ、及びデータ取得ステータスを返却しています。
5. うまく行かなかったところの解消方法
上で作成したアプリでは、表示した地物データは数件程度でしたが、実際に取り扱ったデータセットでは、 Polygon
で表現される地物データが数万〜数十万個からなるようなものも存在します。地物データ1件当たり、プロパティ等含めて1KB程度のサイズになることもあり、データサイズとしても10〜100MB程度になります。プロジェクトを推進するにあたり、このようなデータセットのデータの描画が遅くなってしまう問題に直面しました。
データをネットワークから取得するのではなく、予めローカルファイルとして配置すると、改善はしたものの、まだ描画が遅い(3〜4秒程度)状況となっています。
ブラウザの開発者ツールを使用し、実際のデータを読み込んでパフォーマンス測定をしたところ、Scriptingにおいて地物データの件数に応じた addData
関数が実行されており、これが計算の9割程度の時間を要していることを確認しました(以下参照)。
※ addData
関数は、 GeoJSON
コンポーネントがpropで受け取ったデータを読み込む処理を実行しています。
この問題を解決するため、以下の解決策を検討しました。
- 解決策1
- 表示するデータを地図表示領域に限定し、地図を移動するたびに必要なデータをレンダリングする → 以下の点から、不採用としました。
- 地物データ中の座標と地図表示領域を紐付けるためには、一度
GeoJSON
コンポーネントに変換する必要があります。今回は、まさにGeoJSON
コンポーネントに変換する処理で時間がかかっています。 - また、上記のスクリーンショットからわかるとおり、Rendering及びPaintingにはそれほど時間がかかっていません。そのため、表示範囲を限定することはあまり効果がないと判断しました。
- 地物データ中の座標と地図表示領域を紐付けるためには、一度
- 解決策2
- より軽量なデータ形式(TopoJSON)に変換する → 以下の点から、不採用としました。
- バックエンドから返却されるデータはGeoJSONであり、これを変更することはプロジェクトとしては難しかったです。
- プロジェクト実施時点で、フロントエンド側でGeoJSON → TopoJSON変換を実施するライブラリが見つけられませんでした。
- 解決策3
Polygon
をマージ・単純化等実施し、不要な点やポリゴンを取り除くことで、描画対象データを軽量化する → 以下の点から、不採用としました。- geojson-reducer という、GeoJSONオブジェクトへの変換前に不要な座標をトリミングするライブラリが存在しますが、プロジェクト実施時点で最終更新から4年以上経過しているため、採用に踏み切ることができませんでした。
- 解決策4(採用)
- 初回の描画で時間がかかってしまうのは諦める → この方針を採用しました。
- 今回は、実測で最大10秒程度であり、プロジェクトの制約条件として合意することができました。
- 解決策5(採用)
- 再描画の際は、ネットワークからのデータ再取得は行わず、キャッシュしたデータを使い回す → この方針を採用しました。
- ネットワークI/Oを減らすだけでも改善はしているため、データはキャッシュする方針としました。
- プロジェクトで使用したデータは頻繁に更新されるデータではないため、キャッシュを描画しても問題ない、と判断しました。
MapContainer
の子コンポーネントについては、CSSクラスの切り替えによる表示・非表示の制御をすることができません。そのため、 GeoJSON
コンポーネントのマウント・アンマウントを切り替えることで、表示・非表示を制御しています。このような方式にした場合、たとえ GeoJSON
コンポーネントをメモ化したとしても、再マウントする際に再度計算が実行されてしまうため、どうしても描画の遅さを取り除くことができませんでした。
プロジェクトでは期日の問題で採用できませんでしたが、 GeoJSON
コンポーネントのメモ化と LayersControl.Overlay
を組み合わせることで、上記の問題を解消し、高速に描画の表示・非表示を切り替えることができます。LayersControl.Overlay
内に記載された子コンポーネントは、 checked
属性( true/false
)を介して MapContainer
側で表示・非表示が制御されます。この場合、子コンポーネントのアンマウントは発生しませんが、 MapContainer
での再表示の際にコンポーネントの再計算が発生するため、合わせて子コンポーネントを React.memo
でメモ化しておくことで、高速に表示・非表示を切り替えることができます。注意点として、この方法をとった場合、本来の表示・非表示のコントロールとは別に、 MapContainer
内にも表示・非表示の切り替えチェックボックスが発生してしまいます。
これは、コントロールを折りたたんでおくことで、目立たなくすることは可能です。
参考として、 LayersControl.Overlay
を使用した実装を以下に記載します。
import React, { ReactElement, useEffect, useState } from 'react';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'leaflet/dist/leaflet.css';
import './App.css';
import L, { LatLng, Layer } from 'leaflet';
import { GeoJSON, LayersControl, MapContainer, ScaleControl, TileLayer, ZoomControl } from 'react-leaflet';
import { Col, Container, Row, Card, Spinner } from 'react-bootstrap';
import axios from 'axios';
import ReactDOMServer from 'react-dom/server';
import { Feature, GeoJsonObject, GeometryObject, GeoJsonProperties } from 'geojson';
const App: React.FC = () => {
const [isWaitng, setIsWaiting] = useState(true);
useEffect(() => {
setTimeout(() => {
setIsWaiting(false);
}, 400);
});
return (
<>
{isWaitng ? (
<div className="loading">
<Spinner animation="border" role="status" variant="light" className="spinner" />
</div>
) : (
<Top />
)}
</>
);
};
const Top: React.FC = () => {
const initialPosition = new LatLng(35.654671, 139.795126);
// featureを表示するかどうか制御する(feature毎に作成)
const [polygonVisible, setPolygonVisible] = useState(false);
// featureデータをロードする(feature毎に作成)
const [polygonData, isPolygonLoading] = useGeoJSONData("/test.geojson", polygonVisible);
return (
<>
<div className="top">
{isPolygonLoading && (
<div className="loading">
<Spinner animation="border" role="status" variant="light" className="spinner" />
</div>
)}
<div>
<Card>
<Card.Header>
<span>てすとまっぷ</span>
</Card.Header>
<ul className='category-list'>
<li onClick={() => { setPolygonVisible(!polygonVisible) }} >
<span style={{ color: '#dc143c' }}>■</span> てすと
</li>
</ul>
</Card>
</div>
{/* tap={false}は既知のバグへの代替策
https://github.com/Leaflet/Leaflet/issues/7255 */}
<MapContainer
zoom={13}
zoomControl={false}
center={initialPosition}
tap={false}
preferCanvas={true}
renderer={L.canvas()}
>
<ScaleControl position="bottomright" imperial={false} />
<ZoomControl position="bottomright" />
<LayersControl position="bottomright">
<LayersControl.BaseLayer checked name="OpenStreetMap">
<TileLayer
attribution='© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
</LayersControl.BaseLayer>
// LayersControl.Overlayを使用して、地物の表示・非表示を切り替える
<LayersControl.Overlay checked={polygonVisible} name="てすと">
{polygonData && <GeoJSONMemo geojsonObject={polygonData} onEachFeature={onEachPolygonFeature} style={polygonStyle} />}
</LayersControl.Overlay>
</LayersControl>
</MapContainer>
</div>
</>
);
};
type Props = {
geojsonObject: GeoJsonObject;
onEachFeature: OnEachFeature;
style: Style;
}
// React.memoを使用して、GeoJSONコンポーネントをメモ化する
const GeoJSONMemo: React.FC = React.memo(({ geojsonObject, onEachFeature, style }) => {
return (
<GeoJSON data={geojsonObject} onEachFeature={onEachFeature} style={style} />
);
});
type OnEachFeature = (feature: Feature<GeometryObject, GeoJsonProperties>, layer: Layer) => void;
type Style = {
fillColor: string | undefined,
color: string | undefined,
weight: number | undefined
}
// polygonのfeature(地物)毎の処理を記載する
const onEachPolygonFeature: OnEachFeature = (feature, layer) => {
const fp = feature.properties;
const location_name = (fp && fp.拠点) ? decodeURIComponent(fp.拠点) : '-';
const address = (fp && fp.住所) ? decodeURIComponent(fp.住所) : '-';
const element: ReactElement = (
<Container className="container">
<Row className="row-style-narrow">
<Col className="col-title">拠点名</Col>
<Col xs={6}>{location_name}</Col>
</Row>
<Row className="row-style-narrow">
<Col className="col-title">住所</Col>
<Col xs={6}>{address}</Col>
</Row>
</Container>
);
layer.bindPopup(`${ReactDOMServer.renderToString(element)}`, {
maxHeight: 450,
});
};
const polygonStyle: Style = { fillColor: '#dc143c', color: '#dc143c', weight: 1.0 }
// 描画データのフェッチを制御するカスタムフック。
// 初回描画時にデータをフェッチし、以降は取得しない(更新頻度が小さいことと、取得データが大きいため)
type UseGeoJSONData = (url: string, showToggle: boolean) => [GeoJsonObject | undefined, boolean]
const useGeoJSONData: UseGeoJSONData = (url, showToggle) => {
// 描画データを保持する
const [area, setArea] = useState();
// Topがloading状態かどうかを保持する
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const handleError = (error: any) => {
setIsLoading(false);
alert('サーバとの通信に失敗しました。時間をおいて再度お試しください。');
throw new Error(error);
};
const getData = async () => {
setIsLoading(true);
await axios.get(url)
.then((result) => setArea(result.data))
.catch((error) => handleError(error));
setIsLoading(false);
};
if (showToggle && area == null) {
getData();
}
}, [showToggle]);
return [area, isLoading];
}
export default App;
6. まとめ
本記事にて、React Leafletを使用した地図アプリケーションを実際に作成し、実装のポイントを解説しました。また、大きなデータセットに対して描画が遅くなってしまう問題に対し、プロジェクト内での解決方法を検討し、また他に取りうる方法について指摘しました。
私自身は、プロジェクト内ではバックエンドの担当だったため、地図アプリケーションの開発にはあまり関わっていませんでした。今回は、React/TypeScript/Leafletを勉強しながらの執筆でしたが、思いの外簡単に地図アプリケーションを組むことができて驚いています。今回のような構成だと、他にも地理空間情報やGeoJSON等の知識も必要になりますが、React Leaflet自体は自分でアプリを開発するときにも使いやすそうだと感じました。
作成したアプリは基本的な機能のみ取り上げていますが、実際にGeoJSONデータを描画する処理は、上記のような形式になると考えています。React Leafletを使用して地図アプリケーションを作成する際、本記事をご参考いただければ幸いです。