Flutterでコロプレス図を実現する
概要
この記事では、Flutterを使用してコロプレス図(地域ごとに統計データを色の濃淡で表現した地図)を実装する方法についての解説記事になります。
具体的には以下の内容になります
- 日本の都道府県境界データ(GeoJSON)の取得と最適化
- Google Maps Flutter プラグインを使用した地図表示の実装
- GeoJSONデータのパースと地図へのポリゴン描画
どれくらいの精度でコロプレス図を表示するかは、都道府県境界データの精度に依存しますが、今回はかなり簡略化したデータを使用して実装していきたいと思います。
開発環境
macOS Sonoma バージョン14.6.1 Apple M3
iOS18.1 iPhone 16 Pro simulator
Android API 34 simulator
Flutter SDK version: 3.32.0
Dart SDK version: 3.8.0
コロプレス図とは?
行政区画単位によって集計されたデータを、色やテクスチャで表現する地図
よく天気予報とかで見かける👇こんな図になります。
データ準備
まずは日本の都道府県境界データを取得したいと思います。以下からデータは取得できるのですが、全国だと容量が令和6年版だと 583MB
もあります。その中の「XXXX_prefecture.geojson」ファイルが 336.8MB
となっています。
そこで以下のサイトで簡略化してサイズを圧縮したいと思います。
👆のサイトでダンロードしたファイルの中から「XXXX_prefecture.geojson」となっているファイルをアップロードします。
右上の「Simplify」>「Apply」を選択します。
上部のスライドバーを調整し、どれくらいざっくりの都道府県境界データにするか調整します。今回はそこまで詳細なデータじゃなくて良いので「0.01%」にしました。
調整できたら右上の、「Export」>「GeoJSON」にチェックを入れて「Export」をクリックして圧縮したデータを「prefecture.json」としてダウンロードしときます。
手元の環境だと 18.5MB
まで圧縮できていました。
ただ、中身を見ると geometry
が null
のものが大量に含まれていました。LLM
の返答では「位置情報を持たない(=unlocated)有効な Feature」ではあるが地図描画や空間解析では座標がないと役に立たないため、多くのワークフローでは除外対象になります」との事で、実際に再度データを読み込ませても問題なさそうだったので削除しときます。
jq コマンドを用いて、 以下の様にgeometry
が null
のものを除外してやります。
👇を実行してやると 158KB
まで圧縮できました!
jq '.features |= map(select(.geometry != null))' prefecture.json > prefectures.geojson
まずは普通にGoogle Maps表示
手前味噌ですが、以下を元に google_maps_flutter をインストールしてAPI Keyを設定してGoogle Maps を単純に表示させます。
-
google_maps_flutter
dependencies: google_maps_flutter: ^2.12.2
-
choropleth_map.dart
を以下内容で作成します
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
class ChoroplethMap extends StatefulWidget {
const ChoroplethMap({super.key});
State<ChoroplethMap> createState() => ChoroplethMapState();
}
class ChoroplethMapState extends State<ChoroplethMap> {
final Completer<GoogleMapController> _controller =
Completer<GoogleMapController>();
static const CameraPosition _initialCameraPosition = CameraPosition(
target: LatLng(35.68123428932672, 139.76714355230686),
zoom: 14.4746,
);
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Choropleth Map')),
body: GoogleMap(
mapType: MapType.normal,
initialCameraPosition: _initialCameraPosition,
onMapCreated: (GoogleMapController controller) {
_controller.complete(controller);
},
),
);
}
}
実行すると👇のような地図が表示されてればOKです。
GeoJSONのパース処理
先ほどダウンロードしたGeoJSONファイル (prefectures.geojson)を assets/prefectures.geojson
に置きます。(assetsディレクトリは無ければ作成しときます)
pubspec.yaml
に以下を追加します。
flutter:
# ...
assets:
- assets/prefectures.geojson
今回パース用のパッケージとして geojson_vi
を使います。
このパッケージは GeoJSON Formatの標準化規約の RFC 7946
に準拠しているパッケージになります。
pubspec.yaml
に以下を追加します。
dependencies:
geojson_vi: ^2.2.5
次に、GeoJSONファイルをAssetsから読み込んで google_maps_flutter
のPolygonにセットできる形に変換する処理を実装します。
-
geojson_polygon_loader.dart
を以下内容で作成します
import 'dart:ui' show Color;
import 'package:flutter/services.dart' show rootBundle;
import 'package:geojson_vi/geojson_vi.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
/// GeoJSON(FeatureCollection)からGoogleMaps用Polygon群を生成
///
/// [assetPath] アセットファイルのパス
/// [fillColor] ポリゴンの塗りつぶし色(デフォルト: 半透明の青)
/// [strokeColor] ポリゴンの枠線色(デフォルト: 青)
/// [strokeWidth] ポリゴンの枠線幅(デフォルト: 1)
Future<Set<Polygon>> loadPolygonsFromGeoJson({
required String assetPath,
Color fillColor = const Color(0x55377EF6),
Color strokeColor = const Color(0xFF377EF6),
int strokeWidth = 1,
}) async {
// ❶ GeoJSONを文字列で取得
final geojsonStr = await rootBundle.loadString(assetPath);
return parseGeoJsonToPolygons(
geojsonStr,
fillColor: fillColor,
strokeColor: strokeColor,
strokeWidth: strokeWidth,
);
}
/// GeoJSON文字列からGoogleMaps用Polygon群を生成
///
/// [geojsonStr] GeoJSON文字列
/// [fillColor] ポリゴンの塗りつぶし色
/// [strokeColor] ポリゴンの枠線色
/// [strokeWidth] ポリゴンの枠線幅
Set<Polygon> parseGeoJsonToPolygons(
String geojsonStr, {
Color fillColor = const Color(0x55377EF6),
Color strokeColor = const Color(0xFF377EF6),
int strokeWidth = 1,
}) {
// ❷ パース(FeatureCollectionとして取得)
final collection = GeoJSONFeatureCollection.fromJSON(geojsonStr);
// ❸ Polygonへ変換
final polygons = <Polygon>{};
for (final feature in collection.features) {
// featureやgeometryがnullの場合はスキップ
final geom = feature?.geometry;
if (geom == null) continue;
// 単一ポリゴン
if (geom is GeoJSONPolygon) {
_addPolygon(
geom.coordinates,
polygons,
feature?.id,
fillColor: fillColor,
strokeColor: strokeColor,
strokeWidth: strokeWidth,
);
}
// マルチポリゴン
if (geom is GeoJSONMultiPolygon) {
for (int i = 0; i < geom.coordinates.length; i++) {
final ring = geom.coordinates[i];
_addPolygon(
ring,
polygons,
feature?.id != null
? '${feature?.id}_$i'
: 'multipolygon_$i', // idがnullの場合のフォールバック
fillColor: fillColor,
strokeColor: strokeColor,
strokeWidth: strokeWidth,
);
}
}
}
return polygons;
}
/// 座標配列をLatLngに変換してPolygonを生成
void _addPolygon(
List<List<List<double>>> rings,
Set<Polygon> polygons,
String? id, {
required Color fillColor,
required Color strokeColor,
required int strokeWidth,
}) {
if (rings.isEmpty) return;
final exterior = rings.first; // 外周リング
// 座標が不正な場合はスキップ
if (exterior.length < 3) return;
final points = exterior
.map((xy) {
// 座標の妥当性チェック
if (xy.length < 2) return null;
return LatLng(xy[1], xy[0]); // [lon,lat] → LatLng(lat,lon)
})
.where((point) => point != null)
.cast<LatLng>()
.toList();
// 最低3点必要
if (points.length < 3) return;
polygons.add(
Polygon(
polygonId: PolygonId(id ?? 'polygon_${polygons.length}'),
points: points,
fillColor: fillColor,
strokeColor: strokeColor,
strokeWidth: strokeWidth,
),
);
}
- 次に
choropleth_map.dart
を修正します
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_map_flutter_sample/functions/geojson_polygon_loader.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
class ChoroplethMap extends StatefulWidget {
const ChoroplethMap({super.key});
State<ChoroplethMap> createState() => ChoroplethMapState();
}
class ChoroplethMapState extends State<ChoroplethMap> {
final Completer<GoogleMapController> _controller =
Completer<GoogleMapController>();
static const CameraPosition _initialCameraPosition = CameraPosition(
target: LatLng(35.68123428932672, 139.76714355230686),
zoom: 14.4746,
);
Set<Polygon> _polygons = {};
bool _isLoading = true;
void initState() {
super.initState();
_loadPolygons();
}
/// GeoJSONからpolygonを読み込む
Future<void> _loadPolygons() async {
try {
final polygons = await loadPolygonsFromGeoJson(
assetPath: 'assets/prefectures.geojson',
fillColor: const Color(0x44377EF6),
strokeColor: const Color(0xFF377EF6),
strokeWidth: 2,
);
if (mounted) {
setState(() {
_polygons = polygons;
_isLoading = false;
});
}
} catch (e) {
debugPrint('ポリゴンの読み込みに失敗しました: $e');
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Choropleth Map'),
),
body: _isLoading
? const Center(
child: CircularProgressIndicator(),
)
: GoogleMap(
mapType: MapType.normal,
initialCameraPosition: _initialCameraPosition,
polygons: _polygons,
onMapCreated: (GoogleMapController controller) {
_controller.complete(controller);
},
),
);
}
}
実行すると👇の様なコロプレス図が表示されるかと思います。
参考URL
Discussion