Repository: bpillon/google_maps_cluster_manager

Issue:
How to customize the calculation of clusters?
Google Map cluster API supports custom calculate function to group markers. How can it be possible in this lib?


Discussion: Currently there is only algorithm (based on geohash) on this lib but feel free to implements others and make a PR.

Fix patch:
diff --git a/lib/src/cluster.dart b/lib/src/cluster.dart
index 236fb7f..4b3d849 100644
--- a/lib/src/cluster.dart
+++ b/lib/src/cluster.dart
@@ -5,13 +5,27 @@ class Cluster<T extends ClusterItem> {
   final LatLng location;
   final Iterable<T> items;
 
-  Cluster(this.items)
-      : this.location = LatLng(
+  Cluster(this.items, this.location);
+
+  Cluster.fromItems(Iterable<T> items)
+      : this.items = items,
+        this.location = LatLng(
             items.fold<double>(0.0, (p, c) => p + c.location.latitude) /
                 items.length,
             items.fold<double>(0.0, (p, c) => p + c.location.longitude) /
                 items.length);
 
+  //location becomes weighted avarage lat lon
+  Cluster.fromClusters(Cluster<T> cluster1, Cluster<T> cluster2)
+      : this.items = cluster1.items.toSet()..addAll(cluster2.items.toSet()),
+        this.location = LatLng(
+            (cluster1.location.latitude * cluster1.count +
+                    cluster2.location.latitude * cluster2.count) /
+                (cluster1.count + cluster2.count),
+            (cluster1.location.longitude * cluster1.count +
+                    cluster2.location.longitude * cluster2.count) /
+                (cluster1.count + cluster2.count));
+
   /// Get number of clustered items
   int get count => items.length;
 
@@ -30,4 +44,7 @@ class Cluster<T extends ClusterItem> {
   String toString() {
     return 'Cluster of $count $T (${location.latitude}, ${location.longitude})';
   }
+
+  bool operator ==(o) => o is Cluster && items == o.items;
+  int get hashCode => items.hashCode;
 }
diff --git a/lib/src/cluster_manager.dart b/lib/src/cluster_manager.dart
index f7b09d9..d2e17e8 100644
--- a/lib/src/cluster_manager.dart
+++ b/lib/src/cluster_manager.dart
@@ -5,13 +5,25 @@ import 'dart:ui';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:google_maps_cluster_manager/google_maps_cluster_manager.dart';
+import 'package:google_maps_cluster_manager/src/max_dist_clustering.dart';
 import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart';
 
+enum ClusterAlgorithm { GEOHASH, MAX_DIST }
+
+class MaxDistParams {
+  final double epsilon;
+
+  MaxDistParams(this.epsilon);
+}
+
 class ClusterManager<T extends ClusterItem> {
   ClusterManager(this._items, this.updateMarkers,
       {Future<Marker> Function(Cluster<T>)? markerBuilder,
       this.levels = const [1, 4.25, 6.75, 8.25, 11.5, 14.5, 16.0, 16.5, 20.0],
       this.extraPercent = 0.5,
+      this.maxItemsForMaxDistAlgo = 200,
+      this.clusterAlgorithm = ClusterAlgorithm.GEOHASH,
+      this.maxDistParams,
       this.stopClusteringZoom})
       : this.markerBuilder = markerBuilder ?? _basicMarkerBuilder,
         assert(levels.length <= precision);
@@ -19,6 +31,9 @@ class ClusterManager<T extends ClusterItem> {
   /// Method to build markers
   final Future<Marker> Function(Cluster<T>) markerBuilder;
 
+  // Num of Items to switch from MAX_DIST algo to GEOHASH
+  final int maxItemsForMaxDistAlgo;
+
   /// Function to update Markers on Google Map
   final void Function(Set<Marker>) updateMarkers;
 
@@ -28,6 +43,11 @@ class ClusterManager<T extends ClusterItem> {
   /// Extra percent of markers to be loaded (ex : 0.2 for 20%)
   final double extraPercent;
 
+  // Clusteringalgorithm
+  final ClusterAlgorithm clusterAlgorithm;
+
+  final MaxDistParams? maxDistParams;
+
   /// Zoom level to stop cluster rendering
   final double? stopClusteringZoom;
 
@@ -94,20 +114,32 @@ class ClusterManager<T extends ClusterItem> {
     final LatLngBounds mapBounds = await GoogleMapsFlutterPlatform.instance
         .getVisibleRegion(mapId: _mapId!);
 
-    final LatLngBounds inflatedBounds = _inflateBounds(mapBounds);
+    late LatLngBounds inflatedBounds;
+    if (clusterAlgorithm == ClusterAlgorithm.GEOHASH) {
+      inflatedBounds = _inflateBounds(mapBounds);
+    } else {
+      inflatedBounds = mapBounds;
+    }
 
     List<T> visibleItems = items.where((i) {
       return inflatedBounds.contains(i.location);
     }).toList();
 
     if (stopClusteringZoom != null && _zoom >= stopClusteringZoom!)
-      return visibleItems.map((i) => Cluster<T>([i])).toList();
-
-    int level = _findLevel(levels);
-    List<Cluster<T>> markers = _computeClusters(
-        visibleItems, List.empty(growable: true),
-        level: level);
-    return markers;
+      return visibleItems.map((i) => Cluster<T>.fromItems([i])).toList();
+
+    if (clusterAlgorithm == ClusterAlgorithm.GEOHASH ||
+        visibleItems.length >= maxItemsForMaxDistAlgo) {
+      int level = _findLevel(levels);
+      List<Cluster<T>> markers = _computeClusters(
+          visibleItems, List.empty(growable: true),
+          level: level);
+      return markers;
+    } else {
+      List<Cluster<T>> markers =
+          _computeClustersWithMaxDist(visibleItems, _zoom);
+      return markers;
+    }
   }
 
   LatLngBounds _inflateBounds(LatLngBounds bounds) {
@@ -146,18 +178,36 @@ class ClusterManager<T extends ClusterItem> {
     return 1;
   }
 
+  int _getZoomLevel(double zoom) {
+    for (int i = levels.length - 1; i >= 0; i--) {
+      if (levels[i] <= zoom) {
+        return levels[i].toInt();
+      }
+    }
+
+    return 1;
+  }
+
+  List<Cluster<T>> _computeClustersWithMaxDist(
+      List<T> inputItems, double zoom) {
+    MaxDistClustering<T> scanner = MaxDistClustering(
+      epsilon: maxDistParams?.epsilon ?? 20,
+    );
+
+    return scanner.run(inputItems, _getZoomLevel(zoom));
+  }
+
   List<Cluster<T>> _computeClusters(
       List<T> inputItems, List<Cluster<T>> markerItems,
       {int level = 5}) {
     if (inputItems.isEmpty) return markerItems;
-
     String nextGeohash = inputItems[0].geohash.substring(0, level);
 
     List<T> items = inputItems
         .where((p) => p.geohash.substring(0, level) == nextGeohash)
         .toList();
 
-    markerItems.add(Cluster<T>(items));
+    markerItems.add(Cluster<T>.fromItems(items));
 
     List<T> newInputList = List.from(
         inputItems.where((i) => i.geohash.substring(0, level) != nextGeohash));
diff --git a/lib/src/common.dart b/lib/src/common.dart
new file mode 100644
index 0000000..d8e14bf
--- /dev/null
+++ b/lib/src/common.dart
@@ -0,0 +1,132 @@
+import 'dart:math';
+import 'dart:ui';
+
+import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart';
+
+import 'cluster.dart';
+
+const int _mio = 1000000;
+const int _thd = 1000;
+const double _PI = 3.141592653589793238;
+
+class _Tuple {
+  final LatLng pos1;
+  final LatLng pos2;
+
+  _Tuple(this.pos1, this.pos2);
+
+  bool operator ==(o) => o is _Tuple && pos1 == o.pos1 && pos2 == o.pos2;
+  int get hashCode => pos1.hashCode + pos2.hashCode;
+}
+
+class DistUtils {
+// Zoom-Level:
+// Level	# Tiles	Tile width
+// (° of longitudes)	m / pixel
+// (on Equator)	~ Scale
+// (on screen)	Examples of
+// areas to represent
+// 0	1	360	156 412	1:500 million	whole world
+// 1	4	180	78 206	1:250 million
+// 2	16	90	39 103	1:150 million	subcontinental area
+// 3	64	45	19 551	1:70 million	largest country
+// 4	256	22.5	9 776	1:35 million
+// 5	1 024	11.25	4 888	1:15 million	large African country
+// 6	4 096	5.625	2 444	1:10 million	large European country
+// 7	16 384	2.813	1 222	1:4 million	small country, US state
+// 8	65 536	1.406	610.984	1:2 million
+// 9	262 144	0.703	305.492	1:1 million	wide area, large metropolitan area
+// 10	1 048 576	0.352	152.746	1:500 thousand	metropolitan area
+// 11	4 194 304	0.176	76.373	1:250 thousand	city
+// 12	16 777 216	0.088	38.187	1:150 thousand	town, or city district
+// 13	67 108 864	0.044	19.093	1:70 thousand	village, or suburb
+// 14	268 435 456	0.022	9.547	1:35 thousand
+// 15	1 073 741 824	0.011	4.773	1:15 thousand	small road
+// 16	4 294 967 296	0.005	2.387	1:8 thousand	street
+// 17	17 179 869 184	0.003	1.193	1:4 thousand	block, park, addresses
+// 18	68 719 476 736	0.001	0.596	1:2 thousand	some buildings, trees
+// 19	274 877 906 944	0.0005	0.298	1:1 thousand	local highway and crossing details
+// 20	1 099 511 627 776	0.00025	0.149	1:5 hundred	A mid-sized building
+  final Map<_Tuple, double> distCache = {};
+
+  double getLatLonDist(LatLng point1, LatLng point2, int zoomLevel) {
+    if (distCache[_Tuple(point1, point2)] != null) {
+      return distCache[_Tuple(point1, point2)]!;
+    }
+    double meterPerPixel = _getScalingFactor(zoomLevel);
+    double dist = getDistanceFromLatLonInKm(point1.latitude, point1.longitude,
+            point2.latitude, point2.longitude) /
+        (meterPerPixel / _thd);
+    // print("dist is $x");
+    distCache[_Tuple(point1, point2)] = dist;
+    return dist;
+  }
+
+  double getDistanceFromLatLonInKm(
+      double lat1, double lon1, double lat2, double lon2) {
+    var R = 6371; // Radius of the earth in km
+    var dLat = _degreeToRadian(lat2 - lat1);
+    var dLon = _degreeToRadian(lon2 - lon1);
+    var a = sin(dLat / 2) * sin(dLat / 2) +
+        cos(_degreeToRadian(lat1)) *
+            cos(_degreeToRadian(lat2)) *
+            sin(dLon / 2) *
+            sin(dLon / 2);
+    var c = 2 * atan2(sqrt(a), sqrt(1 - a));
+    var d = R * c; // Distance in km
+    return d;
+  }
+
+  double _degreeToRadian(double degree) {
+    return degree * _PI / 180;
+  }
+
+  double _getScalingFactor(int zoomLevel) {
+    switch (zoomLevel) {
+      case 0:
+        return 156412;
+      case 1:
+        return 78206;
+      case 2:
+        return 39103;
+      case 3:
+        return 19551;
+      case 4:
+        return 9776;
+      case 5:
+        return 4888;
+      case 6:
+        return 2444;
+      case 7:
+        return 1222;
+      case 8:
+        return 610.984;
+      case 9:
+        return 305.492;
+      case 10:
+        return 152.746;
+      case 11:
+        return 76.373;
+      case 12:
+        return 38.187;
+      case 13:
+        return 19.093;
+      case 14:
+        return 9.547;
+      case 15:
+        return 4.773;
+      case 16:
+        return 2.387;
+      case 17:
+        return 1.193;
+      case 18:
+        return 0.596;
+      case 19:
+        return 0.298;
+      case 20:
+        return 0.149;
+      default:
+        return 0.149;
+    }
+  }
+}
diff --git a/lib/src/max_dist_clustering.dart b/lib/src/max_dist_clustering.dart
new file mode 100644
index 0000000..ebe119a
--- /dev/null
+++ b/lib/src/max_dist_clustering.dart
@@ -0,0 +1,68 @@
+import '../google_maps_cluster_manager.dart';
+import 'common.dart';
+
+class _MinDistCluster<T extends ClusterItem> {
+  final Cluster<T> cluster;
+  final double dist;
+
+  _MinDistCluster(this.cluster, this.dist);
+}
+
+class MaxDistClustering<T extends ClusterItem> {
+  ///Complete list of points
+  late List<T> dataset;
+
+  List<Cluster<T>> _cluster = [];
+
+  ///Threshold distance for two clusters to be considered as one cluster
+  final double epsilon;
+
+  final DistUtils distUtils = DistUtils();
+
+  MaxDistClustering({
+    this.epsilon = 1,
+  });
+
+  ///Run clustering process, add configs in constructor
+  List<Cluster<T>> run(List<T> dataset, int zoomLevel) {
+    this.dataset = dataset;
+
+    //initial variables
+    List<List<double>> distMatrix = [];
+    for (T entry1 in dataset) {
+      distMatrix.add([]);
+      _cluster.add(Cluster.fromItems([entry1]));
+    }
+    bool changed = true;
+    while (changed) {
+      changed = false;
+      for (Cluster<T> c in _cluster) {
+        _MinDistCluster<T>? minDistCluster = getClosestCluster(c, zoomLevel);
+        // print("mindistcluster ${minDistCluster?.dist}");
+        if (minDistCluster == null || minDistCluster.dist > epsilon) continue;
+        _cluster.add(Cluster.fromClusters(minDistCluster.cluster, c));
+        _cluster.remove(c);
+        _cluster.remove(minDistCluster.cluster);
+        changed = true;
+
+        break;
+      }
+    }
+    return _cluster;
+  }
+
+  _MinDistCluster<T>? getClosestCluster(Cluster cluster, int zoomLevel) {
+    double minDist = 1000000000;
+    Cluster<T> minDistCluster = Cluster.fromItems([]);
+    for (Cluster<T> c in _cluster) {
+      if (c.location == cluster.location) continue;
+      double tmp =
+          distUtils.getLatLonDist(c.location, cluster.location, zoomLevel);
+      if (tmp < minDist) {
+        minDist = tmp;
+        minDistCluster = Cluster<T>.fromItems(c.items);
+      }
+    }
+    return _MinDistCluster(minDistCluster, minDist);
+  }
+}


Test patch:
diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart
index 747db1d..f4cdd49 100644
--- a/example/test/widget_test.dart
+++ b/example/test/widget_test.dart
@@ -11,20 +11,20 @@ import 'package:flutter_test/flutter_test.dart';
 import 'package:example/main.dart';
 
 void main() {
-  testWidgets('Counter increments smoke test', (WidgetTester tester) async {
-    // Build our app and trigger a frame.
-    await tester.pumpWidget(MyApp());
+  // testWidgets('Counter increments smoke test', (WidgetTester tester) async {
+  //   // Build our app and trigger a frame.
+  //   await tester.pumpWidget(MyApp());
 
-    // Verify that our counter starts at 0.
-    expect(find.text('0'), findsOneWidget);
-    expect(find.text('1'), findsNothing);
+  //   // Verify that our counter starts at 0.
+  //   expect(find.text('0'), findsOneWidget);
+  //   expect(find.text('1'), findsNothing);
 
-    // Tap the '+' icon and trigger a frame.
-    await tester.tap(find.byIcon(Icons.add));
-    await tester.pump();
+  //   // Tap the '+' icon and trigger a frame.
+  //   await tester.tap(find.byIcon(Icons.add));
+  //   await tester.pump();
 
-    // Verify that our counter has incremented.
-    expect(find.text('0'), findsNothing);
-    expect(find.text('1'), findsOneWidget);
-  });
+  //   // Verify that our counter has incremented.
+  //   expect(find.text('0'), findsNothing);
+  //   expect(find.text('1'), findsOneWidget);
+  // });
 }
diff --git a/test/common_test.dart b/test/common_test.dart
new file mode 100644
index 0000000..6f4ff2f
--- /dev/null
+++ b/test/common_test.dart
@@ -0,0 +1,41 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:google_maps_cluster_manager/src/common.dart';
+import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart';
+
+void main() {
+  group("test get_distance of coordinates", () {
+    test(
+        "should get dist of between 600m and 800m on call with close coordinates",
+        () {
+      LatLng start = LatLng(52.421327, 10.623056);
+      LatLng end = LatLng(52.42748887594039, 10.623379056822062);
+      final DistUtils utils = DistUtils();
+      double dist = utils.getDistanceFromLatLonInKm(
+          start.latitude, start.longitude, end.latitude, end.longitude);
+      print("dist is $dist");
+      expect(dist >= 0.6 && dist <= 0.8, true);
+    });
+
+    test(
+        "should get dist of between 75km and 80km on call with wider coordinates",
+        () {
+      LatLng start = LatLng(52.45175365359977, 10.679139941065786);
+      LatLng end = LatLng(51.7578902763405, 10.74257578002594);
+      final DistUtils utils = DistUtils();
+      double dist = utils.getDistanceFromLatLonInKm(
+          start.latitude, start.longitude, end.latitude, end.longitude);
+      print("dist is $dist");
+      expect(dist >= 75 && dist <= 80, true);
+    });
+
+    test("should map distance of 77km with zoomLevel to ", () {
+      LatLng start = LatLng(52.45175365359977, 10.679139941065786);
+      LatLng end = LatLng(51.7578902763405, 10.74257578002594);
+      final DistUtils utils = DistUtils();
+
+      double dist = utils.getLatLonDist(start, end, 16);
+      print("dist is $dist ${75 / 2.387 * 1000} ${80 / 2.387 * 1000}");
+      expect(dist >= 75 / 2.387 * 1000 && dist <= 80 / 2.387 * 1000, true);
+    });
+  });
+}
diff --git a/test/google_maps_cluster_manager_test.dart b/test/google_maps_cluster_manager_test.dart
deleted file mode 100644
index 21f3e9f..0000000
--- a/test/google_maps_cluster_manager_test.dart
+++ /dev/null
@@ -1,3 +0,0 @@
-void main() {
-  // TODO
-}