🗺️

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

コロプレス図とは?

https://visualizing.jp/choropleth-map/

行政区画単位によって集計されたデータを、色やテクスチャで表現する地図

よく天気予報とかで見かける👇こんな図になります。

image1.png

データ準備

まずは日本の都道府県境界データを取得したいと思います。以下からデータは取得できるのですが、全国だと容量が令和6年版だと 583MB もあります。その中の「XXXX_prefecture.geojson」ファイルが 336.8MB となっています。

https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-N03-2024.html

そこで以下のサイトで簡略化してサイズを圧縮したいと思います。

https://mapshaper.org/

👆のサイトでダンロードしたファイルの中から「XXXX_prefecture.geojson」となっているファイルをアップロードします。

右上の「Simplify」>「Apply」を選択します。

image2.png

上部のスライドバーを調整し、どれくらいざっくりの都道府県境界データにするか調整します。今回はそこまで詳細なデータじゃなくて良いので「0.01%」にしました。

image3.png

調整できたら右上の、「Export」>「GeoJSON」にチェックを入れて「Export」をクリックして圧縮したデータを「prefecture.json」としてダウンロードしときます。

手元の環境だと 18.5MB まで圧縮できていました。

ただ、中身を見ると geometrynull のものが大量に含まれていました。LLM

の返答では「位置情報を持たない(=unlocated)有効な Feature」ではあるが地図描画や空間解析では座標がないと役に立たないため、多くのワークフローでは除外対象になります」との事で、実際に再度データを読み込ませても問題なさそうだったので削除しときます。

jq コマンドを用いて、 以下の様にgeometrynull のものを除外してやります。

https://jqlang.org/

👇を実行してやると 158KB まで圧縮できました!

jq '.features |= map(select(.geometry != null))' prefecture.json > prefectures.geojson

まずは普通にGoogle Maps表示

手前味噌ですが、以下を元に google_maps_flutter をインストールしてAPI Keyを設定してGoogle Maps を単純に表示させます。

https://zenn.dev/slowhand/articles/f4e4e092f9b72b

  • 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です。

image4.png

GeoJSONのパース処理

先ほどダウンロードしたGeoJSONファイル (prefectures.geojson)を assets/prefectures.geojson に置きます。(assetsディレクトリは無ければ作成しときます)

pubspec.yaml に以下を追加します。

flutter:
  # ...
  assets:
    - assets/prefectures.geojson

今回パース用のパッケージとして geojson_vi を使います。

https://pub.dev/packages/geojson_vi

このパッケージは GeoJSON Formatの標準化規約の RFC 7946 に準拠しているパッケージになります。

https://tex2e.github.io/rfc-translater/html/rfc7946.html

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);
              },
            ),
    );
  }
}

実行すると👇の様なコロプレス図が表示されるかと思います。

image5.gif

参考URL

https://note.com/kazukio/n/n974da9bb1ffb

https://github.com/piuccio/open-data-jp-prefectures-geojson

Discussion