alfonsocejudo / fluster

A geospatial point clustering library for Dart.
MIT License
68 stars 25 forks source link

Fluster - performance issues #7

Open Marek00Malik opened 4 years ago

Marek00Malik commented 4 years ago


First of all good work with the lib and providing an alternative for clustering. I've followed your example with a bloc provider and tried to adapt it to my use case. One major difference is that in my scenario I have over 700 items that need to be displayed on the map (a perfect case for clustering :)). The data is passed from an http service and filtered based on some conditions that can change outside the map. That's why I use a provider to reload the map when data changes (the data can change based on the filtering). My markers have different marker icons depending on the item type, the cluster icon has a number - children count that are under the parent. For this reason, I needed to make the function that creates markers an async function (because of the drawing of cluster icons to be exact).

I observed that with this amount of items my app has performance issues (it recalculates the cluster markers on each zoom or camera position change). Because I'm using async functions, this adds extra overlap to the overall process.

There is the demo: https://drive.google.com/open?id=19USypvWS0KIRF7J1-42mSHVYV9BfoMkr

Marek00Malik commented 4 years ago

My Widget:


  final Stream<EventAction> listEvents;

  MapWidget({@required this.listEvents});

  @override
  State<StatefulWidget> createState() => MapWidgetState();
}

class MapWidgetState extends State<MapWidget> {
  Fluster<PlaceMarker> _fluster;

  List<PlaceMarker> _mapMarkers = List();

  GoogleMapController _controller;

  @override
  Widget build(BuildContext context) {
    return Consumer2<RestaurantsData, LocationProvider>(
      builder: (innerContext, restaurantsData, locationProvider,__) {
        if (locationProvider.locationNotSet || restaurantsData.dataNotLoaded) {
          locationProvider
              .loadGpsPosition()
              .then((location) => moveCameraTo(location))
              .then((location) => Geolocator().placemarkFromCoordinates(location.currentPosition.latitude, location.currentPosition.longitude, localeIdentifier: "pl_PL"))
              .then((addresses) => LocationProvider.handleAddressResponse(context, addresses))
              .catchError((onError) => LocationProvider.handleError(onError));

          return _buildMapDetails(innerContext);
        }

        _buildFluster(restaurantsData);
        return _buildMapDetails(innerContext);
      },
    );
  }

  Widget _buildMapDetails(BuildContext context) {
    return FutureBuilder<Set<Marker>>(
        future: mapMarkers(),
        builder: (_, AsyncSnapshot<Set<Marker>> snapshop) {
          if (ConnectionState.done == snapshop.connectionState) {
            _onLoadingContent();
          }

          return Stack(
            children: <Widget>[
              Scaffold(
                body: _googleMaps(ConnectionState.done == snapshop.connectionState ? snapshop.data : Set()),
                floatingActionButton: Padding(
                  padding: EdgeInsets.only(top: 20),
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.start,
                    children: <Widget>[
                      SearchForCityFab(controller: _controller),
                      ZoomFab(
                        fabTag: "zoom_out",
                        icon: Icons.remove,
                        onPressed: () {
                          _controller.animateCamera(CameraUpdate.zoomOut());
                        },
                      ),
                      ZoomFab(
                        fabTag: "zoom_in",
                        icon: Icons.add,
                        onPressed: () {
                          _controller.animateCamera(CameraUpdate.zoomIn());
                        },
                      ),
                      MyLocationFab(controller: _controller)
                    ],
                  ),
                ),
                floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
              ),
              Row(
                children: <Widget>[
                  Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    mainAxisAlignment: MainAxisAlignment.start,
                    mainAxisSize: MainAxisSize.min,
                    children: <Widget>[
                      FilterByCityNotificationWidget(),
                      FilterByNameAddressNotificationWidget(),
                      FilterByTypesNotificationWidget(),
                    ],
                  ),
                ],
              ),
            ],
          );
        });
  }

  Widget _googleMaps(Set<Marker> restaurantMarkers) {
    LocationProvider locationProvider = Provider.of<LocationProvider>(context, listen: false);
    return GoogleMap(
      mapType: MapType.normal,
      myLocationEnabled: true,
      myLocationButtonEnabled: false,
      mapToolbarEnabled: false,
      zoomGesturesEnabled: true,
      rotateGesturesEnabled: true,
      initialCameraPosition: CameraPosition(
        zoom: locationProvider.currentZoom,
        target: locationProvider.currentPosition,
      ),
      onMapCreated: (GoogleMapController controller) => _controller = controller,
      markers: restaurantMarkers,
      onCameraMove: (CameraPosition cameraPosition) {
        if (locationProvider.currentZoom != cameraPosition.zoom) locationProvider.setZoom(cameraPosition.zoom, notify: true);
        if (locationProvider.currentPosition != cameraPosition.target) locationProvider.setPosition(cameraPosition.target);
      },
      gestureRecognizers: _gestureRecognizers,
    );
  }

  void _buildFluster(RestaurantsData restaurantsData) {
    logger.d("Creating map markers");
    var restaurantItems = restaurantsData
        .getRestaurants()
        .map((item) => PlaceMarker(
              item: item,
              itemId: item.id,
              name: item.name,
              location: item.latLng,
              onTap: () async {
                logger.i("Marker clicked: ${item.id}, ${item.name}");
                showRestaurantDetails(item);
                restaurantsData.selectOne(item);
                _controller.animateCamera(CameraUpdate.newLatLng(item.latLng));
              },
            ))
        .toList();

    _mapMarkers
      ..clear()
      ..addAll(restaurantItems);
    logger.d("Fluster Markers has been initialized!");

      _fluster = Fluster<PlaceMarker>(
        minZoom: 5,
        maxZoom: 20,
        radius: 450,
        extent: 2048,
        nodeSize: 64,
        points: _mapMarkers,
        createCluster: (BaseCluster cluster, double lng, double lat) => PlaceMarker(
          itemId: cluster.id,
          location: LatLng(lat, lng),
          isCluster: cluster.isCluster,
          clusterId: cluster.id,
          pointsSize: cluster.pointsSize,
          childMarkerId: cluster.childMarkerId,
        ),
      );
    logger.d("Fluster has been initialized!");
  }

  Future<Set<Marker>> mapMarkers() async {
    var mapMarker = Provider.of<MapMarkerProvider>(context, listen: false);
    var locationProvider = Provider.of<LocationProvider>(context, listen: false);
    logger.d("Building Google Map Markers for current zoom -> ${locationProvider.currentZoom}");

    var clusters = this._fluster.clusters([-180, -85, 180, 85], locationProvider.currentZoom.truncate());
    Set<Marker> clusterMarkers = HashSet();
    for (PlaceMarker clusteredPlaces in clusters) {
      BitmapDescriptor icon = clusteredPlaces.isCluster ? await _getClusterBitmap(clusteredPlaces.pointsSize.toString()) : mapMarker.forObject(clusteredPlaces.item);

      Marker marker = Marker(
        markerId: MarkerId("${clusteredPlaces.itemId}"),
        position: clusteredPlaces.location,
        icon: icon,
        onTap: clusteredPlaces.onTap,
      );

      clusterMarkers.add(marker);
    }

    logger.d("Finished, build ${clusterMarkers.length} Map Markers.");
    return clusterMarkers;
  }

  Future<BitmapDescriptor> _getClusterBitmap(String count) async {
    int size = 125;

    final PictureRecorder pictureRecorder = PictureRecorder();
    final Canvas canvas = Canvas(pictureRecorder);
    final Paint paint1 = Paint()..color = CustomColor.green;
    final Paint paint2 = Paint()..color = Colors.white;

    canvas.drawCircle(Offset(size / 2, size / 2), size / 2.0, paint1);
    canvas.drawCircle(Offset(size / 2, size / 2), size / 2.2, paint2);
    canvas.drawCircle(Offset(size / 2, size / 2), size / 2.8, paint1);
    TextPainter painter = TextPainter(textDirection: TextDirection.ltr);
    painter.text = TextSpan(
      text: count,
      style: TextStyle(fontSize: size / 3, color: Colors.white, fontWeight: FontWeight.normal),
    );
    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: ImageByteFormat.png);
    return BitmapDescriptor.fromBytes(data.buffer.asUint8List());
  }

  LocationProvider moveCameraTo(LocationProvider location) {
    _controller?.animateCamera(CameraUpdate.newLatLngZoom(location.currentPosition, location.currentZoom));
    return location;
  }
}

```
Marek00Malik commented 4 years ago

Also, a little issue that I found it that I need to instantiate the Fluster instance with data already, with one possibility to update the instance later. This is quite a bit obstacle if you would like to manage the state properly and not rebuild the entire widget from scratch.

badrobot15 commented 4 years ago

I've been working with over 8000 items to be displayed on the map and I have to say I'm pretty envious of lesser lag you are facing.

One way I managed to optimize loading of the clusters was here at this line:

var clusters = this._fluster.clusters([-180, -85, 180, 85], locationProvider.currentZoom.truncate());

I replaced the [-180, -85, 180, 85] with only the currently visible region on the map using mapController.getVisibleRegion()

So essentially, you would be fetching and processing only those clusters that are currently visible to the user on the map. When the user moves the maps camera, I'm calling fetchClusters() again with an updated visible region.

Marek00Malik commented 4 years ago

Oo that is a good idea, but doesn't your phone get overheated? With these 700 items, the phone is quickly starting to burn (I'm using Pixel XL and iPhone xs).

Also, I observe that when having items very need each other it would be nice to have the clustering stop working when reaching some zoom values, like above 18 clusterings would just leave it to google maps.

badrobot15 commented 4 years ago

I did not face an overheating issue but I did face terrible lag. I added an if condition too that checks the current zoom level and displays the clusters only if it is under a certain zoom level. Yet it was still slow.

I'm not an expert but from what I understood, the K-D tree algorithm at the heart of the Fluster package essentially works on two things - searching and indexing. Indexing is what shows the clusters and searching is when you zoom down to a single marker. They are decided by 'extent' and 'nodeSize' parameters of fluster. The default values are like a sweet spot. These two parameters can be tweaked at the expense of each other i.e. increasing the indexing efficiency makes the searching bad and increasing the searching efficiency makes me the indexing bad. (I guess. Like I said I'm not an expert). I managed to reduce the lag by drastically increasing the 'extent' parameter value and decreasing 'nodeSize' value but that in turn resulted in me waiting for a couple of seconds for the marker to show up once I zoom down. You could probably look into this.

At the end, I gave up on using Fluster because for 8000 items I just could not reduce the lag regardless of what hack I tried. Good luck!

vogttho commented 4 years ago

I had the same problem. Using onCameraIdle instead of onCameraMove() to update map and only update visible region solved the problem for me

GoogleMap( mapType: mapType, initialCameraPosition: _kGooglePlex, markers: _markers, onMapCreated: (GoogleMapController controller) { _controller.complete(controller); _googleMapController = controller; setState(() { _isMapLoading = false; }); initData(); }, onCameraMove: (position) { if (position.zoom != null) { _currentZoom = position.zoom; } }, onCameraIdle:() => _updateMarkers() , ),

thedalelakes commented 10 months ago

I'm using something like this:

  int _calculateExtent(double zoom) {
    if (zoom < 15) {
      return 512;
    }
    return 2048;
  }

  int _calculateNodeSize(double zoom) {
    if (zoom < 15) {
      return 256;
    }
    return 64;
  }

Not perfect. Still tinkering with the values. Will probably have more steps at various zoom levels. But the relationship between extent and nodeSize is definitely the right approach from my testing.