google_maps_flutterとflusterでマーカーをクラスタリングする

FlutterでGoogleマップを表示するのに[google_maps_flutter](https://pub.dev/packages/google_maps_flutter)を使いつつ、[googlemaps/v3-utility-library](https://github.com/googlemaps/v3-utility-library)のMarkerClustererのような感じで、各マーカーをクラスタリングしてマーカー数を表示したかったので、[alfonsocejudo/fluster](https://github.com/alfonsocejudo/fluster)を使ってみた。
日本語のドキュメントが少なそうだったので、やったことをメモしておく。

<img width="375" alt="Simulator Screen Shot - iPhone 7 - 2019-10-07 at 20.04.18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34515/2b7464ec-80e7-d86d-4094-b42949e12d83.png">

### Github
[renoinn/map_fluster_sample](https://github.com/renoinn/map_fluster_sample)

### pubspec.yaml
```yaml

  google_maps_flutter: ^0.5.21
  location: ^2.3.5
  fluster: ^1.1.2
```

### ソースコード全体
マップ表示部分のソースコードはこんな感じ。以下、掻い摘んで説明する。

<details><summary>MapPage.dart</summary><div>
```dart

import 'dart:async';
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:fluster/fluster.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:mapsample/MapMarker.dart';

class MapPage extends StatefulWidget {
  const MapPage({Key key}) : super(key: key);

  @override
  _MapPageState createState() => _MapPageState();
}

class _MapPageState extends State<MapPage> {
  Completer<GoogleMapController> _controller = Completer();
  Fluster<MapMarker> _fluster;
  List<MapMarker> _points = List<MapMarker>();
  CameraPosition _currentPosition;
  StreamController<Map<MarkerId, Marker>> _markers = StreamController();
  static final CameraPosition _kInitialPosition = CameraPosition(
    target: LatLng(35.6812405, 139.7649361),
    zoom: 10,
  );

  double mapPixelWidth;
  double mapPixelHeight;

  @override
  void initState() {
    super.initState();
    _createRandomPoints().then((value) async {
      await _createCluster();
      await _updateMarkers();
    });

    _currentPosition = _kInitialPosition;
  }

  @override
  void dispose() {
    super.dispose();
    _markers.close();
  }

  @override
  Widget build(BuildContext context) {
    final Size size = MediaQuery.of(context).size;
    final double ratio = MediaQuery.of(context).devicePixelRatio;
    mapPixelWidth = size.width * ratio;
    mapPixelHeight = size.height * ratio;
    return Scaffold(
      body: Container(
        child: StreamBuilder<Map<MarkerId, Marker>>(
          initialData: {},
          stream: _markers.stream,
          builder: (_, AsyncSnapshot<Map<MarkerId, Marker>> snapshot) {
            return GoogleMap(
              initialCameraPosition: _kInitialPosition,
              mapType: MapType.normal,
              myLocationEnabled: false,
              myLocationButtonEnabled: false,
              rotateGesturesEnabled: false,
              onCameraMove: _onCameraMove,
              onCameraIdle: _onCameraIdle,
              onTap: _onTap,
              onMapCreated: (GoogleMapController controller) {
                _controller.complete(controller);
              },
              gestureRecognizers: <Factory<OneSequenceGestureRecognizer>>[
                Factory<OneSequenceGestureRecognizer>(
                      () => EagerGestureRecognizer(),
                ),
              ].toSet(),
              markers: Set<Marker>.of(snapshot.data.values),
            );
          },
        ),
      ),
    );
  }

  void _onCameraMove(CameraPosition cameraPosition) {
    setState(() {
      _currentPosition = cameraPosition;
    });
  }

  void _onCameraIdle() {
    _updateMarkers();
  }

  void _onTap(LatLng target) {
    _controller.future.then((controller) {
      controller.animateCamera(CameraUpdate.newLatLng(LatLng(target.latitude, target.longitude)));
    });
  }

  Future<void> _createCluster() async {
    _fluster = Fluster<MapMarker>(
        minZoom: 0,
        maxZoom: 21,
        radius: 210,
        extent: 512,
        nodeSize: 64,
        points: _points,
        createCluster: (BaseCluster cluster, double longitude, double latitude) =>
            MapMarker(
                latitude: latitude,
                longitude: longitude,
                isCluster: true,
                clusterId: cluster.id,
                pointsSize: cluster.pointsSize,
                markerId: cluster.id.toString(),
                childMarkerId: cluster.childMarkerId
            )
    );
  }

  Future<void> _updateMarkers() async {
    if (_fluster == null || _points == null) {
      return;
    }

    Map<MarkerId, Marker> markers = Map();
    int zoom = _currentPosition.zoom.round();
    markers = await _createMarkers(_currentPosition.target, zoom);
    _markers.sink.add(markers);
  }

  Future<Map<MarkerId, Marker>> _createMarkers(LatLng location, int zoom) async {
    LatLng northeast = _calculateLatLon(mapPixelWidth, 0);
    LatLng southwest = _calculateLatLon(0, mapPixelHeight);
    var bounds = [
      southwest.longitude,
      southwest.latitude,
      northeast.longitude,
      northeast.latitude
    ];
    List<MapMarker> clusters = _fluster.clusters(bounds, zoom);
    Map<MarkerId, Marker> markers = Map();
    for (MapMarker feature in clusters) {
      final Uint8List markerIcon = await _getBytesFromCanvas(feature);
      BitmapDescriptor bitmapDescriptor = BitmapDescriptor.fromBytes(markerIcon);
      Marker marker = Marker(
          markerId: MarkerId(feature.markerId),
          position: LatLng(feature.latitude, feature.longitude),
          icon: bitmapDescriptor,
      );
      markers.putIfAbsent(MarkerId(feature.markerId), () => marker);
    }
    return markers;
  }

  LatLng _calculateLatLon(x, y) {
    double parallelMultiplier = math.cos(_currentPosition.target.latitude * math.pi / 180);
    double degreesPerPixelX = 360 / math.pow(2, _currentPosition.zoom + 8);
    double degreesPerPixelY = 360 / math.pow(2, _currentPosition.zoom + 8) * parallelMultiplier;
    var lat = _currentPosition.target.latitude - degreesPerPixelY * (y - mapPixelHeight / 2);
    var lng = _currentPosition.target.longitude + degreesPerPixelX * (x  - mapPixelWidth / 2);
    LatLng latLng = LatLng(lat, lng);
    return latLng;
  }

  Future<Uint8List> _getBytesFromCanvas(MapMarker feature) async {
    Color color = Colors.blue[300];
    String text = "1";
    int size = 80;

    if (feature.pointsSize != null) {
      text = feature.pointsSize.toString();
      if (feature.pointsSize >= 100) {
        color = Colors.red[400];
        size = 110;
      } else if (feature.pointsSize >= 10) {
        color = Colors.yellow[600];
        size = 90;
      }
    }

    final ui.PictureRecorder pictureRecorder = ui.PictureRecorder();
    final Canvas canvas = Canvas(pictureRecorder);
    final Paint paint2 = Paint()..color = Colors.white;
    final Paint paint1 = Paint()..color = color;
    canvas.drawCircle(Offset(size / 2, size / 2), size / 3.0, paint2);
    canvas.drawCircle(Offset(size / 2, size / 2), size / 3.3, paint1);
    TextPainter painter = TextPainter(textDirection: TextDirection.ltr);
    painter.text = TextSpan(
      text: text,
      style: TextStyle(
          fontSize: size / 4, color: Colors.black, fontWeight: FontWeight.bold),
    );
    painter.layout();
    painter.paint(
      canvas,
      Offset(size / 2 - painter.width / 2, size / 2 - painter.height / 2),
    );

    final img = await pictureRecorder.endRecording().toImage(size, size);
    final data = await img.toByteData(format: ui.ImageByteFormat.png);
    return data.buffer.asUint8List();
  }

  Future _createRandomPoints() async {
    List<MapMarker> data = [];
    for (double i = 0; i < 300; i++) {
      final String markerId = i.toString();

      math.Random rand = math.Random();
      int negative = rand.nextBool() ? -1 : 1;
      double lat = _kInitialPosition.target.latitude + (rand.nextInt(10000) / 10000 * negative);
      negative = rand.nextBool() ? -1 : 1;
      double lng = _kInitialPosition.target.longitude + (rand.nextInt(10000) / 10000 * negative);
      MapMarker point = MapMarker(
        latitude: lat,
        longitude: lng,
        markerId: markerId
      );
      data.add(point);
    }
    _points = data;
  }
}
```
</div></details>

### データクラス

```dart
import 'package:fluster/fluster.dart';
import 'package:meta/meta.dart';

class MapMarker extends Clusterable {
  MapMarker({
    @required latitude,
    @required longitude,
    isCluster = false,
    clusterId,
    pointsSize,
    markerId,
    childMarkerId,
  }) : super(
    latitude: latitude,
    longitude: longitude,
    isCluster: isCluster,
    clusterId: clusterId,
    pointsSize: pointsSize,
    markerId: markerId,
    childMarkerId: childMarkerId);
}
```
flusterでクラスタリングをするにあたって、まずはClusterableを継承したクラスを作る必要がある。
最低限の実装だと上記のような感じ。他に独自のフィールドを追加することもできる。

### Flusterインスタンス
```dart

Future<void> _createCluster() async {
    _fluster = Fluster<MapMarker>(
        minZoom: 0,
        maxZoom: 21,
        radius: 210,
        extent: 512,
        nodeSize: 64,
        points: _points,
        createCluster: (BaseCluster cluster, double longitude, double latitude) =>
            MapMarker(
                latitude: latitude,
                longitude: longitude,
                isCluster: true,
                clusterId: cluster.id,
                pointsSize: cluster.pointsSize,
                markerId: cluster.id.toString(),
                childMarkerId: cluster.childMarkerId
            )
    );
  }
```
次にクラスタリングを行ってくれるflusterインスタンスを作る。

| パラメータ | 説明 |
| :----------------- | :------------------ |
| minZoom | マップの最小ズームに合わせる |
| maxZoom | マップの最大ズームに合わせる |
| radius | クラスタリングを行う範囲 |
| extent | クラスタリング後のマーカー数に関係してそう。必ず2の指数で指定する |
| nodeSize | あまりちゃんと理解してないけどパフォーマンスに影響してるっぽい |
| points | クラスタリングさせたい緯度経度のリスト。List<Clusterable> |

### マップへの表示
```dart

Future<void> _updateMarkers() async {
    if (_fluster == null || _points == null) {
      return;
    }

    Map<MarkerId, Marker> markers = Map();
    int zoom = _currentPosition.zoom.round();
    markers = await _createMarkers(_currentPosition.target, zoom);
    _markers.sink.add(markers);
  }

  Future<Map<MarkerId, Marker>> _createMarkers(LatLng location, int zoom) async {
    LatLng northeast = _calculateLatLon(mapPixelWidth, 0);
    LatLng southwest = _calculateLatLon(0, mapPixelHeight);
    var bounds = [
      southwest.longitude,
      southwest.latitude,
      northeast.longitude,
      northeast.latitude
    ];
    List<MapMarker> clusters = _fluster.clusters(bounds, zoom);
    Map<MarkerId, Marker> markers = Map();
    for (MapMarker feature in clusters) {
      final Uint8List markerIcon = await _getBytesFromCanvas(feature);
      BitmapDescriptor bitmapDescriptor = BitmapDescriptor.fromBytes(markerIcon);
      Marker marker = Marker(
          markerId: MarkerId(feature.markerId),
          position: LatLng(feature.latitude, feature.longitude),
          icon: bitmapDescriptor,
      );
      markers.putIfAbsent(MarkerId(feature.markerId), () => marker);
    }
    return markers;
  }
```
\_fluster.clusters()でクラスタリングされたMapMarkerのリストを受け取って、MapMarker毎にMarkerを作り、それを_markersのstreamに流している。
\_fluster.clusters()で受け取るboundsはそれぞれ、
`[南東の経度, 南東の緯度, 北西の経度, 北西の緯度]`でflusterのサンプルだと`[-180, -85, 180, 85]`になっている。
このままだと画面上見えてないところにもマーカーが全部置かれることになる。少ないうちは大丈夫だが、マーカー数は100個を超えたあたりからマップの表示がもっさりし始めて、300以上になるとかなり重い。
なので、地図の表示サイズと現在位置から、\_fluster.clusters()で作成するマーカーを見えてる範囲のだけに限定する。

```dart
  ...
  double mapPixelWidth;
  double mapPixelHeight;

  @override
  Widget build(BuildContext context) {
    final Size size = MediaQuery.of(context).size;
    final double ratio = MediaQuery.of(context).devicePixelRatio;
    mapPixelWidth = size.width * ratio;
    mapPixelHeight = size.height * ratio;
    ...
  }
```
こんな感じで、devicePixelRatioを使ってピクセル換算できる。今回は画面全体に表示しているので、size.widthとsize.heightにそのまま掛けている。

```dart

  LatLng _calculateLatLon(x, y) {
    double parallelMultiplier = math.cos(_currentPosition.target.latitude * math.pi / 180);
    double degreesPerPixelX = 360 / math.pow(2, _currentPosition.zoom + 8);
    double degreesPerPixelY = 360 / math.pow(2, _currentPosition.zoom + 8) * parallelMultiplier;
    var lat = _currentPosition.target.latitude - degreesPerPixelY * (y - mapPixelHeight / 2);
    var lng = _currentPosition.target.longitude + degreesPerPixelX * (x  - mapPixelWidth / 2);
    LatLng latLng = LatLng(lat, lng);
    return latLng;
  }
```
こっちが計算式。

### マーカー画像の生成

```dart
Future<Uint8List> _getBytesFromCanvas(MapMarker feature) async {
    Color color = Colors.blue[300];
    String text = "1";
    int size = 80;

    if (feature.pointsSize != null) {
      text = feature.pointsSize.toString();
      if (feature.pointsSize >= 100) {
        color = Colors.red[400];
        size = 110;
      } else if (feature.pointsSize >= 10) {
        color = Colors.yellow[600];
        size = 90;
      }
    }

    final ui.PictureRecorder pictureRecorder = ui.PictureRecorder();
    final Canvas canvas = Canvas(pictureRecorder);
    final Paint paint2 = Paint()..color = Colors.white;
    final Paint paint1 = Paint()..color = color;
    canvas.drawCircle(Offset(size / 2, size / 2), size / 3.0, paint2);
    canvas.drawCircle(Offset(size / 2, size / 2), size / 3.3, paint1);
    TextPainter painter = TextPainter(textDirection: TextDirection.ltr);
    painter.text = TextSpan(
      text: text,
      style: TextStyle(
          fontSize: size / 4, color: Colors.black, fontWeight: FontWeight.bold),
    );
    painter.layout();
    painter.paint(
      canvas,
      Offset(size / 2 - painter.width / 2, size / 2 - painter.height / 2),
    );

    final img = await pictureRecorder.endRecording().toImage(size, size);
    final data = await img.toByteData(format: ui.ImageByteFormat.png);
    return data.buffer.asUint8List();
  }
```
クラスタリング後に要素の数を表示したいので、マーカー用の画像を生成している。\_fluster.cluster()で生成されたMapMarkerのpointSizeに要素数が入っているので、Canvasを使って数字を描写する。

### おわり
ざっくりとこんな感じでマップ上にクラスタリングされたマーカーを置くことができる。GoogleのSDKとはロジックが違うため、全く同じようにはならないが、現状公式のgoogle_maps_flutterではクラスタリングがサポートされていないので、flusterを使うのが一番手っ取り早いかなと思ってる。
あとは、毎回クラスタを作るんじゃなくてzoomが変わってない場合はキャッシュさせておくとかすると良いかも。