LoginSignup
10
6

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-10-07

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

Simulator Screen Shot - iPhone 7 - 2019-10-07 at 20.04.18.png

Github

renoinn/map_fluster_sample

pubspec.yaml


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

ソースコード全体

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

MapPage.dart

```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 {
Completer _controller = Completer();
Fluster _fluster;
List _points = List();
CameraPosition _currentPosition;
StreamController> _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>(
initialData: {},
stream: markers.stream,
builder: (
, AsyncSnapshot> 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(
() => EagerGestureRecognizer(),
),
].toSet(),
markers: Set.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 _createCluster() async {
_fluster = Fluster(
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 _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> _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 clusters = _fluster.clusters(bounds, zoom);
Map 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 _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 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;

}
}
```

データクラス

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インスタンス


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

マップへの表示


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()で作成するマーカーを見えてる範囲のだけに限定する。

  ...
  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にそのまま掛けている。


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

クラスタリング後に要素の数を表示したいので、マーカー用の画像を生成している。_fluster.cluster()で生成されたMapMarkerのpointSizeに要素数が入っているので、Canvasを使って数字を描写する。

おわり

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

10
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6