見出し画像

Flutter で熊出没マップを作ろう🐻

この記事はjig.jp Advent Calendar 2023の12月20日(水)の記事です🐻


こんにちは。
株式会社jig.jp でアプリ開発・保守を担当しております。
この記事では、Flutter で弊社周辺の熊出没マップを作っていきます。

作成したアプリの リポジトリ / デモ

はじめに

弊社の開発センターはめがねのまち福井県鯖江市に立地しております。

今年は福井県内でも度々熊が目撃されており、社内のSlack では時折、「ヤバい!」「 近すぎない??」とザワつくことがあります。

果たして弊社は安全なのか、周辺のどのぐらいの位置に出没しているのか、気になりませんか? 気になりますよね。

はい、なんと鯖江市は熊出没情報を位置情報も含め、すべて公開しております:
https://www.city.sabae.fukui.jp/anzen_anshin/chojuhigaitaisaku/kuma-taisaku/kumasyutubotu/index.html

今回はこちらのデータを整形して、熊出没情報を地図上で確認するアプリをFlutterで開発していこうと思います。

flutter_map を使ってみよう

今回は Flutter でマッピングするのに flutter_map というFlutter 用の多用途マッピングパッケージを利用します。実装は非常にシンプル、かつカスタマイズ容易であり、Flutter アプリで簡単なマッピングアプリを実装するのに最適です。

まずはじめに、国土地理院マップを表示するだけのアプリを作成してみましょう。

国土地理院タイルマップについて

今回は熊の分布を山林か市街地かで確認したかったため、国土地理院の衛星写真のタイルマップを9~18のズームレベルで利用します:

タイルマップはいろいろなものが公開されていますが、利用規約を参照して、出典が必要なものは出典を記載するようにしてください。出典の記載のみでは利用できないものもあるためご注意ください。

flutter_map で国土地理院の衛星写真地図を表示する

flutter_map で地図を描画するには、FlutterMap Widget の children に TileLayer を配置して、使用するタイルマップのURLテンプレートを渡すだけです。

MapOptions でズームレベルやインタラクションなども指定できます。

また、 Attribution Layer では、使用するタイルマップなどの出典を明記するのに便利な2種類のWidgetが用意されています。

import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';

class KumapPage extends StatelessWidget {
  const KumapPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: FlutterMap(
        options: const MapOptions(
          // 初期座標に弊社の開発センターの座標を指定しておく
          initialCenter: LatLng(35.943306, 136.200357);
          initialZoom: 14,
          minZoom: 9,
          maxZoom: 18,
        ),
        children: [
          TileLayer(
            // タイルマップのURLを設定する:
            // オープンストリートマップ: https://tile.openstreetmap.org/{z}/{x}/{y}.png
            // 国土地理院(標準地図): https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png
            // 国土地理院(衛星写真): https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg     
            urlTemplate: 'https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg',
          ),
          RichAttributionWidget(
            attributions: [
              // ※ 必要に応じて出典を追加すること
              TextSourceAttribution(
                '国土地理院',
                onTap: () => launchUrl(Uri.parse('https://maps.gsi.go.jp/development/ichiran.html')),
              ),
              const TextSourceAttribution('Landsat8画像(GSI,TSIC,GEO Grid/AIST)'),
              const TextSourceAttribution('Landsat8画像(courtesy of the U.S. Geological Survey)'),
              const TextSourceAttribution('海底地形(GEBCO)'),
            ],
          ),
        ],
      ),
    );
  }
}

Chrome でビルドしてみるとブラウザ上で地図が表示されました 🎉
右下のボタンから、出典情報をポップアップすることができます。

MarkerLayer で地図上にマーカーを設置してみよう

地図アプリで欠かせないのが、任意座標にマーカーを設置することです。

flutter_map では TileLayer の上に MarkerLayer を重ねることで任意の座標に任意のWidget を設置する事が可能です。

試しに弊開発センターの座標に弊社ロゴを設置してみましょう:

ロゴ画像をAssetsに追加しておく

プロジェクト直下に assets というディレクトリを作成し、ロゴ画像のファイルを放り込んでおきます。

また、pubspec.yaml で assets ディレクトリにアクセスできるようにしておきましょう。

flutter:
  assets:
    - assets/

JigMarkerクラスを作成

まずは、Marker クラスを拡張して JigMarker クラスを作成します:

import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';

class JigMarker extends Marker {
  static const size = 48.0;
  // 弊開発センターの座標(緯度, 経度)
  static const pos = LatLng(35.943306, 136.200357);

  JigMarker()
      : super(
          // マーカーの高さ
          height: JigMarker.size, 
          // マーカーの幅
          width: JigMarker.size,
          // 座標
          point: pos, 
          // マーカーWidget
          child: Image.asset('assets/jigjp.png'),  
        );
}

MarkerLayerでマーカーを配置

あとは MarkerLayer にマーカーを渡してやるだけです:

class KumapPage extends StatelessWidget {
  const KumapPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: FlutterMap(
        options: const MapOptions(
          initialCenter: JigMarker.pos,
          initialZoom: 14,
          minZoom: 9,
          maxZoom: 18,
        ),
        children: [
          TileLayer(   
            urlTemplate: 'https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg',
          ),
          // jigマーカー設置
          MarkerLayer(
            markers: [
              JigMarker(),
            ],
          ),
          RichAttributionWidget(
            ...
          ),
        ],
      ),
    );
  }
}

表示されました! 簡単ですね。
なお、衛星画像のデータが古いため、画像では弊開発センターがまだ建設中の状態になっています🏗

CircleLayer で円を描画

CircleLayer を利用することで地図上に任意の半径で円を描画することも可能です:

JigCircleMarkerクラスを作成

CircleMarker クラスを拡張して JigCircleMarker クラスを作成します:

class JigCircleMarker extends CircleMarker {
  JigCircleMarker()
      : super(
          // 座標
          point: JigMarker.pos,
          // メートル単位
          useRadiusInMeter: true,
          // 1000m = 1km 半径
          radius: 1000,
          // 円の色
          color: Colors.green.withOpacity(0.1),
          // 円の枠線の色
          borderColor: Colors.green,
          // 円の枠線の太さ
          borderStrokeWidth: 1.0,
        );
}

CircleLayerで円を配置

あとは CircleLayer にマーカーを渡してやるだけです:

class KumapPage extends StatelessWidget {
  const KumapPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: FlutterMap(
        options: const MapOptions(
          initialCenter: JigMarker.pos,
          initialZoom: 14,
          minZoom: 9,
          maxZoom: 18,
        ),
        children: [
          TileLayer(   
            urlTemplate: 'https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg',
          ),
          // jigマーカー設置
          MarkerLayer(
            markers: [
              JigMarker(),
            ],
          ),
          // jigサークル設置
          CircleLayer(
            circles: [
              JigCircleMarker(),
            ],
          ),
          RichAttributionWidget(
            ...
          ),
        ],
      ),
    );
  }
}

半径1kmの円を描画することができました。とっても簡単ですね!

熊出没情報のデータセット作成

では、熊出没情報のデータを作成していきます。

鯖江市のクマ出没情報 から必要な情報を下記の Json 形式でリスト化しました:

[
  {
    "type": "痕跡情報",
    "latitude": 35.98032,
    "longitude": 136.19014,
    "url": "https://www.city.sabae.fukui.jp/anzen_anshin/chojuhigaitaisaku/kuma-taisaku/kumasyutubotu/20231211.html"
  },
  {
    "type": "目撃情報",
    "latitude": 35.979302,
    "longitude": 136.199367,
    "url": "https://www.city.sabae.fukui.jp/anzen_anshin/chojuhigaitaisaku/kuma-taisaku/kumasyutubotu/kuma_R51211-2.html"
  },  
  {
    "type": "痕跡情報",
    "latitude": 35.98053,
    "longitude": 136.191211,
    "url": "https://www.city.sabae.fukui.jp/anzen_anshin/chojuhigaitaisaku/kuma-taisaku/kumasyutubotu/20231210.html"
  },    
  ...
]

プロジェクトの assets/kuma.json というファイルに作成したデータを保存しておきます。

freezed で Kuma データクラスを作成する

おなじみ、freezedjson_serializable を導入しておきましょう:

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  freezed_annotation:
  json_annotation:

dev_dependencies:
  flutter_test:
    sdk: flutter
  freezed:
  build_runner:
  json_serializable:

先に作成した熊出没情報のデータセットをデータクラスに変換できるようにします。

freezed では、Kumaクラスを下記のように定義します:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'kuma.freezed.dart';
part 'kuma.g.dart';

@freezed
class Kuma with _$Kuma {
  const factory Kuma({
    required String url,
    required String type,
    required double longitude,
    required double latitude,
  }) = _Kuma;

  factory Kuma.fromJson(Map<String, Object?> json) => _$KumaFromJson(json);
}

ターミナルで dart run build_runner build を実行すると、コードが自動生成されます。

RiverPod で Kuma データリストのProvider を作成する

この程度なら FutureProvider でも十分ですが、せっかくなので、riverpod_generator を使って熊情報一覧を返すProvider を実装してみましょう。

導入:

# pubspec.yaml
dependencies:
  ...
  flutter_hooks:
  hooks_riverpod:
  riverpod_annotation:

dev_dependencies:
  ...
  build_runner:
  riverpod_generator:

Providerの定義:

import 'dart:convert';

import 'package:flutter/services.dart';
import 'package:kumap/data/kuma.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'kuma_list_provider.g.dart';

@riverpod
Future<List<Kuma>> kumaList(KumaListRef ref) async {
  // assets から 熊出没情報のデータセットを読み込む
  final json = await rootBundle.loadString('assets/kuma.json');
  // jsonDecode する
  final List<dynamic> list = jsonDecode(json);
  // Kumaデータクラスに変換して返す
  return list.map((e) => Kuma.fromJson(e)).toList();
}

dart run build_runner build を実行すると、コードが自動生成されます。

これでデータセットを利用する準備は完了です!

熊出没情報をマッピングする

では、データセットから熊情報の座標を取得してマーカーやサークルを配置していきます。

ProviderScope を追加

RiverPod を利用するには、アプリを ProviderScope で包むのを忘れないようにしましょう。

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

熊マーカーを作成

Jigマーカーと同様に熊マーカーを作成します:

import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:kumap/data/kuma.dart';
import 'package:latlong2/latlong.dart';
import 'package:url_launcher/url_launcher.dart';

class KumaMarker extends Marker {
  static const size = 24.0;

  KumaMarker(this.kuma)
      : super(
          height: KumaMarker.size,
          width: KumaMarker.size,
          point: LatLng(kuma.latitude, kuma.longitude),
          child: GestureDetector(
            // タップで詳細ページを開く
            onTap: () => launchUrl(Uri.parse(kuma.url)),
            child: Image.asset(
              // 目撃か痕跡かでアイコン出し分け
              kuma.type == '目撃情報'
                  ? 'assets/kuma.png'
                  : 'assets/ashiato.png',
            ),
          ),
        );

  final Kuma kuma;
}

class KumaCircleMarker extends CircleMarker {
  KumaCircleMarker(this.kuma)
      : super(
          point: LatLng(kuma.latitude, kuma.longitude),
          radius: 1000,
          useRadiusInMeter: true,
          color: Colors.red.withOpacity(0.1),
        );

  final Kuma kuma;
}

熊マーカーを設置

// RiverPod を使うため ConsumerWidget に置き換える
class KumapPage extends ConsumerWidget {
  const KumapPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 熊出没情報を参照 
    // データが読み込み終わったタイミングでリビルドされマッピングされる
    final kumaList = switch (ref.watch(kumaListProvider)) {
      AsyncData(:final value) => value,
      _ => <Kuma>[],
    };

    return Scaffold(
      body: FlutterMap(
        options: const MapOptions(
          initialCenter: JigMarker.pos,
          initialZoom: 14,
          minZoom: 9,
          maxZoom: 18,
        ),
        children: [
          TileLayer(
            urlTemplate: 'https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg',
          ),
          MarkerLayer(
            markers: [
              JigMarker(),
              ...kumaList.map(KumaMarker.new),
            ],
          ),
          CircleLayer(
            circles: [
              JigCircleMarker(),
              ...kumaList.map(KumaCircleMarker.new),
            ],
          ),
          RichAttributionWidget(
            attributions: [
              TextSourceAttribution(
                '国土地理院',
                onTap: () => launchUrl(Uri.parse('https://maps.gsi.go.jp/development/ichiran.html')),
              ),
              const TextSourceAttribution('Landsat8画像(GSI,TSIC,GEO Grid/AIST)'),
              const TextSourceAttribution('Landsat8画像(courtesy of the U.S. Geological Survey)'),
              const TextSourceAttribution('海底地形(GEBCO)'),
              TextSourceAttribution(
                'クマ出没情報 – めがねのまちさばえ 鯖江市',
                onTap: () => launchUrl(Uri.parse('https://www.city.sabae.fukui.jp/anzen_anshin/chojuhigaitaisaku/kuma-taisaku/kumasyutubotu/index.html')),
              ),
              TextSourceAttribution(
                'くまアイコン - ICOOON MONO',
                onTap: () => launchUrl(Uri.parse('https://icooon-mono.com/')),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

オフィス周辺の熊の目撃情報と痕跡情報をマッピングすることができました 🎉
出典もばっちりくま 🐻

今のところは...安全...かな...( ˘ω˘)

まとめ

この記事では flutter_map を使って簡単なマッピングアプリを Flutter で実装することを学びました。
ハッカソンなどで簡単な地図アプリを作ってみようとなった際には是非、参考になさってください 🗾🐻

この記事が気に入ったらサポートをしてみませんか?